PraisonAI 3.0.0__py3-none-any.whl

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 (393) hide show
  1. praisonai/__init__.py +54 -0
  2. praisonai/__main__.py +15 -0
  3. praisonai/acp/__init__.py +54 -0
  4. praisonai/acp/config.py +159 -0
  5. praisonai/acp/server.py +587 -0
  6. praisonai/acp/session.py +219 -0
  7. praisonai/adapters/__init__.py +50 -0
  8. praisonai/adapters/readers.py +395 -0
  9. praisonai/adapters/rerankers.py +315 -0
  10. praisonai/adapters/retrievers.py +394 -0
  11. praisonai/adapters/vector_stores.py +409 -0
  12. praisonai/agent_scheduler.py +337 -0
  13. praisonai/agents_generator.py +903 -0
  14. praisonai/api/call.py +292 -0
  15. praisonai/auto.py +1197 -0
  16. praisonai/capabilities/__init__.py +275 -0
  17. praisonai/capabilities/a2a.py +140 -0
  18. praisonai/capabilities/assistants.py +283 -0
  19. praisonai/capabilities/audio.py +320 -0
  20. praisonai/capabilities/batches.py +469 -0
  21. praisonai/capabilities/completions.py +336 -0
  22. praisonai/capabilities/container_files.py +155 -0
  23. praisonai/capabilities/containers.py +93 -0
  24. praisonai/capabilities/embeddings.py +158 -0
  25. praisonai/capabilities/files.py +467 -0
  26. praisonai/capabilities/fine_tuning.py +293 -0
  27. praisonai/capabilities/guardrails.py +182 -0
  28. praisonai/capabilities/images.py +330 -0
  29. praisonai/capabilities/mcp.py +190 -0
  30. praisonai/capabilities/messages.py +270 -0
  31. praisonai/capabilities/moderations.py +154 -0
  32. praisonai/capabilities/ocr.py +217 -0
  33. praisonai/capabilities/passthrough.py +204 -0
  34. praisonai/capabilities/rag.py +207 -0
  35. praisonai/capabilities/realtime.py +160 -0
  36. praisonai/capabilities/rerank.py +165 -0
  37. praisonai/capabilities/responses.py +266 -0
  38. praisonai/capabilities/search.py +109 -0
  39. praisonai/capabilities/skills.py +133 -0
  40. praisonai/capabilities/vector_store_files.py +334 -0
  41. praisonai/capabilities/vector_stores.py +304 -0
  42. praisonai/capabilities/videos.py +141 -0
  43. praisonai/chainlit_ui.py +304 -0
  44. praisonai/chat/__init__.py +106 -0
  45. praisonai/chat/app.py +125 -0
  46. praisonai/cli/__init__.py +26 -0
  47. praisonai/cli/app.py +213 -0
  48. praisonai/cli/commands/__init__.py +75 -0
  49. praisonai/cli/commands/acp.py +70 -0
  50. praisonai/cli/commands/completion.py +333 -0
  51. praisonai/cli/commands/config.py +166 -0
  52. praisonai/cli/commands/debug.py +142 -0
  53. praisonai/cli/commands/diag.py +55 -0
  54. praisonai/cli/commands/doctor.py +166 -0
  55. praisonai/cli/commands/environment.py +179 -0
  56. praisonai/cli/commands/lsp.py +112 -0
  57. praisonai/cli/commands/mcp.py +210 -0
  58. praisonai/cli/commands/profile.py +457 -0
  59. praisonai/cli/commands/run.py +228 -0
  60. praisonai/cli/commands/schedule.py +150 -0
  61. praisonai/cli/commands/serve.py +97 -0
  62. praisonai/cli/commands/session.py +212 -0
  63. praisonai/cli/commands/traces.py +145 -0
  64. praisonai/cli/commands/version.py +101 -0
  65. praisonai/cli/configuration/__init__.py +18 -0
  66. praisonai/cli/configuration/loader.py +353 -0
  67. praisonai/cli/configuration/paths.py +114 -0
  68. praisonai/cli/configuration/schema.py +164 -0
  69. praisonai/cli/features/__init__.py +268 -0
  70. praisonai/cli/features/acp.py +236 -0
  71. praisonai/cli/features/action_orchestrator.py +546 -0
  72. praisonai/cli/features/agent_scheduler.py +773 -0
  73. praisonai/cli/features/agent_tools.py +474 -0
  74. praisonai/cli/features/agents.py +375 -0
  75. praisonai/cli/features/at_mentions.py +471 -0
  76. praisonai/cli/features/auto_memory.py +182 -0
  77. praisonai/cli/features/autonomy_mode.py +490 -0
  78. praisonai/cli/features/background.py +356 -0
  79. praisonai/cli/features/base.py +168 -0
  80. praisonai/cli/features/capabilities.py +1326 -0
  81. praisonai/cli/features/checkpoints.py +338 -0
  82. praisonai/cli/features/code_intelligence.py +652 -0
  83. praisonai/cli/features/compaction.py +294 -0
  84. praisonai/cli/features/compare.py +534 -0
  85. praisonai/cli/features/cost_tracker.py +514 -0
  86. praisonai/cli/features/debug.py +810 -0
  87. praisonai/cli/features/deploy.py +517 -0
  88. praisonai/cli/features/diag.py +289 -0
  89. praisonai/cli/features/doctor/__init__.py +63 -0
  90. praisonai/cli/features/doctor/checks/__init__.py +24 -0
  91. praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
  92. praisonai/cli/features/doctor/checks/config_checks.py +366 -0
  93. praisonai/cli/features/doctor/checks/db_checks.py +366 -0
  94. praisonai/cli/features/doctor/checks/env_checks.py +543 -0
  95. praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
  96. praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
  97. praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
  98. praisonai/cli/features/doctor/checks/network_checks.py +251 -0
  99. praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
  100. praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
  101. praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
  102. praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
  103. praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
  104. praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
  105. praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
  106. praisonai/cli/features/doctor/engine.py +266 -0
  107. praisonai/cli/features/doctor/formatters.py +310 -0
  108. praisonai/cli/features/doctor/handler.py +397 -0
  109. praisonai/cli/features/doctor/models.py +264 -0
  110. praisonai/cli/features/doctor/registry.py +239 -0
  111. praisonai/cli/features/endpoints.py +1019 -0
  112. praisonai/cli/features/eval.py +560 -0
  113. praisonai/cli/features/external_agents.py +231 -0
  114. praisonai/cli/features/fast_context.py +410 -0
  115. praisonai/cli/features/flow_display.py +566 -0
  116. praisonai/cli/features/git_integration.py +651 -0
  117. praisonai/cli/features/guardrail.py +171 -0
  118. praisonai/cli/features/handoff.py +185 -0
  119. praisonai/cli/features/hooks.py +583 -0
  120. praisonai/cli/features/image.py +384 -0
  121. praisonai/cli/features/interactive_runtime.py +585 -0
  122. praisonai/cli/features/interactive_tools.py +380 -0
  123. praisonai/cli/features/interactive_tui.py +603 -0
  124. praisonai/cli/features/jobs.py +632 -0
  125. praisonai/cli/features/knowledge.py +531 -0
  126. praisonai/cli/features/lite.py +244 -0
  127. praisonai/cli/features/lsp_cli.py +225 -0
  128. praisonai/cli/features/mcp.py +169 -0
  129. praisonai/cli/features/message_queue.py +587 -0
  130. praisonai/cli/features/metrics.py +211 -0
  131. praisonai/cli/features/n8n.py +673 -0
  132. praisonai/cli/features/observability.py +293 -0
  133. praisonai/cli/features/ollama.py +361 -0
  134. praisonai/cli/features/output_style.py +273 -0
  135. praisonai/cli/features/package.py +631 -0
  136. praisonai/cli/features/performance.py +308 -0
  137. praisonai/cli/features/persistence.py +636 -0
  138. praisonai/cli/features/profile.py +226 -0
  139. praisonai/cli/features/profiler/__init__.py +81 -0
  140. praisonai/cli/features/profiler/core.py +558 -0
  141. praisonai/cli/features/profiler/optimizations.py +652 -0
  142. praisonai/cli/features/profiler/suite.py +386 -0
  143. praisonai/cli/features/profiling.py +350 -0
  144. praisonai/cli/features/queue/__init__.py +73 -0
  145. praisonai/cli/features/queue/manager.py +395 -0
  146. praisonai/cli/features/queue/models.py +286 -0
  147. praisonai/cli/features/queue/persistence.py +564 -0
  148. praisonai/cli/features/queue/scheduler.py +484 -0
  149. praisonai/cli/features/queue/worker.py +372 -0
  150. praisonai/cli/features/recipe.py +1723 -0
  151. praisonai/cli/features/recipes.py +449 -0
  152. praisonai/cli/features/registry.py +229 -0
  153. praisonai/cli/features/repo_map.py +860 -0
  154. praisonai/cli/features/router.py +466 -0
  155. praisonai/cli/features/sandbox_executor.py +515 -0
  156. praisonai/cli/features/serve.py +829 -0
  157. praisonai/cli/features/session.py +222 -0
  158. praisonai/cli/features/skills.py +856 -0
  159. praisonai/cli/features/slash_commands.py +650 -0
  160. praisonai/cli/features/telemetry.py +179 -0
  161. praisonai/cli/features/templates.py +1384 -0
  162. praisonai/cli/features/thinking.py +305 -0
  163. praisonai/cli/features/todo.py +334 -0
  164. praisonai/cli/features/tools.py +680 -0
  165. praisonai/cli/features/tui/__init__.py +83 -0
  166. praisonai/cli/features/tui/app.py +580 -0
  167. praisonai/cli/features/tui/cli.py +566 -0
  168. praisonai/cli/features/tui/debug.py +511 -0
  169. praisonai/cli/features/tui/events.py +99 -0
  170. praisonai/cli/features/tui/mock_provider.py +328 -0
  171. praisonai/cli/features/tui/orchestrator.py +652 -0
  172. praisonai/cli/features/tui/screens/__init__.py +50 -0
  173. praisonai/cli/features/tui/screens/main.py +245 -0
  174. praisonai/cli/features/tui/screens/queue.py +174 -0
  175. praisonai/cli/features/tui/screens/session.py +124 -0
  176. praisonai/cli/features/tui/screens/settings.py +148 -0
  177. praisonai/cli/features/tui/widgets/__init__.py +56 -0
  178. praisonai/cli/features/tui/widgets/chat.py +261 -0
  179. praisonai/cli/features/tui/widgets/composer.py +224 -0
  180. praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
  181. praisonai/cli/features/tui/widgets/status.py +167 -0
  182. praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
  183. praisonai/cli/features/workflow.py +720 -0
  184. praisonai/cli/legacy.py +236 -0
  185. praisonai/cli/main.py +5559 -0
  186. praisonai/cli/schedule_cli.py +54 -0
  187. praisonai/cli/state/__init__.py +31 -0
  188. praisonai/cli/state/identifiers.py +161 -0
  189. praisonai/cli/state/sessions.py +313 -0
  190. praisonai/code/__init__.py +93 -0
  191. praisonai/code/agent_tools.py +344 -0
  192. praisonai/code/diff/__init__.py +21 -0
  193. praisonai/code/diff/diff_strategy.py +432 -0
  194. praisonai/code/tools/__init__.py +27 -0
  195. praisonai/code/tools/apply_diff.py +221 -0
  196. praisonai/code/tools/execute_command.py +275 -0
  197. praisonai/code/tools/list_files.py +274 -0
  198. praisonai/code/tools/read_file.py +206 -0
  199. praisonai/code/tools/search_replace.py +248 -0
  200. praisonai/code/tools/write_file.py +217 -0
  201. praisonai/code/utils/__init__.py +46 -0
  202. praisonai/code/utils/file_utils.py +307 -0
  203. praisonai/code/utils/ignore_utils.py +308 -0
  204. praisonai/code/utils/text_utils.py +276 -0
  205. praisonai/db/__init__.py +64 -0
  206. praisonai/db/adapter.py +531 -0
  207. praisonai/deploy/__init__.py +62 -0
  208. praisonai/deploy/api.py +231 -0
  209. praisonai/deploy/docker.py +454 -0
  210. praisonai/deploy/doctor.py +367 -0
  211. praisonai/deploy/main.py +327 -0
  212. praisonai/deploy/models.py +179 -0
  213. praisonai/deploy/providers/__init__.py +33 -0
  214. praisonai/deploy/providers/aws.py +331 -0
  215. praisonai/deploy/providers/azure.py +358 -0
  216. praisonai/deploy/providers/base.py +101 -0
  217. praisonai/deploy/providers/gcp.py +314 -0
  218. praisonai/deploy/schema.py +208 -0
  219. praisonai/deploy.py +185 -0
  220. praisonai/endpoints/__init__.py +53 -0
  221. praisonai/endpoints/a2u_server.py +410 -0
  222. praisonai/endpoints/discovery.py +165 -0
  223. praisonai/endpoints/providers/__init__.py +28 -0
  224. praisonai/endpoints/providers/a2a.py +253 -0
  225. praisonai/endpoints/providers/a2u.py +208 -0
  226. praisonai/endpoints/providers/agents_api.py +171 -0
  227. praisonai/endpoints/providers/base.py +231 -0
  228. praisonai/endpoints/providers/mcp.py +263 -0
  229. praisonai/endpoints/providers/recipe.py +206 -0
  230. praisonai/endpoints/providers/tools_mcp.py +150 -0
  231. praisonai/endpoints/registry.py +131 -0
  232. praisonai/endpoints/server.py +161 -0
  233. praisonai/inbuilt_tools/__init__.py +24 -0
  234. praisonai/inbuilt_tools/autogen_tools.py +117 -0
  235. praisonai/inc/__init__.py +2 -0
  236. praisonai/inc/config.py +96 -0
  237. praisonai/inc/models.py +155 -0
  238. praisonai/integrations/__init__.py +56 -0
  239. praisonai/integrations/base.py +303 -0
  240. praisonai/integrations/claude_code.py +270 -0
  241. praisonai/integrations/codex_cli.py +255 -0
  242. praisonai/integrations/cursor_cli.py +195 -0
  243. praisonai/integrations/gemini_cli.py +222 -0
  244. praisonai/jobs/__init__.py +67 -0
  245. praisonai/jobs/executor.py +425 -0
  246. praisonai/jobs/models.py +230 -0
  247. praisonai/jobs/router.py +314 -0
  248. praisonai/jobs/server.py +186 -0
  249. praisonai/jobs/store.py +203 -0
  250. praisonai/llm/__init__.py +66 -0
  251. praisonai/llm/registry.py +382 -0
  252. praisonai/mcp_server/__init__.py +152 -0
  253. praisonai/mcp_server/adapters/__init__.py +74 -0
  254. praisonai/mcp_server/adapters/agents.py +128 -0
  255. praisonai/mcp_server/adapters/capabilities.py +168 -0
  256. praisonai/mcp_server/adapters/cli_tools.py +568 -0
  257. praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
  258. praisonai/mcp_server/adapters/knowledge.py +93 -0
  259. praisonai/mcp_server/adapters/memory.py +104 -0
  260. praisonai/mcp_server/adapters/prompts.py +306 -0
  261. praisonai/mcp_server/adapters/resources.py +124 -0
  262. praisonai/mcp_server/adapters/tools_bridge.py +280 -0
  263. praisonai/mcp_server/auth/__init__.py +48 -0
  264. praisonai/mcp_server/auth/api_key.py +291 -0
  265. praisonai/mcp_server/auth/oauth.py +460 -0
  266. praisonai/mcp_server/auth/oidc.py +289 -0
  267. praisonai/mcp_server/auth/scopes.py +260 -0
  268. praisonai/mcp_server/cli.py +852 -0
  269. praisonai/mcp_server/elicitation.py +445 -0
  270. praisonai/mcp_server/icons.py +302 -0
  271. praisonai/mcp_server/recipe_adapter.py +573 -0
  272. praisonai/mcp_server/recipe_cli.py +824 -0
  273. praisonai/mcp_server/registry.py +703 -0
  274. praisonai/mcp_server/sampling.py +422 -0
  275. praisonai/mcp_server/server.py +490 -0
  276. praisonai/mcp_server/tasks.py +443 -0
  277. praisonai/mcp_server/transports/__init__.py +18 -0
  278. praisonai/mcp_server/transports/http_stream.py +376 -0
  279. praisonai/mcp_server/transports/stdio.py +132 -0
  280. praisonai/persistence/__init__.py +84 -0
  281. praisonai/persistence/config.py +238 -0
  282. praisonai/persistence/conversation/__init__.py +25 -0
  283. praisonai/persistence/conversation/async_mysql.py +427 -0
  284. praisonai/persistence/conversation/async_postgres.py +410 -0
  285. praisonai/persistence/conversation/async_sqlite.py +371 -0
  286. praisonai/persistence/conversation/base.py +151 -0
  287. praisonai/persistence/conversation/json_store.py +250 -0
  288. praisonai/persistence/conversation/mysql.py +387 -0
  289. praisonai/persistence/conversation/postgres.py +401 -0
  290. praisonai/persistence/conversation/singlestore.py +240 -0
  291. praisonai/persistence/conversation/sqlite.py +341 -0
  292. praisonai/persistence/conversation/supabase.py +203 -0
  293. praisonai/persistence/conversation/surrealdb.py +287 -0
  294. praisonai/persistence/factory.py +301 -0
  295. praisonai/persistence/hooks/__init__.py +18 -0
  296. praisonai/persistence/hooks/agent_hooks.py +297 -0
  297. praisonai/persistence/knowledge/__init__.py +26 -0
  298. praisonai/persistence/knowledge/base.py +144 -0
  299. praisonai/persistence/knowledge/cassandra.py +232 -0
  300. praisonai/persistence/knowledge/chroma.py +295 -0
  301. praisonai/persistence/knowledge/clickhouse.py +242 -0
  302. praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
  303. praisonai/persistence/knowledge/couchbase.py +286 -0
  304. praisonai/persistence/knowledge/lancedb.py +216 -0
  305. praisonai/persistence/knowledge/langchain_adapter.py +291 -0
  306. praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
  307. praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
  308. praisonai/persistence/knowledge/milvus.py +277 -0
  309. praisonai/persistence/knowledge/mongodb_vector.py +306 -0
  310. praisonai/persistence/knowledge/pgvector.py +335 -0
  311. praisonai/persistence/knowledge/pinecone.py +253 -0
  312. praisonai/persistence/knowledge/qdrant.py +301 -0
  313. praisonai/persistence/knowledge/redis_vector.py +291 -0
  314. praisonai/persistence/knowledge/singlestore_vector.py +299 -0
  315. praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
  316. praisonai/persistence/knowledge/upstash_vector.py +266 -0
  317. praisonai/persistence/knowledge/weaviate.py +223 -0
  318. praisonai/persistence/migrations/__init__.py +10 -0
  319. praisonai/persistence/migrations/manager.py +251 -0
  320. praisonai/persistence/orchestrator.py +406 -0
  321. praisonai/persistence/state/__init__.py +21 -0
  322. praisonai/persistence/state/async_mongodb.py +200 -0
  323. praisonai/persistence/state/base.py +107 -0
  324. praisonai/persistence/state/dynamodb.py +226 -0
  325. praisonai/persistence/state/firestore.py +175 -0
  326. praisonai/persistence/state/gcs.py +155 -0
  327. praisonai/persistence/state/memory.py +245 -0
  328. praisonai/persistence/state/mongodb.py +158 -0
  329. praisonai/persistence/state/redis.py +190 -0
  330. praisonai/persistence/state/upstash.py +144 -0
  331. praisonai/persistence/tests/__init__.py +3 -0
  332. praisonai/persistence/tests/test_all_backends.py +633 -0
  333. praisonai/profiler.py +1214 -0
  334. praisonai/recipe/__init__.py +134 -0
  335. praisonai/recipe/bridge.py +278 -0
  336. praisonai/recipe/core.py +893 -0
  337. praisonai/recipe/exceptions.py +54 -0
  338. praisonai/recipe/history.py +402 -0
  339. praisonai/recipe/models.py +266 -0
  340. praisonai/recipe/operations.py +440 -0
  341. praisonai/recipe/policy.py +422 -0
  342. praisonai/recipe/registry.py +849 -0
  343. praisonai/recipe/runtime.py +214 -0
  344. praisonai/recipe/security.py +711 -0
  345. praisonai/recipe/serve.py +859 -0
  346. praisonai/recipe/server.py +613 -0
  347. praisonai/scheduler/__init__.py +45 -0
  348. praisonai/scheduler/agent_scheduler.py +552 -0
  349. praisonai/scheduler/base.py +124 -0
  350. praisonai/scheduler/daemon_manager.py +225 -0
  351. praisonai/scheduler/state_manager.py +155 -0
  352. praisonai/scheduler/yaml_loader.py +193 -0
  353. praisonai/scheduler.py +194 -0
  354. praisonai/setup/__init__.py +1 -0
  355. praisonai/setup/build.py +21 -0
  356. praisonai/setup/post_install.py +23 -0
  357. praisonai/setup/setup_conda_env.py +25 -0
  358. praisonai/setup.py +16 -0
  359. praisonai/templates/__init__.py +116 -0
  360. praisonai/templates/cache.py +364 -0
  361. praisonai/templates/dependency_checker.py +358 -0
  362. praisonai/templates/discovery.py +391 -0
  363. praisonai/templates/loader.py +564 -0
  364. praisonai/templates/registry.py +511 -0
  365. praisonai/templates/resolver.py +206 -0
  366. praisonai/templates/security.py +327 -0
  367. praisonai/templates/tool_override.py +498 -0
  368. praisonai/templates/tools_doctor.py +256 -0
  369. praisonai/test.py +105 -0
  370. praisonai/train.py +562 -0
  371. praisonai/train_vision.py +306 -0
  372. praisonai/ui/agents.py +824 -0
  373. praisonai/ui/callbacks.py +57 -0
  374. praisonai/ui/chainlit_compat.py +246 -0
  375. praisonai/ui/chat.py +532 -0
  376. praisonai/ui/code.py +717 -0
  377. praisonai/ui/colab.py +474 -0
  378. praisonai/ui/colab_chainlit.py +81 -0
  379. praisonai/ui/components/aicoder.py +284 -0
  380. praisonai/ui/context.py +283 -0
  381. praisonai/ui/database_config.py +56 -0
  382. praisonai/ui/db.py +294 -0
  383. praisonai/ui/realtime.py +488 -0
  384. praisonai/ui/realtimeclient/__init__.py +756 -0
  385. praisonai/ui/realtimeclient/tools.py +242 -0
  386. praisonai/ui/sql_alchemy.py +710 -0
  387. praisonai/upload_vision.py +140 -0
  388. praisonai/version.py +1 -0
  389. praisonai-3.0.0.dist-info/METADATA +3493 -0
  390. praisonai-3.0.0.dist-info/RECORD +393 -0
  391. praisonai-3.0.0.dist-info/WHEEL +5 -0
  392. praisonai-3.0.0.dist-info/entry_points.txt +4 -0
  393. praisonai-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1723 @@
