@build-astron-co/nimbus 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (469) hide show
  1. package/bin/nimbus +26 -10
  2. package/bin/nimbus.cmd +41 -0
  3. package/bin/nimbus.mjs +70 -0
  4. package/completions/nimbus.bash +38 -0
  5. package/completions/nimbus.fish +48 -0
  6. package/completions/nimbus.zsh +81 -0
  7. package/dist/src/agent/compaction-agent.js +215 -0
  8. package/dist/src/agent/context-manager.js +385 -0
  9. package/dist/src/agent/context.js +322 -0
  10. package/dist/src/agent/deploy-preview.js +395 -0
  11. package/dist/src/agent/expand-files.js +95 -0
  12. package/dist/src/agent/index.js +18 -0
  13. package/dist/src/agent/loop.js +1535 -0
  14. package/dist/src/agent/modes.js +347 -0
  15. package/dist/src/agent/permissions.js +396 -0
  16. package/dist/src/agent/subagents/base.js +67 -0
  17. package/dist/src/agent/subagents/cost.js +45 -0
  18. package/dist/src/agent/subagents/explore.js +36 -0
  19. package/dist/src/agent/subagents/general.js +41 -0
  20. package/dist/src/agent/subagents/index.js +88 -0
  21. package/dist/src/agent/subagents/infra.js +52 -0
  22. package/dist/src/agent/subagents/security.js +60 -0
  23. package/dist/src/agent/system-prompt.js +860 -0
  24. package/dist/src/app.js +152 -0
  25. package/dist/src/audit/activity-log.js +209 -0
  26. package/dist/src/audit/compliance-checker.js +419 -0
  27. package/dist/src/audit/cost-tracker.js +231 -0
  28. package/dist/src/audit/index.js +10 -0
  29. package/dist/src/audit/security-scanner.js +490 -0
  30. package/dist/src/auth/guard.js +64 -0
  31. package/dist/src/auth/index.js +19 -0
  32. package/dist/src/auth/keychain.js +79 -0
  33. package/dist/src/auth/oauth.js +389 -0
  34. package/dist/src/auth/providers.js +415 -0
  35. package/dist/src/auth/sso.js +87 -0
  36. package/dist/src/auth/store.js +424 -0
  37. package/dist/src/auth/types.js +5 -0
  38. package/dist/src/cli/index.js +8 -0
  39. package/dist/src/cli/init.js +1048 -0
  40. package/dist/src/cli/openapi-spec.js +346 -0
  41. package/dist/src/cli/run.js +505 -0
  42. package/dist/src/cli/serve-auth.js +56 -0
  43. package/dist/src/cli/serve.js +432 -0
  44. package/dist/src/cli/web.js +50 -0
  45. package/dist/src/cli.js +1574 -0
  46. package/dist/src/clients/core-engine-client.js +156 -0
  47. package/dist/src/clients/enterprise-client.js +246 -0
  48. package/dist/src/clients/generator-client.js +219 -0
  49. package/dist/src/clients/git-client.js +367 -0
  50. package/dist/src/clients/github-client.js +229 -0
  51. package/dist/src/clients/helm-client.js +299 -0
  52. package/dist/src/clients/index.js +18 -0
  53. package/dist/src/clients/k8s-client.js +270 -0
  54. package/dist/src/clients/llm-client.js +119 -0
  55. package/dist/src/clients/rest-client.js +104 -0
  56. package/dist/src/clients/service-discovery.js +35 -0
  57. package/dist/src/clients/terraform-client.js +302 -0
  58. package/dist/src/clients/tools-client.js +1227 -0
  59. package/dist/src/clients/ws-client.js +93 -0
  60. package/dist/src/commands/alias.js +91 -0
  61. package/dist/src/commands/analyze/index.js +313 -0
  62. package/dist/src/commands/apply/helm.js +375 -0
  63. package/dist/src/commands/apply/index.js +176 -0
  64. package/dist/src/commands/apply/k8s.js +350 -0
  65. package/dist/src/commands/apply/terraform.js +465 -0
  66. package/dist/src/commands/ask.js +137 -0
  67. package/dist/src/commands/audit/index.js +322 -0
  68. package/dist/src/commands/auth-cloud.js +345 -0
  69. package/dist/src/commands/auth-list.js +112 -0
  70. package/dist/src/commands/auth-profile.js +104 -0
  71. package/dist/src/commands/auth-refresh.js +161 -0
  72. package/dist/src/commands/auth-status.js +122 -0
  73. package/dist/src/commands/aws/ec2.js +402 -0
  74. package/dist/src/commands/aws/iam.js +304 -0
  75. package/dist/src/commands/aws/index.js +108 -0
  76. package/dist/src/commands/aws/lambda.js +317 -0
  77. package/dist/src/commands/aws/rds.js +345 -0
  78. package/dist/src/commands/aws/s3.js +346 -0
  79. package/dist/src/commands/aws/vpc.js +302 -0
  80. package/dist/src/commands/aws-discover.js +413 -0
  81. package/dist/src/commands/aws-terraform.js +618 -0
  82. package/dist/src/commands/azure/aks.js +305 -0
  83. package/dist/src/commands/azure/functions.js +200 -0
  84. package/dist/src/commands/azure/index.js +93 -0
  85. package/dist/src/commands/azure/storage.js +378 -0
  86. package/dist/src/commands/azure/vm.js +291 -0
  87. package/dist/src/commands/billing/index.js +224 -0
  88. package/dist/src/commands/chat.js +259 -0
  89. package/dist/src/commands/completions.js +255 -0
  90. package/dist/src/commands/config.js +291 -0
  91. package/dist/src/commands/cost/cloud-cost-estimator.js +211 -0
  92. package/dist/src/commands/cost/estimator.js +73 -0
  93. package/dist/src/commands/cost/index.js +625 -0
  94. package/dist/src/commands/cost/parsers/terraform.js +234 -0
  95. package/dist/src/commands/cost/parsers/types.js +4 -0
  96. package/dist/src/commands/cost/pricing/aws.js +501 -0
  97. package/dist/src/commands/cost/pricing/azure.js +462 -0
  98. package/dist/src/commands/cost/pricing/gcp.js +359 -0
  99. package/dist/src/commands/cost/pricing/index.js +24 -0
  100. package/dist/src/commands/demo.js +196 -0
  101. package/dist/src/commands/deploy.js +215 -0
  102. package/dist/src/commands/doctor.js +1291 -0
  103. package/dist/src/commands/drift/index.js +674 -0
  104. package/dist/src/commands/explain.js +235 -0
  105. package/dist/src/commands/export.js +120 -0
  106. package/dist/src/commands/feedback.js +319 -0
  107. package/dist/src/commands/fix.js +263 -0
  108. package/dist/src/commands/fs/index.js +338 -0
  109. package/dist/src/commands/gcp/compute.js +266 -0
  110. package/dist/src/commands/gcp/functions.js +221 -0
  111. package/dist/src/commands/gcp/gke.js +357 -0
  112. package/dist/src/commands/gcp/iam.js +295 -0
  113. package/dist/src/commands/gcp/index.js +105 -0
  114. package/dist/src/commands/gcp/storage.js +232 -0
  115. package/dist/src/commands/generate-helm.js +1026 -0
  116. package/dist/src/commands/generate-k8s.js +1263 -0
  117. package/dist/src/commands/generate-terraform.js +1058 -0
  118. package/dist/src/commands/gh/index.js +663 -0
  119. package/dist/src/commands/git/index.js +1208 -0
  120. package/dist/src/commands/helm/index.js +985 -0
  121. package/dist/src/commands/help.js +639 -0
  122. package/dist/src/commands/history.js +120 -0
  123. package/dist/src/commands/import.js +782 -0
  124. package/dist/src/commands/incident.js +144 -0
  125. package/dist/src/commands/index.js +109 -0
  126. package/dist/src/commands/init.js +955 -0
  127. package/dist/src/commands/k8s/index.js +979 -0
  128. package/dist/src/commands/login.js +588 -0
  129. package/dist/src/commands/logout.js +61 -0
  130. package/dist/src/commands/logs.js +160 -0
  131. package/dist/src/commands/onboarding.js +382 -0
  132. package/dist/src/commands/pipeline.js +153 -0
  133. package/dist/src/commands/plan/display.js +216 -0
  134. package/dist/src/commands/plan/index.js +525 -0
  135. package/dist/src/commands/plugin.js +325 -0
  136. package/dist/src/commands/preview.js +356 -0
  137. package/dist/src/commands/profile.js +297 -0
  138. package/dist/src/commands/questionnaire.js +1021 -0
  139. package/dist/src/commands/resume.js +35 -0
  140. package/dist/src/commands/rollback.js +259 -0
  141. package/dist/src/commands/rollout.js +74 -0
  142. package/dist/src/commands/runbook.js +307 -0
  143. package/dist/src/commands/schedule.js +202 -0
  144. package/dist/src/commands/status.js +213 -0
  145. package/dist/src/commands/team/index.js +309 -0
  146. package/dist/src/commands/team-context.js +200 -0
  147. package/dist/src/commands/template.js +204 -0
  148. package/dist/src/commands/tf/index.js +989 -0
  149. package/dist/src/commands/upgrade.js +515 -0
  150. package/dist/src/commands/usage/index.js +118 -0
  151. package/dist/src/commands/version.js +145 -0
  152. package/dist/src/commands/watch.js +127 -0
  153. package/dist/src/compat/index.js +2 -0
  154. package/dist/src/compat/runtime.js +10 -0
  155. package/dist/src/compat/sqlite.js +144 -0
  156. package/dist/src/config/index.js +6 -0
  157. package/dist/src/config/manager.js +469 -0
  158. package/dist/src/config/mode-store.js +57 -0
  159. package/dist/src/config/profiles.js +66 -0
  160. package/dist/src/config/safety-policy.js +251 -0
  161. package/dist/src/config/schema.js +107 -0
  162. package/dist/src/config/types.js +311 -0
  163. package/dist/src/config/workspace-state.js +38 -0
  164. package/dist/src/context/context-db.js +138 -0
  165. package/dist/src/demo/index.js +295 -0
  166. package/dist/src/demo/scenarios/full-journey.js +226 -0
  167. package/dist/src/demo/scenarios/getting-started.js +124 -0
  168. package/dist/src/demo/scenarios/helm-release.js +334 -0
  169. package/dist/src/demo/scenarios/k8s-deployment.js +190 -0
  170. package/dist/src/demo/scenarios/terraform-vpc.js +167 -0
  171. package/dist/src/demo/types.js +6 -0
  172. package/dist/src/engine/cost-estimator.js +334 -0
  173. package/dist/src/engine/diagram-generator.js +192 -0
  174. package/dist/src/engine/drift-detector.js +688 -0
  175. package/dist/src/engine/executor.js +832 -0
  176. package/dist/src/engine/index.js +39 -0
  177. package/dist/src/engine/orchestrator.js +436 -0
  178. package/dist/src/engine/planner.js +616 -0
  179. package/dist/src/engine/safety.js +609 -0
  180. package/dist/src/engine/verifier.js +664 -0
  181. package/dist/src/enterprise/audit.js +241 -0
  182. package/dist/src/enterprise/auth.js +189 -0
  183. package/dist/src/enterprise/billing.js +512 -0
  184. package/dist/src/enterprise/index.js +16 -0
  185. package/dist/src/enterprise/teams.js +315 -0
  186. package/dist/src/generator/best-practices.js +1375 -0
  187. package/dist/src/generator/helm.js +495 -0
  188. package/dist/src/generator/index.js +11 -0
  189. package/dist/src/generator/intent-parser.js +420 -0
  190. package/dist/src/generator/kubernetes.js +773 -0
  191. package/dist/src/generator/terraform.js +1472 -0
  192. package/dist/src/history/index.js +6 -0
  193. package/dist/src/history/manager.js +199 -0
  194. package/dist/src/history/types.js +6 -0
  195. package/dist/src/hooks/config.js +318 -0
  196. package/dist/src/hooks/engine.js +317 -0
  197. package/dist/src/hooks/index.js +2 -0
  198. package/dist/src/llm/auth-bridge.js +157 -0
  199. package/dist/src/llm/circuit-breaker.js +116 -0
  200. package/dist/src/llm/config-loader.js +172 -0
  201. package/dist/src/llm/cost-calculator.js +137 -0
  202. package/dist/src/llm/index.js +7 -0
  203. package/dist/src/llm/model-aliases.js +99 -0
  204. package/dist/src/llm/provider-registry.js +57 -0
  205. package/dist/src/llm/providers/anthropic.js +430 -0
  206. package/dist/src/llm/providers/bedrock.js +409 -0
  207. package/dist/src/llm/providers/google.js +344 -0
  208. package/dist/src/llm/providers/ollama.js +661 -0
  209. package/dist/src/llm/providers/openai-compatible.js +289 -0
  210. package/dist/src/llm/providers/openai.js +284 -0
  211. package/dist/src/llm/providers/openrouter.js +293 -0
  212. package/dist/src/llm/router.js +844 -0
  213. package/dist/src/llm/types.js +69 -0
  214. package/dist/src/lsp/client.js +239 -0
  215. package/dist/src/lsp/languages.js +95 -0
  216. package/dist/src/lsp/manager.js +243 -0
  217. package/dist/src/mcp/client.js +289 -0
  218. package/dist/src/mcp/index.js +5 -0
  219. package/dist/src/mcp/manager.js +113 -0
  220. package/dist/src/nimbus.js +212 -0
  221. package/dist/src/plugins/index.js +13 -0
  222. package/dist/src/plugins/loader.js +280 -0
  223. package/dist/src/plugins/manager.js +282 -0
  224. package/dist/src/plugins/types.js +23 -0
  225. package/dist/src/scanners/cicd-scanner.js +230 -0
  226. package/dist/src/scanners/cloud-scanner.js +415 -0
  227. package/dist/src/scanners/framework-scanner.js +430 -0
  228. package/dist/src/scanners/iac-scanner.js +350 -0
  229. package/dist/src/scanners/index.js +454 -0
  230. package/dist/src/scanners/language-scanner.js +258 -0
  231. package/dist/src/scanners/package-manager-scanner.js +252 -0
  232. package/dist/src/scanners/types.js +6 -0
  233. package/dist/src/sessions/manager.js +395 -0
  234. package/dist/src/sessions/types.js +4 -0
  235. package/dist/src/sharing/sync.js +238 -0
  236. package/dist/src/sharing/viewer.js +131 -0
  237. package/dist/src/snapshots/index.js +1 -0
  238. package/dist/src/snapshots/manager.js +432 -0
  239. package/dist/src/state/artifacts.js +94 -0
  240. package/dist/src/state/audit.js +73 -0
  241. package/dist/src/state/billing.js +126 -0
  242. package/dist/src/state/checkpoints.js +81 -0
  243. package/dist/src/state/config.js +58 -0
  244. package/dist/src/state/conversations.js +7 -0
  245. package/dist/src/state/credentials.js +96 -0
  246. package/dist/src/state/db.js +53 -0
  247. package/dist/src/state/index.js +23 -0
  248. package/dist/src/state/messages.js +76 -0
  249. package/dist/src/state/projects.js +92 -0
  250. package/dist/src/state/schema.js +233 -0
  251. package/dist/src/state/sessions.js +79 -0
  252. package/dist/src/state/teams.js +131 -0
  253. package/dist/src/telemetry.js +91 -0
  254. package/dist/src/tools/aws-ops.js +747 -0
  255. package/dist/src/tools/azure-ops.js +491 -0
  256. package/dist/src/tools/file-ops.js +451 -0
  257. package/dist/src/tools/gcp-ops.js +559 -0
  258. package/dist/src/tools/git-ops.js +557 -0
  259. package/dist/src/tools/github-ops.js +460 -0
  260. package/dist/src/tools/helm-ops.js +634 -0
  261. package/dist/src/tools/index.js +16 -0
  262. package/dist/src/tools/k8s-ops.js +579 -0
  263. package/dist/src/tools/schemas/converter.js +129 -0
  264. package/dist/src/tools/schemas/devops.js +3319 -0
  265. package/dist/src/tools/schemas/index.js +19 -0
  266. package/dist/src/tools/schemas/standard.js +966 -0
  267. package/dist/src/tools/schemas/types.js +409 -0
  268. package/dist/src/tools/spawn-exec.js +109 -0
  269. package/dist/src/tools/terraform-ops.js +627 -0
  270. package/dist/src/types/config.js +1 -0
  271. package/dist/src/types/drift.js +4 -0
  272. package/dist/src/types/enterprise.js +5 -0
  273. package/dist/src/types/index.js +14 -0
  274. package/dist/src/types/plan.js +1 -0
  275. package/dist/src/types/request.js +1 -0
  276. package/dist/src/types/response.js +1 -0
  277. package/dist/src/types/service.js +1 -0
  278. package/dist/src/ui/App.js +1672 -0
  279. package/dist/src/ui/DeployPreview.js +60 -0
  280. package/dist/src/ui/FileDiffModal.js +108 -0
  281. package/dist/src/ui/Header.js +46 -0
  282. package/dist/src/ui/HelpModal.js +9 -0
  283. package/dist/src/ui/InputBox.js +408 -0
  284. package/dist/src/ui/MessageList.js +795 -0
  285. package/dist/src/ui/PermissionPrompt.js +72 -0
  286. package/dist/src/ui/StatusBar.js +109 -0
  287. package/dist/src/ui/TerminalPane.js +31 -0
  288. package/dist/src/ui/ToolCallDisplay.js +303 -0
  289. package/dist/src/ui/TreePane.js +83 -0
  290. package/dist/src/ui/chat-ui.js +721 -0
  291. package/dist/src/ui/index.js +11 -0
  292. package/dist/src/ui/ink/index.js +1325 -0
  293. package/dist/src/ui/streaming.js +137 -0
  294. package/dist/src/ui/theme.js +78 -0
  295. package/dist/src/ui/types.js +7 -0
  296. package/dist/src/utils/analytics.js +61 -0
  297. package/dist/src/utils/cost-warning.js +25 -0
  298. package/dist/src/utils/env.js +42 -0
  299. package/dist/src/utils/errors.js +54 -0
  300. package/dist/src/utils/event-bus.js +22 -0
  301. package/dist/src/utils/index.js +16 -0
  302. package/dist/src/utils/logger.js +150 -0
  303. package/dist/src/utils/rate-limiter.js +90 -0
  304. package/dist/src/utils/service-auth.js +36 -0
  305. package/dist/src/utils/validation.js +39 -0
  306. package/dist/src/version.js +3 -0
  307. package/dist/src/watcher/index.js +192 -0
  308. package/dist/src/wizard/approval.js +275 -0
  309. package/dist/src/wizard/index.js +13 -0
  310. package/dist/src/wizard/prompts.js +273 -0
  311. package/dist/src/wizard/types.js +4 -0
  312. package/dist/src/wizard/ui.js +453 -0
  313. package/dist/src/wizard/wizard.js +227 -0
  314. package/package.json +31 -23
  315. package/src/__tests__/alias.test.ts +133 -0
  316. package/src/__tests__/app.test.ts +1 -1
  317. package/src/__tests__/audit.test.ts +1 -1
  318. package/src/__tests__/circuit-breaker.test.ts +1 -1
  319. package/src/__tests__/cli-run.test.ts +237 -1
  320. package/src/__tests__/compat-sqlite.test.ts +68 -0
  321. package/src/__tests__/context-manager.test.ts +131 -1
  322. package/src/__tests__/context.test.ts +1 -1
  323. package/src/__tests__/devops-terminal-gaps.test.ts +718 -0
  324. package/src/__tests__/doctor.test.ts +48 -0
  325. package/src/__tests__/enterprise.test.ts +1 -1
  326. package/src/__tests__/export.test.ts +236 -0
  327. package/src/__tests__/gap-11-18-20.test.ts +958 -0
  328. package/src/__tests__/generator.test.ts +1 -1
  329. package/src/__tests__/helm-streaming.test.ts +127 -0
  330. package/src/__tests__/hooks.test.ts +1 -1
  331. package/src/__tests__/incident.test.ts +179 -0
  332. package/src/__tests__/init.test.ts +55 -4
  333. package/src/__tests__/intent-parser.test.ts +1 -1
  334. package/src/__tests__/llm-router.test.ts +1 -1
  335. package/src/__tests__/logs.test.ts +107 -0
  336. package/src/__tests__/loop-errors.test.ts +244 -0
  337. package/src/__tests__/lsp.test.ts +1 -1
  338. package/src/__tests__/modes.test.ts +1 -1
  339. package/src/__tests__/perf-optimizations.test.ts +847 -0
  340. package/src/__tests__/permissions.test.ts +1 -1
  341. package/src/__tests__/pipeline.test.ts +50 -0
  342. package/src/__tests__/polish-phase3.test.ts +340 -0
  343. package/src/__tests__/profile.test.ts +237 -0
  344. package/src/__tests__/rollback.test.ts +83 -0
  345. package/src/__tests__/runbook.test.ts +219 -0
  346. package/src/__tests__/schedule.test.ts +206 -0
  347. package/src/__tests__/serve.test.ts +1 -1
  348. package/src/__tests__/sessions.test.ts +96 -1
  349. package/src/__tests__/sharing.test.ts +53 -1
  350. package/src/__tests__/snapshots.test.ts +1 -1
  351. package/src/__tests__/standalone-migration.test.ts +199 -0
  352. package/src/__tests__/state-db.test.ts +1 -1
  353. package/src/__tests__/status.test.ts +158 -0
  354. package/src/__tests__/stream-with-tools.test.ts +71 -25
  355. package/src/__tests__/subagents.test.ts +1 -1
  356. package/src/__tests__/system-prompt.test.ts +82 -3
  357. package/src/__tests__/terminal-gap-v2.test.ts +395 -0
  358. package/src/__tests__/terminal-parity.test.ts +393 -0
  359. package/src/__tests__/tf-apply.test.ts +187 -0
  360. package/src/__tests__/tool-converter.test.ts +1 -1
  361. package/src/__tests__/tool-schemas.test.ts +209 -4
  362. package/src/__tests__/tools.test.ts +4 -3
  363. package/src/__tests__/version-json.test.ts +184 -0
  364. package/src/__tests__/version.test.ts +1 -1
  365. package/src/__tests__/watch.test.ts +129 -0
  366. package/src/agent/compaction-agent.ts +40 -1
  367. package/src/agent/context-manager.ts +67 -3
  368. package/src/agent/deploy-preview.ts +62 -1
  369. package/src/agent/expand-files.ts +108 -0
  370. package/src/agent/loop.ts +1312 -31
  371. package/src/agent/permissions.ts +51 -4
  372. package/src/agent/system-prompt.ts +573 -19
  373. package/src/app.ts +58 -0
  374. package/src/audit/security-scanner.ts +45 -0
  375. package/src/auth/keychain.ts +82 -0
  376. package/src/auth/oauth.ts +15 -5
  377. package/src/cli/init.ts +378 -5
  378. package/src/cli/run.ts +407 -16
  379. package/src/cli/serve.ts +78 -1
  380. package/src/cli/web.ts +10 -6
  381. package/src/cli.ts +312 -1
  382. package/src/clients/service-discovery.ts +30 -25
  383. package/src/commands/alias.ts +100 -0
  384. package/src/commands/audit/index.ts +121 -2
  385. package/src/commands/auth-cloud.ts +113 -0
  386. package/src/commands/auth-refresh.ts +187 -0
  387. package/src/commands/aws-discover.ts +144 -251
  388. package/src/commands/aws-terraform.ts +68 -118
  389. package/src/commands/chat.ts +9 -3
  390. package/src/commands/completions.ts +268 -0
  391. package/src/commands/config.ts +26 -0
  392. package/src/commands/cost/index.ts +218 -2
  393. package/src/commands/deploy.ts +260 -0
  394. package/src/commands/doctor.ts +744 -152
  395. package/src/commands/drift/index.ts +371 -23
  396. package/src/commands/export.ts +146 -0
  397. package/src/commands/generate-k8s.ts +9 -61
  398. package/src/commands/generate-terraform.ts +191 -449
  399. package/src/commands/help.ts +212 -36
  400. package/src/commands/history.ts +8 -1
  401. package/src/commands/incident.ts +166 -0
  402. package/src/commands/init.ts +5 -0
  403. package/src/commands/login.ts +86 -1
  404. package/src/commands/logs.ts +167 -0
  405. package/src/commands/onboarding.ts +211 -34
  406. package/src/commands/pipeline.ts +186 -0
  407. package/src/commands/plugin.ts +398 -0
  408. package/src/commands/profile.ts +342 -0
  409. package/src/commands/questionnaire.ts +0 -98
  410. package/src/commands/resume.ts +26 -34
  411. package/src/commands/rollback.ts +315 -0
  412. package/src/commands/rollout.ts +88 -0
  413. package/src/commands/runbook.ts +346 -0
  414. package/src/commands/schedule.ts +236 -0
  415. package/src/commands/status.ts +252 -0
  416. package/src/commands/team-context.ts +220 -0
  417. package/src/commands/template.ts +58 -57
  418. package/src/commands/tf/index.ts +70 -11
  419. package/src/commands/upgrade.ts +57 -0
  420. package/src/commands/version.ts +54 -50
  421. package/src/commands/watch.ts +153 -0
  422. package/src/compat/runtime.ts +1 -1
  423. package/src/compat/sqlite.ts +75 -5
  424. package/src/config/mode-store.ts +62 -0
  425. package/src/config/profiles.ts +84 -0
  426. package/src/config/types.ts +83 -1
  427. package/src/config/workspace-state.ts +53 -0
  428. package/src/engine/cost-estimator.ts +52 -10
  429. package/src/engine/executor.ts +33 -2
  430. package/src/engine/planner.ts +68 -1
  431. package/src/generator/terraform.ts +8 -0
  432. package/src/history/manager.ts +2 -74
  433. package/src/hooks/engine.ts +5 -4
  434. package/src/llm/cost-calculator.ts +2 -2
  435. package/src/llm/providers/anthropic.ts +50 -21
  436. package/src/llm/router.ts +76 -7
  437. package/src/lsp/languages.ts +3 -0
  438. package/src/lsp/manager.ts +21 -5
  439. package/src/nimbus.ts +37 -18
  440. package/src/sessions/manager.ts +108 -1
  441. package/src/sharing/sync.ts +4 -0
  442. package/src/sharing/viewer.ts +66 -0
  443. package/src/tools/file-ops.ts +22 -0
  444. package/src/tools/schemas/devops.ts +3007 -117
  445. package/src/tools/schemas/standard.ts +5 -1
  446. package/src/tools/schemas/types.ts +31 -1
  447. package/src/tools/spawn-exec.ts +148 -0
  448. package/src/ui/App.tsx +1183 -66
  449. package/src/ui/DeployPreview.tsx +62 -57
  450. package/src/ui/FileDiffModal.tsx +162 -0
  451. package/src/ui/Header.tsx +87 -24
  452. package/src/ui/HelpModal.tsx +57 -0
  453. package/src/ui/InputBox.tsx +163 -10
  454. package/src/ui/MessageList.tsx +487 -40
  455. package/src/ui/PermissionPrompt.tsx +17 -5
  456. package/src/ui/StatusBar.tsx +122 -3
  457. package/src/ui/TerminalPane.tsx +84 -0
  458. package/src/ui/ToolCallDisplay.tsx +252 -18
  459. package/src/ui/TreePane.tsx +132 -0
  460. package/src/ui/chat-ui.ts +41 -44
  461. package/src/ui/ink/index.ts +771 -38
  462. package/src/ui/streaming.ts +1 -1
  463. package/src/ui/theme.ts +104 -0
  464. package/src/ui/types.ts +18 -0
  465. package/src/version.ts +1 -1
  466. package/src/watcher/index.ts +66 -15
  467. package/src/wizard/types.ts +1 -0
  468. package/src/wizard/ui.ts +1 -1
  469. package/tsconfig.json +2 -2
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * DevOps Tool Definitions
3
3
  *
4
- * Defines the 9 DevOps-specific tools available to the Nimbus agentic loop.
4
+ * Defines the 12 DevOps-specific tools available to the Nimbus agentic loop.
5
5
  * Each tool wraps existing infrastructure operations from `src/tools/` modules
6
6
  * or invokes the appropriate CLI via child_process.
7
7
  *
8
8
  * Tools:
9
9
  * terraform, kubectl, helm, cloud_discover, cost_estimate,
10
- * drift_detect, deploy_preview, git, task
10
+ * drift_detect, deploy_preview, terraform_plan_analyze,
11
+ * kubectl_context, helm_values, git, task
11
12
  *
12
13
  * @module tools/schemas/devops
13
14
  */
@@ -15,10 +16,19 @@
15
16
  import { z } from 'zod';
16
17
  import { exec } from 'node:child_process';
17
18
  import { promisify } from 'node:util';
19
+ import { existsSync, unlinkSync } from 'node:fs';
20
+ import { join as pathJoin } from 'node:path';
18
21
  import type { ToolDefinition, ToolResult } from './types';
22
+ import { spawnExec } from '../spawn-exec';
19
23
 
20
24
  const execAsync = promisify(exec);
21
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
+
22
32
  // ---------------------------------------------------------------------------
23
33
  // Helpers