1
+ """
2
+ Recipe CLI Feature Handler
3
+
4
+ Provides CLI commands for recipe management and execution:
5
+ - list, search, info, validate, run
6
+ - init, test, pack, unpack
7
+ - export, replay
8
+ - serve
9
+
10
+ All commands use the canonical `praisonai recipe` prefix.
11
+ """
12
+
13
+ import json
14
+ import sys
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+
20
+ class RecipeHandler:
21
+ """
22
+ CLI handler for recipe operations.
23
+
24
+ Commands:
25
+ - list: List available recipes
26
+ - search: Search recipes by query
27
+ - info: Show recipe details
28
+ - validate: Validate a recipe
29
+ - run: Run a recipe
30
+ - init: Initialize a new recipe
31
+ - test: Test a recipe
32
+ - pack: Create a recipe bundle
33
+ - unpack: Extract a recipe bundle
34
+ - export: Export a run bundle
35
+ - replay: Replay from a run bundle
36
+ - serve: Start HTTP recipe runner
37
+ """
38
+
39
+ # Stable exit codes
40
+ EXIT_SUCCESS = 0
41
+ EXIT_GENERAL_ERROR = 1
42
+ EXIT_VALIDATION_ERROR = 2
43
+ EXIT_RUNTIME_ERROR = 3
44
+ EXIT_POLICY_DENIED = 4
45
+ EXIT_TIMEOUT = 5
46
+ EXIT_MISSING_DEPS = 6
47
+ EXIT_NOT_FOUND = 7
48
+
49
+ def __init__(self):
50
+ """Initialize the handler."""
51
+ self._recipe_module = None
52
+
53
+ @property
54
+ def recipe(self):
55
+ """Lazy load recipe module."""
56
+ if self._recipe_module is None:
57
+ from praisonai import recipe
58
+ self._recipe_module = recipe
59
+ return self._recipe_module
60
+
61
+ def handle(self, args: List[str]) -> int:
62
+ """
63
+ Handle recipe subcommand.
64
+
65
+ Args:
66
+ args: Command arguments
67
+
68
+ Returns:
69
+ Exit code
70
+ """
71
+ if not args:
72
+ self._print_help()
73
+ return self.EXIT_SUCCESS
74
+
75
+ command = args[0]
76
+ remaining = args[1:]
77
+
78
+ commands = {
79
+ "list": self.cmd_list,
80
+ "search": self.cmd_search,
81
+ "info": self.cmd_info,
82
+ "validate": self.cmd_validate,
83
+ "run": self.cmd_run,
84
+ "init": self.cmd_init,
85
+ "test": self.cmd_test,
86
+ "pack": self.cmd_pack,
87
+ "unpack": self.cmd_unpack,
88
+ "export": self.cmd_export,
89
+ "replay": self.cmd_replay,
90
+ "serve": self.cmd_serve,
91
+ "publish": self.cmd_publish,
92
+ "pull": self.cmd_pull,
93
+ "sbom": self.cmd_sbom,
94
+ "audit": self.cmd_audit,
95
+ "sign": self.cmd_sign,
96
+ "verify": self.cmd_verify,
97
+ "runs": self.cmd_runs,
98
+ "policy": self.cmd_policy,
99
+ "help": lambda _: self._print_help() or self.EXIT_SUCCESS,
100
+ "--help": lambda _: self._print_help() or self.EXIT_SUCCESS,
101
+ "-h": lambda _: self._print_help() or self.EXIT_SUCCESS,
102
+ }
103
+
104
+ if command in commands:
105
+ return commands[command](remaining)
106
+ else:
107
+ self._print_error(f"Unknown command: {command}")
108
+ self._print_help()
109
+ return self.EXIT_GENERAL_ERROR
110
+
111
+ def _print_help(self):
112
+ """Print help message."""
113
+ help_text = """
114
+ [bold cyan]PraisonAI Recipe[/bold cyan]
115
+
116
+ [bold]Usage:[/bold]
117
+ praisonai recipe <command> [options]
118
+
119
+ [bold]Commands:[/bold]
120
+ list List available recipes
121
+ search <query> Search recipes by name or tags
122
+ info <recipe> Show recipe details and dependencies
123
+ validate <recipe> Validate a recipe
124
+ run <recipe> Run a recipe
125
+ init <name> Initialize a new recipe project
126
+ test <recipe> Run recipe tests
127
+ pack <recipe> Create a recipe bundle (.praison)
128
+ unpack <bundle> Extract a recipe bundle
129
+ export <run_id> Export a run bundle for replay
130
+ replay <bundle> Replay from a run bundle
131
+ serve Start HTTP recipe runner
132
+ publish <bundle> Publish recipe to registry
133
+ pull <name> Pull recipe from registry
134
+ runs List/manage run history
135
+ sbom <recipe> Generate SBOM (Software Bill of Materials)
136
+ audit <recipe> Audit dependencies for vulnerabilities
137
+ sign <bundle> Sign a recipe bundle
138
+ verify <bundle> Verify bundle signature
139
+ policy Manage policy packs
140
+
141
+ [bold]Run Options:[/bold]
142
+ --input, -i Input JSON or file path
143
+ --config, -c Config JSON overrides
144
+ --session, -s Session ID for state grouping
145
+ --json Output JSON (for parsing)
146
+ --stream Stream output events (SSE-like)
147
+ --dry-run Validate without executing
148
+ --explain Show execution plan
149
+ --verbose, -v Verbose output
150
+ --timeout <sec> Timeout in seconds (default: 300)
151
+ --non-interactive Disable prompts (for CI)
152
+ --export <path> Export run bundle after execution
153
+ --policy <file> Policy file path
154
+ --mode dev|prod Execution mode (default: dev)
155
+
156
+ [bold]Serve Options:[/bold]
157
+ --port <num> Server port (default: 8765)
158
+ --host <addr> Server host (default: 127.0.0.1)
159
+ --auth <type> Auth type: none, api-key, jwt
160
+ --reload Enable hot reload (dev mode)
161
+
162
+ [bold]Exit Codes:[/bold]
163
+ 0 Success
164
+ 2 Validation error
165
+ 3 Runtime error
166
+ 4 Policy denied
167
+ 5 Timeout
168
+ 6 Missing dependencies
169
+ 7 Recipe not found
170
+
171
+ [bold]Examples:[/bold]
172
+ praisonai recipe list
173
+ praisonai recipe search video
174
+ praisonai recipe info transcript-generator
175
+ praisonai recipe validate support-reply
176
+ praisonai recipe run support-reply --input '{"ticket_id": "T-123"}'
177
+ praisonai recipe run transcript-generator ./audio.mp3 --json
178
+ praisonai recipe init my-recipe
179
+ praisonai recipe serve --port 8765
180
+ """
181
+ self._print_rich(help_text)
182
+
183
+ def _print_rich(self, text: str):
184
+ """Print with rich formatting if available."""
185
+ try:
186
+ from rich import print as rprint
187
+ rprint(text)
188
+ except ImportError:
189
+ # Strip rich formatting
190
+ import re
191
+ plain = re.sub(r'\[/?[^\]]+\]', '', text)
192
+ print(plain)
193
+
194
+ def _print_error(self, message: str):
195
+ """Print error message."""
196
+ try:
197
+ from rich import print as rprint
198
+ rprint(f"[red]Error: {message}[/red]")
199
+ except ImportError:
200
+ print(f"Error: {message}", file=sys.stderr)
201
+
202
+ def _print_success(self, message: str):
203
+ """Print success message."""
204
+ try:
205
+ from rich import print as rprint
206
+ rprint(f"[green]✓ {message}[/green]")
207
+ except ImportError:
208
+ print(f"✓ {message}")
209
+
210
+ def _print_json(self, data: Any):
211
+ """Print JSON output."""
212
+ print(json.dumps(data, indent=2, default=str))
213
+
214
+ def _parse_args(self, args: List[str], spec: Dict[str, Any]) -> Dict[str, Any]:
215
+ """Parse command arguments based on spec."""
216
+ result = {k: v.get("default") for k, v in spec.items()}
217
+ positional_keys = [k for k, v in spec.items() if v.get("positional")]
218
+ positional_idx = 0
219
+
220
+ i = 0
221
+ while i < len(args):
222
+ arg = args[i]
223
+
224
+ if arg.startswith("--"):
225
+ key = arg[2:].replace("-", "_")
226
+ if key in spec:
227
+ if spec[key].get("flag"):
228
+ result[key] = True
229
+ elif i + 1 < len(args):
230
+ result[key] = args[i + 1]
231
+ i += 1
232
+ i += 1
233
+ elif arg.startswith("-") and len(arg) == 2:
234
+ # Short flag
235
+ for key, val in spec.items():
236
+ if val.get("short") == arg:
237
+ if val.get("flag"):
238
+ result[key] = True
239
+ elif i + 1 < len(args):
240
+ result[key] = args[i + 1]
241
+ i += 1
242
+ break
243
+ i += 1
244
+ else:
245
+ # Positional argument
246
+ if positional_idx < len(positional_keys):
247
+ result[positional_keys[positional_idx]] = arg
248
+ positional_idx += 1
249
+ i += 1
250
+
251
+ return result
252
+
253
+ def cmd_list(self, args: List[str]) -> int:
254
+ """List available recipes."""
255
+ spec = {
256
+ "source": {"default": None},
257
+ "tags": {"default": None},
258
+ "registry": {"default": None},
259
+ "token": {"default": None},
260
+ "json": {"flag": True, "default": False},
261
+ "offline": {"flag": True, "default": False},
262
+ }
263
+ parsed = self._parse_args(args, spec)
264
+
265
+ try:
266
+ # If registry URL provided, list from that registry
267
+ if parsed["registry"]:
268
+ from praisonai.recipe.registry import get_registry
269
+ import os
270
+ registry = get_registry(
271
+ registry=parsed["registry"],
272
+ token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
273
+ )
274
+ result = registry.list_recipes(
275
+ tags=parsed["tags"].split(",") if parsed["tags"] else None
276
+ )
277
+ recipes = result.get("recipes", []) if isinstance(result, dict) else result
278
+
279
+ if parsed["json"]:
280
+ self._print_json(recipes)
281
+ return self.EXIT_SUCCESS
282
+
283
+ if not recipes:
284
+ print("No recipes found in registry.")
285
+ return self.EXIT_SUCCESS
286
+
287
+ try:
288
+ from rich.console import Console
289
+ from rich.table import Table
290
+
291
+ console = Console()
292
+ table = Table(title=f"Recipes from {parsed['registry']}")
293
+ table.add_column("Name", style="cyan")
294
+ table.add_column("Version", style="green")
295
+ table.add_column("Description")
296
+ table.add_column("Tags", style="yellow")
297
+
298
+ for r in recipes:
299
+ table.add_row(
300
+ r.get("name", ""),
301
+ r.get("version", ""),
302
+ (r.get("description", "")[:50] + "...") if len(r.get("description", "")) > 50 else r.get("description", ""),
303
+ ", ".join(r.get("tags", [])[:3]),
304
+ )
305
+
306
+ console.print(table)
307
+ except ImportError:
308
+ for r in recipes:
309
+ print(f"{r.get('name')} ({r.get('version')}): {r.get('description', '')}")
310
+
311
+ return self.EXIT_SUCCESS
312
+
313
+ # Default: list local recipes
314
+ recipes = self.recipe.list_recipes(
315
+ source_filter=parsed["source"],
316
+ tags=parsed["tags"].split(",") if parsed["tags"] else None,
317
+ offline=parsed["offline"],
318
+ )
319
+
320
+ if parsed["json"]:
321
+ self._print_json([r.to_dict() for r in recipes])
322
+ return self.EXIT_SUCCESS
323
+
324
+ if not recipes:
325
+ print("No recipes found.")
326
+ return self.EXIT_SUCCESS
327
+
328
+ try:
329
+ from rich.console import Console
330
+ from rich.table import Table
331
+
332
+ console = Console()
333
+ table = Table(title="Available Recipes")
334
+ table.add_column("Name", style="cyan")
335
+ table.add_column("Version", style="green")
336
+ table.add_column("Description")
337
+ table.add_column("Tags", style="yellow")
338
+
339
+ for r in recipes:
340
+ table.add_row(
341
+ r.name,
342
+ r.version,
343
+ r.description[:50] + "..." if len(r.description) > 50 else r.description,
344
+ ", ".join(r.tags[:3]) if r.tags else "",
345
+ )
346
+
347
+ console.print(table)
348
+ except ImportError:
349
+ for r in recipes:
350
+ print(f"{r.name} ({r.version}): {r.description}")
351
+
352
+ return self.EXIT_SUCCESS
353
+
354
+ except Exception as e:
355
+ self._print_error(str(e))
356
+ return self.EXIT_GENERAL_ERROR
357
+
358
+ def cmd_search(self, args: List[str]) -> int:
359
+ """Search recipes by query."""
360
+ spec = {
361
+ "query": {"positional": True, "default": ""},
362
+ "registry": {"default": None},
363
+ "token": {"default": None},
364
+ "json": {"flag": True, "default": False},
365
+ "offline": {"flag": True, "default": False},
366
+ }
367
+ parsed = self._parse_args(args, spec)
368
+
369
+ if not parsed["query"]:
370
+ self._print_error("Search query required")
371
+ return self.EXIT_VALIDATION_ERROR
372
+
373
+ try:
374
+ # If registry URL provided, search that registry
375
+ if parsed["registry"]:
376
+ from praisonai.recipe.registry import get_registry
377
+ import os
378
+ registry = get_registry(
379
+ registry=parsed["registry"],
380
+ token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN")
381
+ )
382
+ result = registry.search(parsed["query"])
383
+ matches = result.get("results", []) if isinstance(result, dict) else result
384
+
385
+ if parsed["json"]:
386
+ self._print_json(matches)
387
+ return self.EXIT_SUCCESS
388
+
389
+ if not matches:
390
+ print(f"No recipes found matching '{parsed['query']}' in registry")
391
+ return self.EXIT_SUCCESS
392
+
393
+ print(f"Found {len(matches)} recipe(s) matching '{parsed['query']}':")
394
+ for r in matches:
395
+ print(f" {r.get('name')}: {r.get('description', '')}")
396
+
397
+ return self.EXIT_SUCCESS
398
+
399
+ # Default: search local recipes
400
+ recipes = self.recipe.list_recipes(offline=parsed["offline"])
401
+ query = parsed["query"].lower()
402
+
403
+ # Filter by query
404
+ matches = []
405
+ for r in recipes:
406
+ if (query in r.name.lower() or
407
+ query in r.description.lower() or
408
+ any(query in t.lower() for t in r.tags)):
409
+ matches.append(r)
410
+
411
+ if parsed["json"]:
412
+ self._print_json([r.to_dict() for r in matches])
413
+ return self.EXIT_SUCCESS
414
+
415
+ if not matches:
416
+ print(f"No recipes found matching '{parsed['query']}'")
417
+ return self.EXIT_SUCCESS
418
+
419
+ print(f"Found {len(matches)} recipe(s) matching '{parsed['query']}':")
420
+ for r in matches:
421
+ print(f" {r.name}: {r.description}")
422
+
423
+ return self.EXIT_SUCCESS
424
+
425
+ except Exception as e:
426
+ self._print_error(str(e))
427
+ return self.EXIT_GENERAL_ERROR
428
+
429
+ def cmd_info(self, args: List[str]) -> int:
430
+ """Show recipe details."""
431
+ spec = {
432
+ "recipe": {"positional": True, "default": ""},
433
+ "json": {"flag": True, "default": False},
434
+ "offline": {"flag": True, "default": False},
435
+ }
436
+ parsed = self._parse_args(args, spec)
437
+
438
+ if not parsed["recipe"]:
439
+ self._print_error("Recipe name required")
440
+ return self.EXIT_VALIDATION_ERROR
441
+
442
+ try:
443
+ info = self.recipe.describe(parsed["recipe"], offline=parsed["offline"])
444
+
445
+ if info is None:
446
+ self._print_error(f"Recipe not found: {parsed['recipe']}")
447
+ return self.EXIT_NOT_FOUND
448
+
449
+ if parsed["json"]:
450
+ self._print_json(info.to_dict())
451
+ return self.EXIT_SUCCESS
452
+
453
+ self._print_rich(f"\n[bold cyan]{info.name}[/bold cyan] v{info.version}")
454
+ if info.description:
455
+ print(f"\n{info.description}")
456
+
457
+ if info.author:
458
+ print(f"\nAuthor: {info.author}")
459
+ if info.license:
460
+ print(f"License: {info.license}")
461
+ if info.tags:
462
+ print(f"Tags: {', '.join(info.tags)}")
463
+
464
+ # Dependencies
465
+ print("\n[bold]Dependencies:[/bold]")
466
+ pkgs = info.get_required_packages()
467
+ if pkgs:
468
+ print(f" Packages: {', '.join(pkgs)}")
469
+ env = info.get_required_env()
470
+ if env:
471
+ print(f" Env vars: {', '.join(env)}")
472
+ ext = info.get_external_deps()
473
+ if ext:
474
+ names = [e.get("name", e) if isinstance(e, dict) else e for e in ext]
475
+ print(f" External: {', '.join(names)}")
476
+
477
+ # Tool permissions
478
+ allowed = info.get_allowed_tools()
479
+ denied = info.get_denied_tools()
480
+ if allowed or denied:
481
+ print("\n[bold]Tool Permissions:[/bold]")
482
+ if allowed:
483
+ print(f" Allow: {', '.join(allowed)}")
484
+ if denied:
485
+ print(f" Deny: {', '.join(denied)}")
486
+
487
+ return self.EXIT_SUCCESS
488
+
489
+ except Exception as e:
490
+ self._print_error(str(e))
491
+ return self.EXIT_GENERAL_ERROR
492
+
493
+ def cmd_validate(self, args: List[str]) -> int:
494
+ """Validate a recipe."""
495
+ spec = {
496
+ "recipe": {"positional": True, "default": ""},
497
+ "json": {"flag": True, "default": False},
498
+ "offline": {"flag": True, "default": False},
499
+ }
500
+ parsed = self._parse_args(args, spec)
501
+
502
+ if not parsed["recipe"]:
503
+ self._print_error("Recipe name required")
504
+ return self.EXIT_VALIDATION_ERROR
505
+
506
+ try:
507
+ result = self.recipe.validate(parsed["recipe"], offline=parsed["offline"])
508
+
509
+ if parsed["json"]:
510
+ self._print_json(result.to_dict())
511
+ return self.EXIT_SUCCESS if result.valid else self.EXIT_VALIDATION_ERROR
512
+
513
+ if result.valid:
514
+ self._print_success(f"Recipe '{result.recipe}' is valid")
515
+ else:
516
+ self._print_error(f"Recipe '{result.recipe}' validation failed")
517
+
518
+ if result.errors:
519
+ print("\nErrors:")
520
+ for err in result.errors:
521
+ print(f" ✗ {err}")
522
+
523
+ if result.warnings:
524
+ print("\nWarnings:")
525
+ for warn in result.warnings:
526
+ print(f" ⚠ {warn}")
527
+
528
+ # Show dependency status
529
+ deps = result.dependencies
530
+ if deps:
531
+ print("\nDependencies:")
532
+ for pkg in deps.get("packages", []):
533
+ status = "✓" if pkg["available"] else "✗"
534
+ print(f" {status} {pkg['name']}")
535
+ for env in deps.get("env", []):
536
+ status = "✓" if env["available"] else "✗"
537
+ print(f" {status} ${env['name']}")
538
+
539
+ return self.EXIT_SUCCESS if result.valid else self.EXIT_VALIDATION_ERROR
540
+
541
+ except Exception as e:
542
+ self._print_error(str(e))
543
+ return self.EXIT_GENERAL_ERROR
544
+
545
+ def cmd_run(self, args: List[str]) -> int:
546
+ """Run a recipe."""
547
+ spec = {
548
+ "recipe": {"positional": True, "default": ""},
549
+ "input": {"short": "-i", "default": None},
550
+ "config": {"short": "-c", "default": None},
551
+ "session": {"short": "-s", "default": None},
552
+ "json": {"flag": True, "default": False},
553
+ "stream": {"flag": True, "default": False},
554
+ "background": {"flag": True, "default": False},
555
+ "dry_run": {"flag": True, "default": False},
556
+ "explain": {"flag": True, "default": False},
557
+ "verbose": {"short": "-v", "flag": True, "default": False},
558
+ "timeout": {"default": "300"},
559
+ "non_interactive": {"flag": True, "default": False},
560
+ "export": {"default": None},
561
+ "policy": {"default": None},
562
+ "mode": {"default": "dev"},
563
+ "offline": {"flag": True, "default": False},
564
+ "force": {"flag": True, "default": False},
565
+ "allow_dangerous_tools": {"flag": True, "default": False},
566
+ }
567
+ parsed = self._parse_args(args, spec)
568
+
569
+ if not parsed["recipe"]:
570
+ self._print_error("Recipe name required")
571
+ return self.EXIT_VALIDATION_ERROR
572
+
573
+ # Parse input
574
+ input_data = {}
575
+ if parsed["input"]:
576
+ if parsed["input"].startswith("{"):
577
+ try:
578
+ input_data = json.loads(parsed["input"])
579
+ except json.JSONDecodeError:
580
+ self._print_error("Invalid JSON input")
581
+ return self.EXIT_VALIDATION_ERROR
582
+ elif os.path.isfile(parsed["input"]):
583
+ input_data = {"input": parsed["input"]}
584
+ else:
585
+ input_data = {"input": parsed["input"]}
586
+
587
+ # Check for positional input after recipe name
588
+ remaining_positional = [a for a in args[1:] if not a.startswith("-")]
589
+ if remaining_positional and not parsed["input"]:
590
+ input_data = {"input": remaining_positional[0]}
591
+
592
+ # Parse config
593
+ config = {}
594
+ if parsed["config"]:
595
+ try:
596
+ config = json.loads(parsed["config"])
597
+ except json.JSONDecodeError:
598
+ self._print_error("Invalid JSON config")
599
+ return self.EXIT_VALIDATION_ERROR
600
+
601
+ # Build options
602
+ options = {
603
+ "dry_run": parsed["dry_run"] or parsed["explain"],
604
+ "verbose": parsed["verbose"],
605
+ "timeout_sec": int(parsed["timeout"]),
606
+ "mode": parsed["mode"],
607
+ "offline": parsed["offline"],
608
+ "force": parsed["force"],
609
+ "allow_dangerous_tools": parsed["allow_dangerous_tools"],
610
+ }
611
+
612
+ try:
613
+ # Background execution mode
614
+ if parsed["background"]:
615
+ return self._run_background(
616
+ parsed["recipe"], input_data, config,
617
+ parsed["session"], options, parsed["json"]
618
+ )
619
+
620
+ if parsed["stream"]:
621
+ return self._run_stream(
622
+ parsed["recipe"], input_data, config,
623
+ parsed["session"], options, parsed["json"]
624
+ )
625
+
626
+ result = self.recipe.run(
627
+ parsed["recipe"],
628
+ input=input_data,
629
+ config=config,
630
+ session_id=parsed["session"],
631
+ options=options,
632
+ )
633
+
634
+ # Output
635
+ if parsed["json"]:
636
+ self._print_json(result.to_dict())
637
+ else:
638
+ if result.ok:
639
+ self._print_success(f"Recipe '{result.recipe}' completed successfully")
640
+ print(f" Run ID: {result.run_id}")
641
+ print(f" Duration: {result.metrics.get('duration_sec', 0):.2f}s")
642
+ if result.output:
643
+ print("\nOutput:")
644
+ if isinstance(result.output, dict):
645
+ for k, v in result.output.items():
646
+ print(f" {k}: {v}")
647
+ else:
648
+ print(f" {result.output}")
649
+ else:
650
+ self._print_error(f"Recipe '{result.recipe}' failed")
651
+ print(f" Status: {result.status}")
652
+ print(f" Error: {result.error}")
653
+
654
+ # Export if requested
655
+ if parsed["export"] and result.ok:
656
+ self._export_run(result, parsed["export"])
657
+
658
+ return result.to_exit_code()
659
+
660
+ except Exception as e:
661
+ if parsed["json"]:
662
+ self._print_json({"ok": False, "error": str(e)})
663
+ else:
664
+ self._print_error(str(e))
665
+ return self.EXIT_RUNTIME_ERROR
666
+
667
+ def _run_background(
668
+ self,
669
+ recipe_name: str,
670
+ input_data: Dict[str, Any],
671
+ config: Dict[str, Any],
672
+ session_id: Optional[str],
673
+ options: Dict[str, Any],
674
+ json_output: bool,
675
+ ) -> int:
676
+ """Run recipe as a background task."""
677
+ try:
678
+ from praisonai.recipe.operations import run_background
679
+
680
+ task = run_background(
681
+ recipe_name,
682
+ input=input_data or None,
683
+ config=config or None,
684
+ session_id=session_id,
685
+ timeout_sec=options.get('timeout_sec', 300),
686
+ )
687
+
688
+ if json_output:
689
+ self._print_json({
690
+ "ok": True,
691
+ "task_id": task.task_id,
692
+ "recipe": task.recipe_name,
693
+ "session_id": task.session_id,
694
+ "message": "Task submitted to background"
695
+ })
696
+ else:
697
+ self._print_success(f"Recipe '{recipe_name}' submitted to background")
698
+ print(f" Task ID: {task.task_id}")
699
+ print(f" Session: {task.session_id}")
700
+ print(f"\nCheck status with: praisonai background status {task.task_id}")
701
+
702
+ return self.EXIT_SUCCESS
703
+
704
+ except Exception as e:
705
+ if json_output:
706
+ self._print_json({"ok": False, "error": str(e)})
707
+ else:
708
+ self._print_error(f"Failed to submit background task: {e}")
709
+ return self.EXIT_RUNTIME_ERROR
710
+
711
+ def _run_stream(
712
+ self,
713
+ recipe_name: str,
714
+ input_data: Dict[str, Any],
715
+ config: Dict[str, Any],
716
+ session_id: Optional[str],
717
+ options: Dict[str, Any],
718
+ json_output: bool,
719
+ ) -> int:
720
+ """Run recipe with streaming output."""
721
+ try:
722
+ for event in self.recipe.run_stream(
723
+ recipe_name,
724
+ input=input_data,
725
+ config=config,
726
+ session_id=session_id,
727
+ options=options,
728
+ ):
729
+ if json_output:
730
+ print(event.to_sse(), end="", flush=True)
731
+ else:
732
+ if event.event_type == "started":
733
+ print(f"Started: {event.data.get('run_id')}")
734
+ elif event.event_type == "progress":
735
+ print(f" [{event.data.get('step')}] {event.data.get('message', '')}")
736
+ elif event.event_type == "output":
737
+ print(f"Output: {event.data.get('output')}")
738
+ elif event.event_type == "completed":
739
+ status = event.data.get("status", "unknown")
740
+ duration = event.data.get("duration_sec", 0)
741
+ print(f"Completed: {status} ({duration:.2f}s)")
742
+ elif event.event_type == "error":
743
+ print(f"Error: {event.data.get('message')}")
744
+ return self.EXIT_RUNTIME_ERROR
745
+
746
+ return self.EXIT_SUCCESS
747
+
748
+ except Exception as e:
749
+ self._print_error(str(e))
750
+ return self.EXIT_RUNTIME_ERROR
751
+
752
+ def _export_run(self, result, path: str):
753
+ """Export run result to a bundle."""
754
+ import json
755
+
756
+ bundle = {
757
+ "run_id": result.run_id,
758
+ "recipe": result.recipe,
759
+ "version": result.version,
760
+ "status": result.status,
761
+ "output": result.output,
762
+ "metrics": result.metrics,
763
+ "trace": result.trace,
764
+ "exported_at": self._get_timestamp(),
765
+ }
766
+
767
+ with open(path, "w") as f:
768
+ json.dump(bundle, f, indent=2, default=str)
769
+
770
+ self._print_success(f"Run exported to {path}")
771
+
772
+ def _get_timestamp(self) -> str:
773
+ """Get current timestamp."""
774
+ from datetime import datetime, timezone
775
+ return datetime.now(timezone.utc).isoformat()
776
+
777
+ def cmd_init(self, args: List[str]) -> int:
778
+ """Initialize a new recipe project."""
779
+ spec = {
780
+ "name": {"positional": True, "default": ""},
781
+ "template": {"short": "-t", "default": None},
782
+ "output": {"short": "-o", "default": "."},
783
+ }
784
+ parsed = self._parse_args(args, spec)
785
+
786
+ if not parsed["name"]:
787
+ self._print_error("Recipe name required")
788
+ return self.EXIT_VALIDATION_ERROR
789
+
790
+ try:
791
+ output_dir = Path(parsed["output"]) / parsed["name"]
792
+ output_dir.mkdir(parents=True, exist_ok=True)
793
+
794
+ # Create TEMPLATE.yaml
795
+ template_yaml = f'''schema_version: "1.0"
796
+ name: {parsed["name"]}
797
+ version: "1.0.0"
798
+ description: |
799
+ Description of your recipe.
800
+ author: your-name
801
+ license: Apache-2.0
802
+ tags: [example]
803
+
804
+ requires:
805
+ env: [OPENAI_API_KEY]
806
+ packages: []
807
+
808
+ tools:
809
+ allow: []
810
+ deny: [shell.exec, file.write]
811
+
812
+ workflow: workflow.yaml
813
+
814
+ config:
815
+ input:
816
+ type: string
817
+ required: true
818
+ description: Input for the recipe
819
+
820
+ defaults:
821
+ input: ""
822
+
823
+ outputs:
824
+ - name: result
825
+ type: text
826
+ description: Recipe output
827
+ '''
828
+ (output_dir / "TEMPLATE.yaml").write_text(template_yaml)
829
+
830
+ # Create workflow.yaml
831
+ workflow_yaml = '''framework: praisonai
832
+ topic: ""
833
+ roles:
834
+ assistant:
835
+ role: AI Assistant
836
+ goal: Complete the task
837
+ backstory: You are a helpful AI assistant.
838
+ tasks:
839
+ main_task:
840
+ description: "Process the input: {{{{input}}}}"
841
+ expected_output: Processed result
842
+ '''
843
+ (output_dir / "workflow.yaml").write_text(workflow_yaml)
844
+
845
+ # Create README.md
846
+ readme = f'''# {parsed["name"]}
847
+
848
+ A PraisonAI recipe.
849
+
850
+ ## Usage
851
+
852
+ ```bash
853
+ praisonai recipe run {parsed["name"]} --input "your input"
854
+ ```
855
+
856
+ ## Configuration
857
+
858
+ See TEMPLATE.yaml for configuration options.
859
+ '''
860
+ (output_dir / "README.md").write_text(readme)
861
+
862
+ # Create .env.example
863
+ env_example = '''# Required environment variables
864
+ OPENAI_API_KEY=your-api-key
865
+ '''
866
+ (output_dir / ".env.example").write_text(env_example)
867
+
868
+ self._print_success(f"Recipe '{parsed['name']}' initialized at {output_dir}")
869
+ print("\nNext steps:")
870
+ print(f" 1. cd {output_dir}")
871
+ print(" 2. Edit TEMPLATE.yaml and workflow.yaml")
872
+ print(f" 3. praisonai recipe validate {parsed['name']}")
873
+ print(f" 4. praisonai recipe run {parsed['name']} --input 'test'")
874
+
875
+ return self.EXIT_SUCCESS
876
+
877
+ except Exception as e:
878
+ self._print_error(str(e))
879
+ return self.EXIT_GENERAL_ERROR
880
+
881
+ def cmd_test(self, args: List[str]) -> int:
882
+ """Run recipe tests."""
883
+ spec = {
884
+ "recipe": {"positional": True, "default": ""},
885
+ "json": {"flag": True, "default": False},
886
+ "verbose": {"short": "-v", "flag": True, "default": False},
887
+ }
888
+ parsed = self._parse_args(args, spec)
889
+
890
+ if not parsed["recipe"]:
891
+ self._print_error("Recipe name required")
892
+ return self.EXIT_VALIDATION_ERROR
893
+
894
+ try:
895
+ # First validate
896
+ result = self.recipe.validate(parsed["recipe"])
897
+
898
+ if not result.valid:
899
+ if parsed["json"]:
900
+ self._print_json({"ok": False, "errors": result.errors})
901
+ else:
902
+ self._print_error("Recipe validation failed")
903
+ for err in result.errors:
904
+ print(f" ✗ {err}")
905
+ return self.EXIT_VALIDATION_ERROR
906
+
907
+ # Run dry-run test
908
+ run_result = self.recipe.run(
909
+ parsed["recipe"],
910
+ input={},
911
+ options={"dry_run": True, "verbose": parsed["verbose"]},
912
+ )
913
+
914
+ if parsed["json"]:
915
+ self._print_json({
916
+ "ok": run_result.ok,
917
+ "validation": result.to_dict(),
918
+ "dry_run": run_result.to_dict(),
919
+ })
920
+ else:
921
+ self._print_success(f"Recipe '{parsed['recipe']}' tests passed")
922
+ print(" ✓ Validation passed")
923
+ print(" ✓ Dry run passed")
924
+
925
+ return self.EXIT_SUCCESS
926
+
927
+ except Exception as e:
928
+ if parsed["json"]:
929
+ self._print_json({"ok": False, "error": str(e)})
930
+ else:
931
+ self._print_error(str(e))
932
+ return self.EXIT_RUNTIME_ERROR
933
+
934
+ def cmd_pack(self, args: List[str]) -> int:
935
+ """Create a recipe bundle."""
936
+ spec = {
937
+ "recipe": {"positional": True, "default": ""},
938
+ "output": {"short": "-o", "default": None},
939
+ "json": {"flag": True, "default": False},
940
+ }
941
+ parsed = self._parse_args(args, spec)
942
+
943
+ if not parsed["recipe"]:
944
+ self._print_error("Recipe name required")
945
+ return self.EXIT_VALIDATION_ERROR
946
+
947
+ try:
948
+ import tarfile
949
+ import hashlib
950
+
951
+ info = self.recipe.describe(parsed["recipe"])
952
+ if info is None:
953
+ self._print_error(f"Recipe not found: {parsed['recipe']}")
954
+ return self.EXIT_NOT_FOUND
955
+
956
+ if not info.path:
957
+ self._print_error("Recipe path not available")
958
+ return self.EXIT_GENERAL_ERROR
959
+
960
+ recipe_dir = Path(info.path)
961
+ output_name = parsed["output"] or f"{info.name}-{info.version}.praison"
962
+
963
+ # Create tarball
964
+ with tarfile.open(output_name, "w:gz") as tar:
965
+ # Add manifest
966
+ manifest = {
967
+ "name": info.name,
968
+ "version": info.version,
969
+ "created_at": self._get_timestamp(),
970
+ "files": [],
971
+ }
972
+
973
+ for file_path in recipe_dir.rglob("*"):
974
+ if file_path.is_file() and not file_path.name.startswith("."):
975
+ rel_path = file_path.relative_to(recipe_dir)
976
+ tar.add(file_path, arcname=str(rel_path))
977
+
978
+ # Calculate checksum
979
+ with open(file_path, "rb") as f:
980
+ checksum = hashlib.sha256(f.read()).hexdigest()
981
+ manifest["files"].append({
982
+ "path": str(rel_path),
983
+ "checksum": checksum,
984
+ })
985
+
986
+ # Add manifest
987
+ import io
988
+ manifest_bytes = json.dumps(manifest, indent=2).encode()
989
+ manifest_info = tarfile.TarInfo(name="manifest.json")
990
+ manifest_info.size = len(manifest_bytes)
991
+ tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
992
+
993
+ if parsed["json"]:
994
+ self._print_json({
995
+ "ok": True,
996
+ "bundle": output_name,
997
+ "recipe": info.name,
998
+ "version": info.version,
999
+ })
1000
+ else:
1001
+ self._print_success(f"Bundle created: {output_name}")
1002
+
1003
+ return self.EXIT_SUCCESS
1004
+
1005
+ except Exception as e:
1006
+ self._print_error(str(e))
1007
+ return self.EXIT_GENERAL_ERROR
1008
+
1009
+ def cmd_unpack(self, args: List[str]) -> int:
1010
+ """Extract a recipe bundle."""
1011
+ spec = {
1012
+ "bundle": {"positional": True, "default": ""},
1013
+ "output": {"short": "-o", "default": "."},
1014
+ "json": {"flag": True, "default": False},
1015
+ }
1016
+ parsed = self._parse_args(args, spec)
1017
+
1018
+ if not parsed["bundle"]:
1019
+ self._print_error("Bundle path required")
1020
+ return self.EXIT_VALIDATION_ERROR
1021
+
1022
+ try:
1023
+ import tarfile
1024
+
1025
+ bundle_path = Path(parsed["bundle"])
1026
+ if not bundle_path.exists():
1027
+ self._print_error(f"Bundle not found: {parsed['bundle']}")
1028
+ return self.EXIT_NOT_FOUND
1029
+
1030
+ output_dir = Path(parsed["output"])
1031
+
1032
+ with tarfile.open(bundle_path, "r:gz") as tar:
1033
+ # Read manifest
1034
+ manifest_file = tar.extractfile("manifest.json")
1035
+ if manifest_file:
1036
+ manifest = json.load(manifest_file)
1037
+ recipe_name = manifest.get("name", "recipe")
1038
+ else:
1039
+ recipe_name = bundle_path.stem.split("-")[0]
1040
+
1041
+ # Extract to recipe directory
1042
+ recipe_dir = output_dir / recipe_name
1043
+ recipe_dir.mkdir(parents=True, exist_ok=True)
1044
+
1045
+ for member in tar.getmembers():
1046
+ if member.name != "manifest.json":
1047
+ tar.extract(member, recipe_dir)
1048
+
1049
+ if parsed["json"]:
1050
+ self._print_json({
1051
+ "ok": True,
1052
+ "recipe": recipe_name,
1053
+ "path": str(recipe_dir),
1054
+ })
1055
+ else:
1056
+ self._print_success(f"Bundle extracted to {recipe_dir}")
1057
+
1058
+ return self.EXIT_SUCCESS
1059
+
1060
+ except Exception as e:
1061
+ self._print_error(str(e))
1062
+ return self.EXIT_GENERAL_ERROR
1063
+
1064
+ def cmd_export(self, args: List[str]) -> int:
1065
+ """Export a run bundle."""
1066
+ spec = {
1067
+ "run_id": {"positional": True, "default": ""},
1068
+ "output": {"short": "-o", "default": None},
1069
+ "json": {"flag": True, "default": False},
1070
+ }
1071
+ parsed = self._parse_args(args, spec)
1072
+
1073
+ if not parsed["run_id"]:
1074
+ self._print_error("Run ID required")
1075
+ return self.EXIT_VALIDATION_ERROR
1076
+
1077
+ try:
1078
+ from praisonai.recipe.history import get_history
1079
+
1080
+ history = get_history()
1081
+ output_path = history.export(
1082
+ run_id=parsed["run_id"],
1083
+ output_path=Path(parsed["output"]) if parsed["output"] else None,
1084
+ )
1085
+
1086
+ if parsed["json"]:
1087
+ self._print_json({"ok": True, "path": str(output_path)})
1088
+ else:
1089
+ self._print_success(f"Exported to {output_path}")
1090
+
1091
+ return self.EXIT_SUCCESS
1092
+
1093
+ except Exception as e:
1094
+ self._print_error(str(e))
1095
+ return self.EXIT_GENERAL_ERROR
1096
+
1097
+ def cmd_replay(self, args: List[str]) -> int:
1098
+ """Replay from a run bundle."""
1099
+ spec = {
1100
+ "bundle": {"positional": True, "default": ""},
1101
+ "compare": {"flag": True, "default": False},
1102
+ "json": {"flag": True, "default": False},
1103
+ }
1104
+ parsed = self._parse_args(args, spec)
1105
+
1106
+ if not parsed["bundle"]:
1107
+ self._print_error("Bundle path required")
1108
+ return self.EXIT_VALIDATION_ERROR
1109
+
1110
+ try:
1111
+ bundle_path = Path(parsed["bundle"])
1112
+ if not bundle_path.exists():
1113
+ self._print_error(f"Bundle not found: {parsed['bundle']}")
1114
+ return self.EXIT_NOT_FOUND
1115
+
1116
+ with open(bundle_path) as f:
1117
+ bundle = json.load(f)
1118
+
1119
+ # Re-run the recipe
1120
+ result = self.recipe.run(
1121
+ bundle["recipe"],
1122
+ input=bundle.get("input", {}),
1123
+ config=bundle.get("config", {}),
1124
+ )
1125
+
1126
+ if parsed["compare"]:
1127
+ # Compare with original
1128
+ original_output = bundle.get("output")
1129
+ new_output = result.output
1130
+
1131
+ drift = original_output != new_output
1132
+
1133
+ if parsed["json"]:
1134
+ self._print_json({
1135
+ "ok": result.ok,
1136
+ "drift": drift,
1137
+ "original": original_output,
1138
+ "new": new_output,
1139
+ })
1140
+ else:
1141
+ if drift:
1142
+ print("⚠ Output drift detected")
1143
+ print(f" Original: {original_output}")
1144
+ print(f" New: {new_output}")
1145
+ else:
1146
+ self._print_success("No drift detected")
1147
+ else:
1148
+ if parsed["json"]:
1149
+ self._print_json(result.to_dict())
1150
+ else:
1151
+ if result.ok:
1152
+ self._print_success(f"Replay completed: {result.run_id}")
1153
+ else:
1154
+ self._print_error(f"Replay failed: {result.error}")
1155
+
1156
+ return result.to_exit_code()
1157
+
1158
+ except Exception as e:
1159
+ self._print_error(str(e))
1160
+ return self.EXIT_GENERAL_ERROR
1161
+
1162
+ def cmd_serve(self, args: List[str]) -> int:
1163
+ """Start HTTP recipe runner."""
1164
+ spec = {
1165
+ "port": {"default": "8765"},
1166
+ "host": {"default": "127.0.0.1"},
1167
+ "auth": {"default": "none"},
1168
+ "reload": {"flag": True, "default": False},
1169
+ "preload": {"flag": True, "default": False},
1170
+ "recipes": {"default": None},
1171
+ "config": {"default": None},
1172
+ "api_key": {"default": None},
1173
+ "workers": {"default": "1"},
1174
+ "rate_limit": {"default": None},
1175
+ "max_request_size": {"default": None},
1176
+ "enable_metrics": {"flag": True, "default": False},
1177
+ "enable_admin": {"flag": True, "default": False},
1178
+ "trace_exporter": {"default": "none"},
1179
+ }
1180
+ parsed = self._parse_args(args, spec)
1181
+
1182
+ # Load config file if specified
1183
+ config = {}
1184
+ if parsed["config"]:
1185
+ try:
1186
+ from praisonai.recipe.serve import load_config
1187
+ config = load_config(parsed["config"])
1188
+ except Exception as e:
1189
+ self._print_error(f"Failed to load config: {e}")
1190
+ return self.EXIT_VALIDATION_ERROR
1191
+
1192
+ # CLI flags override config file
1193
+ port = int(parsed["port"]) if parsed["port"] != "8765" or "port" not in config else config.get("port", 8765)
1194
+ host = parsed["host"] if parsed["host"] != "127.0.0.1" or "host" not in config else config.get("host", "127.0.0.1")
1195
+ auth = parsed["auth"] if parsed["auth"] != "none" or "auth" not in config else config.get("auth", "none")
1196
+ workers = int(parsed["workers"]) if parsed["workers"] != "1" or "workers" not in config else config.get("workers", 1)
1197
+
1198
+ # Update config with CLI overrides
1199
+ config["auth"] = auth
1200
+ if parsed["api_key"]:
1201
+ config["api_key"] = parsed["api_key"]
1202
+ if parsed["recipes"]:
1203
+ config["recipes"] = parsed["recipes"].split(",")
1204
+ if parsed["rate_limit"]:
1205
+ config["rate_limit"] = int(parsed["rate_limit"])
1206
+ if parsed["max_request_size"]:
1207
+ config["max_request_size"] = int(parsed["max_request_size"])
1208
+ if parsed["enable_metrics"]:
1209
+ config["enable_metrics"] = True
1210
+ if parsed["enable_admin"]:
1211
+ config["enable_admin"] = True
1212
+ if parsed["trace_exporter"] != "none":
1213
+ config["trace_exporter"] = parsed["trace_exporter"]
1214
+
1215
+ # Safety check: require auth for non-localhost
1216
+ if host != "127.0.0.1" and host != "localhost" and auth == "none":
1217
+ self._print_error("Auth required for non-localhost binding. Use --auth api-key or --auth jwt")
1218
+ return self.EXIT_POLICY_DENIED
1219
+
1220
+ try:
1221
+ from praisonai.recipe.serve import serve
1222
+
1223
+ print(f"Starting Recipe Runner on http://{host}:{port}")
1224
+ if workers > 1:
1225
+ print(f"Workers: {workers}")
1226
+ if auth != "none":
1227
+ print(f"Auth: {auth}")
1228
+ print("Press Ctrl+C to stop")
1229
+ print("\nEndpoints:")
1230
+ print(" GET /health - Health check")
1231
+ print(" GET /v1/recipes - List recipes")
1232
+ print(" GET /v1/recipes/{name} - Describe recipe")
1233
+ print(" POST /v1/recipes/run - Run recipe")
1234
+ print(" POST /v1/recipes/stream - Stream recipe")
1235
+ print(" GET /openapi.json - OpenAPI spec")
1236
+ if config.get("enable_metrics"):
1237
+ print(" GET /metrics - Prometheus metrics")
1238
+ if config.get("enable_admin"):
1239
+ print(" POST /admin/reload - Hot reload registry")
1240
+
1241
+ # Preload recipes if requested
1242
+ if parsed["preload"]:
1243
+ print("\nPreloading recipes...")
1244
+ from praisonai import recipe
1245
+ recipes = recipe.list_recipes()
1246
+ print(f" Loaded {len(recipes)} recipes")
1247
+
1248
+ serve(host=host, port=port, reload=parsed["reload"], config=config, workers=workers)
1249
+
1250
+ return self.EXIT_SUCCESS
1251
+
1252
+ except ImportError:
1253
+ self._print_error("Serve dependencies not installed. Run: pip install praisonai[serve]")
1254
+ return self.EXIT_MISSING_DEPS
1255
+ except Exception as e:
1256
+ self._print_error(str(e))
1257
+ return self.EXIT_GENERAL_ERROR
1258
+
1259
+
1260
+ def cmd_publish(self, args: List[str]) -> int:
1261
+ """Publish recipe to registry."""
1262
+ spec = {
1263
+ "bundle": {"positional": True, "default": ""},
1264
+ "registry": {"default": None},
1265
+ "token": {"default": None},
1266
+ "force": {"flag": True, "default": False},
1267
+ "json": {"flag": True, "default": False},
1268
+ }
1269
+ parsed = self._parse_args(args, spec)
1270
+
1271
+ if not parsed["bundle"]:
1272
+ self._print_error("Bundle or recipe directory required")
1273
+ return self.EXIT_VALIDATION_ERROR
1274
+
1275
+ try:
1276
+ from praisonai.recipe.registry import get_registry
1277
+
1278
+ bundle_path = Path(parsed["bundle"])
1279
+
1280
+ # If directory, pack it first
1281
+ if bundle_path.is_dir():
1282
+ # Pack the recipe first
1283
+ import tarfile
1284
+ import hashlib
1285
+
1286
+ info = self.recipe.describe(str(bundle_path))
1287
+ if info is None:
1288
+ self._print_error(f"Invalid recipe directory: {bundle_path}")
1289
+ return self.EXIT_VALIDATION_ERROR
1290
+
1291
+ bundle_name = f"{info.name}-{info.version}.praison"
1292
+ bundle_path_new = Path(bundle_name)
1293
+
1294
+ with tarfile.open(bundle_path_new, "w:gz") as tar:
1295
+ manifest = {
1296
+ "name": info.name,
1297
+ "version": info.version,
1298
+ "description": info.description,
1299
+ "tags": info.tags,
1300
+ "created_at": self._get_timestamp(),
1301
+ "files": [],
1302
+ }
1303
+
1304
+ for file_path in bundle_path.rglob("*"):
1305
+ if file_path.is_file() and not file_path.name.startswith("."):
1306
+ rel_path = file_path.relative_to(bundle_path)
1307
+ tar.add(file_path, arcname=str(rel_path))
1308
+ with open(file_path, "rb") as f:
1309
+ checksum = hashlib.sha256(f.read()).hexdigest()
1310
+ manifest["files"].append({"path": str(rel_path), "checksum": checksum})
1311
+
1312
+ import io
1313
+ manifest_bytes = json.dumps(manifest, indent=2).encode()
1314
+ manifest_info = tarfile.TarInfo(name="manifest.json")
1315
+ manifest_info.size = len(manifest_bytes)
1316
+ tar.addfile(manifest_info, io.BytesIO(manifest_bytes))
1317
+
1318
+ bundle_path = bundle_path_new
1319
+
1320
+ # Get registry
1321
+ registry = get_registry(
1322
+ registry=parsed["registry"],
1323
+ token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN"),
1324
+ )
1325
+
1326
+ # Publish
1327
+ result = registry.publish(
1328
+ bundle_path=bundle_path,
1329
+ force=parsed["force"],
1330
+ )
1331
+
1332
+ if parsed["json"]:
1333
+ self._print_json({"ok": True, **result})
1334
+ else:
1335
+ self._print_success(f"Published {result['name']}@{result['version']}")
1336
+ print(f" Registry: {parsed['registry'] or '~/.praison/registry'}")
1337
+ print(f" Checksum: {result['checksum'][:16]}...")
1338
+
1339
+ return self.EXIT_SUCCESS
1340
+
1341
+ except Exception as e:
1342
+ self._print_error(str(e))
1343
+ return self.EXIT_GENERAL_ERROR
1344
+
1345
+ def cmd_pull(self, args: List[str]) -> int:
1346
+ """Pull recipe from registry."""
1347
+ spec = {
1348
+ "name": {"positional": True, "default": ""},
1349
+ "registry": {"default": None},
1350
+ "token": {"default": None},
1351
+ "output": {"short": "-o", "default": "."},
1352
+ "json": {"flag": True, "default": False},
1353
+ }
1354
+ parsed = self._parse_args(args, spec)
1355
+
1356
+ if not parsed["name"]:
1357
+ self._print_error("Recipe name required (e.g., my-recipe@1.0.0)")
1358
+ return self.EXIT_VALIDATION_ERROR
1359
+
1360
+ try:
1361
+ from praisonai.recipe.registry import get_registry
1362
+
1363
+ # Parse name@version
1364
+ name = parsed["name"]
1365
+ version = None
1366
+ if "@" in name:
1367
+ name, version = name.rsplit("@", 1)
1368
+
1369
+ # Get registry
1370
+ registry = get_registry(
1371
+ registry=parsed["registry"],
1372
+ token=parsed["token"] or os.environ.get("PRAISONAI_REGISTRY_TOKEN"),
1373
+ )
1374
+
1375
+ # Pull
1376
+ result = registry.pull(
1377
+ name=name,
1378
+ version=version,
1379
+ output_dir=Path(parsed["output"]),
1380
+ )
1381
+
1382
+ if parsed["json"]:
1383
+ self._print_json({"ok": True, **result})
1384
+ else:
1385
+ self._print_success(f"Pulled {result['name']}@{result['version']}")
1386
+ print(f" Path: {result['path']}")
1387
+
1388
+ return self.EXIT_SUCCESS
1389
+
1390
+ except Exception as e:
1391
+ self._print_error(str(e))
1392
+ return self.EXIT_GENERAL_ERROR
1393
+
1394
+ def cmd_runs(self, args: List[str]) -> int:
1395
+ """List/manage run history."""
1396
+ spec = {
1397
+ "action": {"positional": True, "default": "list"},
1398
+ "recipe": {"default": None},
1399
+ "session": {"default": None},
1400
+ "limit": {"default": "20"},
1401
+ "json": {"flag": True, "default": False},
1402
+ }
1403
+ parsed = self._parse_args(args, spec)
1404
+
1405
+ try:
1406
+ from praisonai.recipe.history import get_history
1407
+
1408
+ history = get_history()
1409
+ action = parsed["action"]
1410
+
1411
+ if action == "list":
1412
+ runs = history.list_runs(
1413
+ recipe=parsed["recipe"],
1414
+ session_id=parsed["session"],
1415
+ limit=int(parsed["limit"]),
1416
+ )
1417
+
1418
+ if parsed["json"]:
1419
+ self._print_json({"runs": runs, "count": len(runs)})
1420
+ else:
1421
+ if not runs:
1422
+ print("No runs found")
1423
+ else:
1424
+ print(f"Recent runs ({len(runs)}):\n")
1425
+ for run in runs:
1426
+ status_icon = "✓" if run.get("status") == "success" else "✗"
1427
+ print(f" {status_icon} {run['run_id']}")
1428
+ print(f" Recipe: {run.get('recipe', 'unknown')}")
1429
+ print(f" Status: {run.get('status', 'unknown')}")
1430
+ print(f" Time: {run.get('stored_at', 'unknown')}")
1431
+ print()
1432
+
1433
+ elif action == "stats":
1434
+ stats = history.get_stats()
1435
+ if parsed["json"]:
1436
+ self._print_json(stats)
1437
+ else:
1438
+ print("Run History Stats:")
1439
+ print(f" Total runs: {stats['total_runs']}")
1440
+ print(f" Storage size: {stats['total_size_bytes'] / 1024:.1f} KB")
1441
+ print(f" Path: {stats['storage_path']}")
1442
+
1443
+ elif action == "cleanup":
1444
+ deleted = history.cleanup()
1445
+ if parsed["json"]:
1446
+ self._print_json({"deleted": deleted})
1447
+ else:
1448
+ self._print_success(f"Cleaned up {deleted} old runs")
1449
+
1450
+ else:
1451
+ self._print_error(f"Unknown action: {action}. Use: list, stats, cleanup")
1452
+ return self.EXIT_VALIDATION_ERROR
1453
+
1454
+ return self.EXIT_SUCCESS
1455
+
1456
+ except Exception as e:
1457
+ self._print_error(str(e))
1458
+ return self.EXIT_GENERAL_ERROR
1459
+
1460
+ def cmd_sbom(self, args: List[str]) -> int:
1461
+ """Generate SBOM for a recipe."""
1462
+ spec = {
1463
+ "recipe": {"positional": True, "default": ""},
1464
+ "format": {"default": "cyclonedx"},
1465
+ "output": {"short": "-o", "default": None},
1466
+ "json": {"flag": True, "default": False},
1467
+ }
1468
+ parsed = self._parse_args(args, spec)
1469
+
1470
+ if not parsed["recipe"]:
1471
+ self._print_error("Recipe path required")
1472
+ return self.EXIT_VALIDATION_ERROR
1473
+
1474
+ try:
1475
+ from praisonai.recipe.security import generate_sbom
1476
+
1477
+ recipe_path = Path(parsed["recipe"])
1478
+ if not recipe_path.exists():
1479
+ # Try to find recipe by name
1480
+ info = self.recipe.describe(parsed["recipe"])
1481
+ if info and info.path:
1482
+ recipe_path = Path(info.path)
1483
+ else:
1484
+ self._print_error(f"Recipe not found: {parsed['recipe']}")
1485
+ return self.EXIT_NOT_FOUND
1486
+
1487
+ sbom = generate_sbom(
1488
+ recipe_path=recipe_path,
1489
+ format=parsed["format"],
1490
+ )
1491
+
1492
+ if parsed["output"]:
1493
+ with open(parsed["output"], "w") as f:
1494
+ json.dump(sbom, f, indent=2)
1495
+ self._print_success(f"SBOM written to {parsed['output']}")
1496
+ elif parsed["json"]:
1497
+ self._print_json(sbom)
1498
+ else:
1499
+ print(f"SBOM ({parsed['format']}):")
1500
+ print(f" Components: {len(sbom.get('components', []))}")
1501
+ for comp in sbom.get("components", [])[:10]:
1502
+ print(f" - {comp['name']}@{comp['version']}")
1503
+ if len(sbom.get("components", [])) > 10:
1504
+ print(f" ... and {len(sbom['components']) - 10} more")
1505
+
1506
+ return self.EXIT_SUCCESS
1507
+
1508
+ except Exception as e:
1509
+ self._print_error(str(e))
1510
+ return self.EXIT_GENERAL_ERROR
1511
+
1512
+ def cmd_audit(self, args: List[str]) -> int:
1513
+ """Audit recipe dependencies."""
1514
+ spec = {
1515
+ "recipe": {"positional": True, "default": ""},
1516
+ "json": {"flag": True, "default": False},
1517
+ "strict": {"flag": True, "default": False},
1518
+ }
1519
+ parsed = self._parse_args(args, spec)
1520
+
1521
+ if not parsed["recipe"]:
1522
+ self._print_error("Recipe path required")
1523
+ return self.EXIT_VALIDATION_ERROR
1524
+
1525
+ try:
1526
+ from praisonai.recipe.security import audit_dependencies
1527
+
1528
+ recipe_path = Path(parsed["recipe"])
1529
+ if not recipe_path.exists():
1530
+ info = self.recipe.describe(parsed["recipe"])
1531
+ if info and info.path:
1532
+ recipe_path = Path(info.path)
1533
+ else:
1534
+ self._print_error(f"Recipe not found: {parsed['recipe']}")
1535
+ return self.EXIT_NOT_FOUND
1536
+
1537
+ report = audit_dependencies(recipe_path)
1538
+
1539
+ if parsed["json"]:
1540
+ self._print_json(report)
1541
+ else:
1542
+ print(f"Audit Report: {report['recipe']}")
1543
+ print(f" Lockfile: {report['lockfile'] or 'Not found'}")
1544
+ print(f" Dependencies: {len(report['dependencies'])}")
1545
+
1546
+ if report["vulnerabilities"]:
1547
+ print(f"\n [red]Vulnerabilities ({len(report['vulnerabilities'])}):[/red]")
1548
+ for vuln in report["vulnerabilities"]:
1549
+ print(f" - {vuln['package']}: {vuln['vulnerability_id']}")
1550
+
1551
+ if report["warnings"]:
1552
+ print("\n Warnings:")
1553
+ for warn in report["warnings"]:
1554
+ print(f" - {warn}")
1555
+
1556
+ if report["passed"]:
1557
+ self._print_success("Audit passed")
1558
+ else:
1559
+ self._print_error("Audit failed")
1560
+
1561
+ if parsed["strict"] and not report["passed"]:
1562
+ return self.EXIT_VALIDATION_ERROR
1563
+
1564
+ return self.EXIT_SUCCESS
1565
+
1566
+ except Exception as e:
1567
+ self._print_error(str(e))
1568
+ return self.EXIT_GENERAL_ERROR
1569
+
1570
+ def cmd_sign(self, args: List[str]) -> int:
1571
+ """Sign a recipe bundle."""
1572
+ spec = {
1573
+ "bundle": {"positional": True, "default": ""},
1574
+ "key": {"default": None},
1575
+ "output": {"short": "-o", "default": None},
1576
+ "json": {"flag": True, "default": False},
1577
+ }
1578
+ parsed = self._parse_args(args, spec)
1579
+
1580
+ if not parsed["bundle"]:
1581
+ self._print_error("Bundle path required")
1582
+ return self.EXIT_VALIDATION_ERROR
1583
+
1584
+ if not parsed["key"]:
1585
+ self._print_error("Private key path required (--key)")
1586
+ return self.EXIT_VALIDATION_ERROR
1587
+
1588
+ try:
1589
+ from praisonai.recipe.security import sign_bundle
1590
+
1591
+ sig_path = sign_bundle(
1592
+ bundle_path=parsed["bundle"],
1593
+ private_key_path=parsed["key"],
1594
+ output_path=parsed["output"],
1595
+ )
1596
+
1597
+ if parsed["json"]:
1598
+ self._print_json({"ok": True, "signature": str(sig_path)})
1599
+ else:
1600
+ self._print_success(f"Bundle signed: {sig_path}")
1601
+
1602
+ return self.EXIT_SUCCESS
1603
+
1604
+ except ImportError:
1605
+ self._print_error("cryptography package required. Install with: pip install cryptography")
1606
+ return self.EXIT_MISSING_DEPS
1607
+ except Exception as e:
1608
+ self._print_error(str(e))
1609
+ return self.EXIT_GENERAL_ERROR
1610
+
1611
+ def cmd_verify(self, args: List[str]) -> int:
1612
+ """Verify bundle signature."""
1613
+ spec = {
1614
+ "bundle": {"positional": True, "default": ""},
1615
+ "key": {"default": None},
1616
+ "signature": {"default": None},
1617
+ "json": {"flag": True, "default": False},
1618
+ }
1619
+ parsed = self._parse_args(args, spec)
1620
+
1621
+ if not parsed["bundle"]:
1622
+ self._print_error("Bundle path required")
1623
+ return self.EXIT_VALIDATION_ERROR
1624
+
1625
+ if not parsed["key"]:
1626
+ self._print_error("Public key path required (--key)")
1627
+ return self.EXIT_VALIDATION_ERROR
1628
+
1629
+ try:
1630
+ from praisonai.recipe.security import verify_bundle
1631
+
1632
+ valid, message = verify_bundle(
1633
+ bundle_path=parsed["bundle"],
1634
+ public_key_path=parsed["key"],
1635
+ signature_path=parsed["signature"],
1636
+ )
1637
+
1638
+ if parsed["json"]:
1639
+ self._print_json({"valid": valid, "message": message})
1640
+ else:
1641
+ if valid:
1642
+ self._print_success(message)
1643
+ else:
1644
+ self._print_error(message)
1645
+
1646
+ return self.EXIT_SUCCESS if valid else self.EXIT_VALIDATION_ERROR
1647
+
1648
+ except ImportError:
1649
+ self._print_error("cryptography package required. Install with: pip install cryptography")
1650
+ return self.EXIT_MISSING_DEPS
1651
+ except Exception as e:
1652
+ self._print_error(str(e))
1653
+ return self.EXIT_GENERAL_ERROR
1654
+
1655
+ def cmd_policy(self, args: List[str]) -> int:
1656
+ """Manage policy packs."""
1657
+ spec = {
1658
+ "action": {"positional": True, "default": "show"},
1659
+ "file": {"positional": True, "default": None},
1660
+ "output": {"short": "-o", "default": None},
1661
+ "json": {"flag": True, "default": False},
1662
+ }
1663
+ parsed = self._parse_args(args, spec)
1664
+
1665
+ try:
1666
+ from praisonai.recipe.policy import PolicyPack, get_default_policy
1667
+
1668
+ action = parsed["action"]
1669
+
1670
+ if action == "show":
1671
+ # Show default or loaded policy
1672
+ if parsed["file"]:
1673
+ policy = PolicyPack.load(parsed["file"])
1674
+ else:
1675
+ policy = get_default_policy()
1676
+
1677
+ if parsed["json"]:
1678
+ self._print_json(policy.to_dict())
1679
+ else:
1680
+ print(f"Policy: {policy.name}")
1681
+ print(f"\nAllowed tools ({len(policy.allowed_tools)}):")
1682
+ for tool in list(policy.allowed_tools)[:5]:
1683
+ print(f" - {tool}")
1684
+ print(f"\nDenied tools ({len(policy.denied_tools)}):")
1685
+ for tool in list(policy.denied_tools)[:5]:
1686
+ print(f" - {tool}")
1687
+ print(f"\nPII mode: {policy.pii_mode}")
1688
+
1689
+ elif action == "init":
1690
+ # Create a new policy file
1691
+ output = parsed["output"] or "policy.yaml"
1692
+ policy = get_default_policy()
1693
+ policy.save(output)
1694
+ self._print_success(f"Policy template created: {output}")
1695
+
1696
+ elif action == "validate":
1697
+ if not parsed["file"]:
1698
+ self._print_error("Policy file required")
1699
+ return self.EXIT_VALIDATION_ERROR
1700
+
1701
+ policy = PolicyPack.load(parsed["file"])
1702
+ self._print_success(f"Policy valid: {policy.name}")
1703
+
1704
+ else:
1705
+ self._print_error(f"Unknown action: {action}. Use: show, init, validate")
1706
+ return self.EXIT_VALIDATION_ERROR
1707
+
1708
+ return self.EXIT_SUCCESS
1709
+
1710
+ except Exception as e:
1711
+ self._print_error(str(e))
1712
+ return self.EXIT_GENERAL_ERROR
1713
+
1714
+ def _get_timestamp(self) -> str:
1715
+ """Get current timestamp."""
1716
+ from datetime import datetime, timezone
1717
+ return datetime.now(timezone.utc).isoformat()
1718
+
1719
+
1720
+ def handle_recipe_command(args: List[str]) -> int:
1721
+ """Entry point for recipe command."""
1722
+ handler = RecipeHandler()
1723
+ return handler.handle(args)