24
34
  // ---------------------------------------------------------------------------
@@ -51,61 +61,294 @@ function errorMessage(error: unknown): string {
51
61
  return error instanceof Error ? error.message : String(error);
52
62
  }
53
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
+
54
153
  // ---------------------------------------------------------------------------
55
154
  // 1. terraform
56
155
  // ---------------------------------------------------------------------------
57
156
 
58
157
  const terraformSchema = z.object({
59
158
  action: z
60
- .enum(['init', 'plan', 'apply', 'validate', 'fmt', 'destroy', 'import', 'state'])
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
+ ])
61
165
  .describe('The Terraform sub-command to run'),
62
166
  workdir: z.string().describe('Working directory containing the Terraform configuration'),
63
167
  args: z.string().optional().describe('Additional CLI arguments'),
64
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)'),
65
174
  });
66
175
 
67
176
  export const terraformTool: ToolDefinition = {
68
177
  name: 'terraform',
69
178
  description:
70
- 'Execute Terraform operations. Supports init, plan, apply, validate, fmt, destroy, import, and state commands.',
179
+ 'Execute Terraform operations. Supports init, plan, apply, validate, fmt, destroy, import, state, output, workspace, providers, graph, and force-unlock commands.',
71
180
  inputSchema: terraformSchema,
72
181
  permissionTier: 'always_ask',
73
182
  category: 'devops',
74
183
  isDestructive: true,
75
184
 
76
- async execute(raw: unknown): Promise<ToolResult> {
185
+ async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
77
186
  try {
78
187
  const input = terraformSchema.parse(raw);
79
188
 
80
- const parts: string[] = ['terraform', `-chdir=${input.workdir}`, input.action];
189
+ // C2: If no workspace specified but session has a workspace context, note it in output
190
+ const sessionWorkspace = ctx?.infraContext?.terraformWorkspace;
81
191
 
82
- if (input.var_file) {
83
- parts.push(`-var-file=${input.var_file}`);
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
+ }
84
207
  }
85
208
 
86
- // Auto-approve for apply/destroy -- the permission engine handles
87
- // user confirmation before execute() is ever called.
88
- if (input.action === 'apply' || input.action === 'destroy') {
89
- parts.push('-auto-approve');
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
+ }
90
221
  }
91
222
 
92
- // Add -no-color for cleaner output in non-TTY contexts.
93
- if (['plan', 'apply', 'destroy', 'init'].includes(input.action)) {
94
- parts.push('-no-color');
95
- }
223
+ // Build the terraform command
224
+ let command: string;
96
225
 
97
- if (input.args) {
98
- parts.push(input.args);
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(' ');
99
301
  }
100
302
 
101
- const command = parts.join(' ');
102
- const { stdout, stderr } = await execAsync(command, {
103
- timeout: 600_000, // 10 minutes
104
- maxBuffer: 10 * 1024 * 1024,
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
105
308
  });
106
309
 
107
- const combined = [stdout, stderr].filter(Boolean).join('\n');
108
- return ok(combined || '(no output)');
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)');
109
352
  } catch (error: unknown) {
110
353
  return err(`Terraform command failed: ${errorMessage(error)}`);
111
354
  }
@@ -118,11 +361,20 @@ export const terraformTool: ToolDefinition = {
118
361
 
119
362
  const kubectlSchema = z.object({
120
363
  action: z
121
- .enum(['get', 'apply', 'delete', 'logs', 'scale', 'rollout', 'exec', 'describe'])
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
+ ])
122
369
  .describe('The kubectl sub-command to run'),
123
370
  resource: z.string().optional().describe('Resource type and/or name (e.g., "pods my-pod")'),
124
371
  namespace: z.string().optional().describe('Kubernetes namespace'),
125
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)'),
126
378
  });
127
379
 
128
380
  export const kubectlTool: ToolDefinition = {
@@ -133,31 +385,103 @@ export const kubectlTool: ToolDefinition = {
133
385
  category: 'devops',
134
386
  isDestructive: true,
135
387
 
136
- async execute(raw: unknown): Promise<ToolResult> {
388
+ async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
137
389
  try {
138
390
  const input = kubectlSchema.parse(raw);
139
391
 
140
- const parts: string[] = ['kubectl', input.action];
392
+ // C2: Use session infraContext as kubectl context fallback
393
+ const contextFlag = ctx?.infraContext?.kubectlContext
394
+ ? `--context=${ctx.infraContext.kubectlContext} `
395
+ : '';
141
396
 
142
- if (input.resource) {
143
- parts.push(input.resource);
144
- }
397
+ const parts: string[] = ['kubectl', input.action];
145
398
 
146
- if (input.namespace) {
147
- parts.push('-n', input.namespace);
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
+ }
148
457
  }
149
458
 
150
- if (input.args) {
151
- parts.push(input.args);
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)');
152
472
  }
153
-
154
- const command = parts.join(' ');
473
+ const cmdEnv = { ...process.env, ...(input.env ?? {}) } as NodeJS.ProcessEnv;
155
474
  const { stdout, stderr } = await execAsync(command, {
156
475
  timeout: 120_000,
157
476
  maxBuffer: 10 * 1024 * 1024,
477
+ env: cmdEnv,
158
478
  });
159
479
 
160
- const combined = [stdout, stderr].filter(Boolean).join('\n');
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
+ }
161
485
  return ok(combined || '(no output)');
162
486
  } catch (error: unknown) {
163
487
  return err(`kubectl command failed: ${errorMessage(error)}`);
@@ -171,14 +495,28 @@ export const kubectlTool: ToolDefinition = {
171
495
 
172
496
  const helmSchema = z.object({
173
497
  action: z
174
- .enum(['install', 'upgrade', 'uninstall', 'list', 'rollback', 'template', 'lint'])
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
+ ])
175
505
  .describe('The Helm sub-command to run'),
176
506
  release: z.string().optional().describe('Helm release name'),
177
507
  chart: z.string().optional().describe('Chart reference (e.g., "bitnami/nginx")'),
178
- values: z.string().optional().describe('Path to a values.yaml file'),
508
+ values: z.string().optional().describe('Path to a values.yaml or SOPS-encrypted values file'),
179
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'),
180
514
  });
181
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
+
182
520
  export const helmTool: ToolDefinition = {
183
521
  name: 'helm',
184
522
  description: 'Execute Helm operations for Kubernetes package management.',
@@ -187,10 +525,140 @@ export const helmTool: ToolDefinition = {
187
525
  category: 'devops',
188
526
  isDestructive: true,
189
527
 
190
- async execute(raw: unknown): Promise<ToolResult> {
528
+ async execute(raw: unknown, ctx?: import('./types').ToolExecuteContext): Promise<ToolResult> {
191
529
  try {
192
530
  const input = helmSchema.parse(raw);
193
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
+
194
662
  const parts: string[] = ['helm', input.action];
195
663
 
196
664
  if (input.release) {
@@ -210,9 +678,21 @@ export const helmTool: ToolDefinition = {
210
678
  }
211
679
 
212
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;
213
692
  const { stdout, stderr } = await execAsync(command, {
214
693
  timeout: 300_000, // 5 minutes
215
694
  maxBuffer: 10 * 1024 * 1024,
695
+ env: helmEnv,
216
696
  });
217
697
 
218
698
  const combined = [stdout, stderr].filter(Boolean).join('\n');
@@ -231,8 +711,11 @@ const cloudDiscoverSchema = z.object({
231
711
  provider: z.enum(['aws', 'gcp', 'azure']).describe('Cloud provider to discover resources from'),
232
712
  resource_type: z
233
713
  .string()
234
- .describe('Resource type to discover (e.g., "ec2", "compute instances", "vm")'),
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
+ ),
235
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)'),
236
719
  });
237
720
 
238
721
  export const cloudDiscoverTool: ToolDefinition = {
@@ -247,17 +730,66 @@ export const cloudDiscoverTool: ToolDefinition = {
247
730
  try {
248
731
  const input = cloudDiscoverSchema.parse(raw);
249
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
+
250
780
  let command: string;
251
781
 
252
782
  switch (input.provider) {
253
783
  case 'aws': {
254
784
  const regionFlag = input.region ? ` --region ${input.region}` : '';
255
- command = `aws ${input.resource_type} describe-instances${regionFlag} --output json`;
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`;
256
787
  break;
257
788
  }
258
789
  case 'gcp': {
259
790
  const regionFlag = input.region ? ` --regions=${input.region}` : '';
260
- command = `gcloud ${input.resource_type} list${regionFlag} --format json`;
791
+ // resource_type is the full subcommand, e.g. "compute instances list", "container clusters list"
792
+ command = `gcloud ${input.resource_type}${regionFlag} --format json`;
261
793
  break;
262
794
  }
263
795
  case 'azure': {
@@ -272,7 +804,99 @@ export const cloudDiscoverTool: ToolDefinition = {
272
804
  });
273
805
 
274
806
  const combined = [stdout, stderr].filter(Boolean).join('\n');
275
- return ok(combined || '(no resources found)');
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
+ }
276
900
  } catch (error: unknown) {
277
901
  return err(`Cloud discovery failed: ${errorMessage(error)}`);
278
902
  }
@@ -286,11 +910,20 @@ export const cloudDiscoverTool: ToolDefinition = {
286
910
  const costEstimateSchema = z.object({
287
911
  plan_file: z.string().optional().describe('Path to a saved Terraform plan file'),
288
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'),
289
922
  });
290
923
 
291
924
  export const costEstimateTool: ToolDefinition = {
292
925
  name: 'cost_estimate',
293
- description: 'Estimate infrastructure costs based on a Terraform plan or working directory.',
926
+ description: 'Estimate infrastructure costs, compare across providers, check savings plans, rightsizing, or budgets.',
294
927
  inputSchema: costEstimateSchema,
295
928
  permissionTier: 'auto_allow',
296
929
  category: 'devops',
@@ -299,6 +932,116 @@ export const costEstimateTool: ToolDefinition = {
299
932
  try {
300
933
  const input = costEstimateSchema.parse(raw);
301
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
+
302
1045
  if (!input.plan_file && !input.workdir) {
303
1046
  return err('Either plan_file or workdir must be provided.');
304
1047
  }
@@ -306,6 +1049,31 @@ export const costEstimateTool: ToolDefinition = {
306
1049
  const cwd = input.workdir ?? '.';
307
1050
  const planArg = input.plan_file ?? '';
308
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
+
309
1077
  // Attempt to extract resource information from a Terraform plan.
310
1078
  const showCommand = planArg
311
1079
  ? `terraform show -json ${planArg}`
@@ -335,14 +1103,39 @@ export const costEstimateTool: ToolDefinition = {
335
1103
  );
336
1104
  }
337
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
+
338
1127
  const lines = [
339
- '--- Cost Estimate (Placeholder) ---',
1128
+ '--- Cost Estimate (Built-in Pricing Tables) ---',
340
1129
  '',
341
1130
  `Total resources: ${resourceCount}`,
342
- `Resource types: ${resourceTypes.join(', ') || 'none'}`,
1131
+ `Estimated monthly cost: ~$${estimatedMonthly}/month`,
1132
+ `Estimated annual cost: ~$${estimatedMonthly * 12}/year`,
343
1133
  '',
344
- 'Note: Accurate cost estimation requires integration with a pricing API',
345
- 'such as Infracost. This is a resource-count summary only.',
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.',
346
1139
  ];
347
1140
 
348
1141
  return ok(lines.join('\n'));
@@ -405,29 +1198,119 @@ export const driftDetectTool: ToolDefinition = {
405
1198
  }
406
1199
 
407
1200
  case 'kubernetes': {
408
- // Use kubectl diff to detect drift in Kubernetes manifests.
409
- const command = `kubectl diff -f ${input.workdir} 2>&1 || true`;
410
- const { stdout } = await execAsync(command, {
411
- timeout: 120_000,
412
- maxBuffer: 10 * 1024 * 1024,
413
- });
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
+ }
414
1278
 
415
- if (!stdout.trim()) {
1279
+ if (results.length === 0) {
416
1280
  return ok('No drift detected in Kubernetes resources.');
417
1281
  }
418
- return ok(`DRIFT DETECTED\n\n${stdout}`);
1282
+ return ok(`DRIFT DETECTED\n\n${results.join('\n\n')}`);
419
1283
  }
420
1284
 
421
1285
  case 'helm': {
422
- // Use helm diff plugin if available, otherwise fall back to helm get.
423
- const command = `helm list -A --output json`;
424
- const { stdout } = await execAsync(command, {
425
- timeout: 120_000,
426
- maxBuffer: 10 * 1024 * 1024,
427
- });
428
- return ok(
429
- `Helm releases:\n${stdout}\n\nNote: Install the helm-diff plugin for detailed drift detection.`
430
- );
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
+ }
431
1314
  }
432
1315
  }
433
1316
  } catch (error: unknown) {
@@ -498,89 +1381,335 @@ export const deployPreviewTool: ToolDefinition = {
498
1381
  };
499
1382
 
500
1383
  // ---------------------------------------------------------------------------
501
- // 8. git
1384
+ // 7b. terraform_plan_analyze
502
1385
  // ---------------------------------------------------------------------------
503
1386
 
504
- const gitSchema = z.object({
505
- action: z
506
- .enum(['status', 'add', 'commit', 'push', 'pull', 'branch', 'checkout', 'diff', 'log'])
507
- .describe('The git sub-command to run'),
508
- args: z.string().optional().describe('Additional CLI arguments'),
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'),
509
1390
  });
510
1391
 
511
- export const gitTool: ToolDefinition = {
512
- name: 'git',
513
- description:
514
- 'Execute git operations. Supports status, add, commit, push, pull, branch, checkout, diff, and log.',
515
- inputSchema: gitSchema,
516
- permissionTier: 'ask_once',
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',
517
1397
  category: 'devops',
518
- isDestructive: true,
519
1398
 
520
1399
  async execute(raw: unknown): Promise<ToolResult> {
521
1400
  try {
522
- const input = gitSchema.parse(raw);
523
-
524
- const parts: string[] = ['git', input.action];
1401
+ const input = terraformPlanAnalyzeSchema.parse(raw);
525
1402
 
526
- if (input.args) {
527
- parts.push(input.args);
1403
+ if (!input.plan_file && !input.workdir) {
1404
+ return err('Either plan_file or workdir must be provided.');
528
1405
  }
529
1406
 
530
- const command = parts.join(' ');
531
- const { stdout, stderr } = await execAsync(command, {
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, {
532
1412
  timeout: 60_000,
533
1413
  maxBuffer: 10 * 1024 * 1024,
534
1414
  });
535
1415
 
536
- const combined = [stdout, stderr].filter(Boolean).join('\n');
537
- return ok(combined || '(no output)');
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'));
538
1471
  } catch (error: unknown) {
539
- return err(`git command failed: ${errorMessage(error)}`);
1472
+ return err(`Terraform plan analysis failed: ${errorMessage(error)}`);
540
1473
  }
541
1474
  },
542
1475
  };
543
1476
 
544
1477
  // ---------------------------------------------------------------------------
545
- // 9. task (subagent)
1478
+ // 10. kubectl_context
546
1479
  // ---------------------------------------------------------------------------
547
1480
 
548
- const taskSchema = z.object({
549
- prompt: z.string().describe('The task for the subagent to perform'),
550
- agent: z
551
- .enum(['explore', 'infra', 'security', 'cost', 'general'])
552
- .optional()
553
- .default('general')
554
- .describe('Subagent specialization to handle the task (default: general)'),
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)'),
555
1486
  });
556
1487
 
557
- export const taskTool: ToolDefinition = {
558
- name: 'task',
559
- description:
560
- '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.',
561
- inputSchema: taskSchema,
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,
562
1492
  permissionTier: 'auto_allow',
563
1493
  category: 'devops',
564
1494
 
565
1495
  async execute(raw: unknown): Promise<ToolResult> {
566
1496
  try {
567
- const input = taskSchema.parse(raw);
1497
+ const input = kubectlContextSchema.parse(raw);
568
1498
 
569
- // Get the LLM router from the app context
570
- const { getAppContext } = await import('../../app');
571
- const ctx = getAppContext();
572
- if (!ctx) {
573
- return err('App not initialised. Cannot spawn subagent.');
574
- }
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
+ }
575
1514
 
576
- // Create and run the appropriate subagent
577
- const { createSubagent } = await import('../../agent/subagents/index');
578
- const subagent = createSubagent(input.agent as any);
579
- const result = await subagent.run(input.prompt, ctx.router);
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
+ }
580
1519
 
581
- const header = [
582
- `[Subagent: ${input.agent}]`,
583
- `Turns: ${result.turns} | Tokens: ${result.totalTokens}`,
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}`,
584
1713
  result.interrupted ? '(interrupted)' : '',
585
1714
  '---',
586
1715
  ]
@@ -594,11 +1723,1753 @@ export const taskTool: ToolDefinition = {
594
1723
  },
595
1724
  };
596
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
+
597
3468
  // ---------------------------------------------------------------------------
598
3469
  // Aggregate export
599
3470
  // ---------------------------------------------------------------------------
600
3471
 
601
- /** All 9 DevOps tools as an ordered array. */
3472
+ /** All 28 DevOps tools as an ordered array. */
602
3473
  export const devopsTools: ToolDefinition[] = [
603
3474
  terraformTool,
604
3475
  kubectlTool,
@@ -607,6 +3478,25 @@ export const devopsTools: ToolDefinition[] = [
607
3478
  costEstimateTool,
608
3479
  driftDetectTool,
609
3480
  deployPreviewTool,
3481
+ terraformPlanAnalyzeTool,
3482
+ kubectlContextTool,
3483
+ helmValuesTool,
610
3484
  gitTool,
611
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,
612
3502
  ];