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.
- praisonai/__init__.py +54 -0
- praisonai/__main__.py +15 -0
- praisonai/acp/__init__.py +54 -0
- praisonai/acp/config.py +159 -0
- praisonai/acp/server.py +587 -0
- praisonai/acp/session.py +219 -0
- praisonai/adapters/__init__.py +50 -0
- praisonai/adapters/readers.py +395 -0
- praisonai/adapters/rerankers.py +315 -0
- praisonai/adapters/retrievers.py +394 -0
- praisonai/adapters/vector_stores.py +409 -0
- praisonai/agent_scheduler.py +337 -0
- praisonai/agents_generator.py +903 -0
- praisonai/api/call.py +292 -0
- praisonai/auto.py +1197 -0
- praisonai/capabilities/__init__.py +275 -0
- praisonai/capabilities/a2a.py +140 -0
- praisonai/capabilities/assistants.py +283 -0
- praisonai/capabilities/audio.py +320 -0
- praisonai/capabilities/batches.py +469 -0
- praisonai/capabilities/completions.py +336 -0
- praisonai/capabilities/container_files.py +155 -0
- praisonai/capabilities/containers.py +93 -0
- praisonai/capabilities/embeddings.py +158 -0
- praisonai/capabilities/files.py +467 -0
- praisonai/capabilities/fine_tuning.py +293 -0
- praisonai/capabilities/guardrails.py +182 -0
- praisonai/capabilities/images.py +330 -0
- praisonai/capabilities/mcp.py +190 -0
- praisonai/capabilities/messages.py +270 -0
- praisonai/capabilities/moderations.py +154 -0
- praisonai/capabilities/ocr.py +217 -0
- praisonai/capabilities/passthrough.py +204 -0
- praisonai/capabilities/rag.py +207 -0
- praisonai/capabilities/realtime.py +160 -0
- praisonai/capabilities/rerank.py +165 -0
- praisonai/capabilities/responses.py +266 -0
- praisonai/capabilities/search.py +109 -0
- praisonai/capabilities/skills.py +133 -0
- praisonai/capabilities/vector_store_files.py +334 -0
- praisonai/capabilities/vector_stores.py +304 -0
- praisonai/capabilities/videos.py +141 -0
- praisonai/chainlit_ui.py +304 -0
- praisonai/chat/__init__.py +106 -0
- praisonai/chat/app.py +125 -0
- praisonai/cli/__init__.py +26 -0
- praisonai/cli/app.py +213 -0
- praisonai/cli/commands/__init__.py +75 -0
- praisonai/cli/commands/acp.py +70 -0
- praisonai/cli/commands/completion.py +333 -0
- praisonai/cli/commands/config.py +166 -0
- praisonai/cli/commands/debug.py +142 -0
- praisonai/cli/commands/diag.py +55 -0
- praisonai/cli/commands/doctor.py +166 -0
- praisonai/cli/commands/environment.py +179 -0
- praisonai/cli/commands/lsp.py +112 -0
- praisonai/cli/commands/mcp.py +210 -0
- praisonai/cli/commands/profile.py +457 -0
- praisonai/cli/commands/run.py +228 -0
- praisonai/cli/commands/schedule.py +150 -0
- praisonai/cli/commands/serve.py +97 -0
- praisonai/cli/commands/session.py +212 -0
- praisonai/cli/commands/traces.py +145 -0
- praisonai/cli/commands/version.py +101 -0
- praisonai/cli/configuration/__init__.py +18 -0
- praisonai/cli/configuration/loader.py +353 -0
- praisonai/cli/configuration/paths.py +114 -0
- praisonai/cli/configuration/schema.py +164 -0
- praisonai/cli/features/__init__.py +268 -0
- praisonai/cli/features/acp.py +236 -0
- praisonai/cli/features/action_orchestrator.py +546 -0
- praisonai/cli/features/agent_scheduler.py +773 -0
- praisonai/cli/features/agent_tools.py +474 -0
- praisonai/cli/features/agents.py +375 -0
- praisonai/cli/features/at_mentions.py +471 -0
- praisonai/cli/features/auto_memory.py +182 -0
- praisonai/cli/features/autonomy_mode.py +490 -0
- praisonai/cli/features/background.py +356 -0
- praisonai/cli/features/base.py +168 -0
- praisonai/cli/features/capabilities.py +1326 -0
- praisonai/cli/features/checkpoints.py +338 -0
- praisonai/cli/features/code_intelligence.py +652 -0
- praisonai/cli/features/compaction.py +294 -0
- praisonai/cli/features/compare.py +534 -0
- praisonai/cli/features/cost_tracker.py +514 -0
- praisonai/cli/features/debug.py +810 -0
- praisonai/cli/features/deploy.py +517 -0
- praisonai/cli/features/diag.py +289 -0
- praisonai/cli/features/doctor/__init__.py +63 -0
- praisonai/cli/features/doctor/checks/__init__.py +24 -0
- praisonai/cli/features/doctor/checks/acp_checks.py +240 -0
- praisonai/cli/features/doctor/checks/config_checks.py +366 -0
- praisonai/cli/features/doctor/checks/db_checks.py +366 -0
- praisonai/cli/features/doctor/checks/env_checks.py +543 -0
- praisonai/cli/features/doctor/checks/lsp_checks.py +199 -0
- praisonai/cli/features/doctor/checks/mcp_checks.py +349 -0
- praisonai/cli/features/doctor/checks/memory_checks.py +268 -0
- praisonai/cli/features/doctor/checks/network_checks.py +251 -0
- praisonai/cli/features/doctor/checks/obs_checks.py +328 -0
- praisonai/cli/features/doctor/checks/performance_checks.py +235 -0
- praisonai/cli/features/doctor/checks/permissions_checks.py +259 -0
- praisonai/cli/features/doctor/checks/selftest_checks.py +322 -0
- praisonai/cli/features/doctor/checks/serve_checks.py +426 -0
- praisonai/cli/features/doctor/checks/skills_checks.py +231 -0
- praisonai/cli/features/doctor/checks/tools_checks.py +371 -0
- praisonai/cli/features/doctor/engine.py +266 -0
- praisonai/cli/features/doctor/formatters.py +310 -0
- praisonai/cli/features/doctor/handler.py +397 -0
- praisonai/cli/features/doctor/models.py +264 -0
- praisonai/cli/features/doctor/registry.py +239 -0
- praisonai/cli/features/endpoints.py +1019 -0
- praisonai/cli/features/eval.py +560 -0
- praisonai/cli/features/external_agents.py +231 -0
- praisonai/cli/features/fast_context.py +410 -0
- praisonai/cli/features/flow_display.py +566 -0
- praisonai/cli/features/git_integration.py +651 -0
- praisonai/cli/features/guardrail.py +171 -0
- praisonai/cli/features/handoff.py +185 -0
- praisonai/cli/features/hooks.py +583 -0
- praisonai/cli/features/image.py +384 -0
- praisonai/cli/features/interactive_runtime.py +585 -0
- praisonai/cli/features/interactive_tools.py +380 -0
- praisonai/cli/features/interactive_tui.py +603 -0
- praisonai/cli/features/jobs.py +632 -0
- praisonai/cli/features/knowledge.py +531 -0
- praisonai/cli/features/lite.py +244 -0
- praisonai/cli/features/lsp_cli.py +225 -0
- praisonai/cli/features/mcp.py +169 -0
- praisonai/cli/features/message_queue.py +587 -0
- praisonai/cli/features/metrics.py +211 -0
- praisonai/cli/features/n8n.py +673 -0
- praisonai/cli/features/observability.py +293 -0
- praisonai/cli/features/ollama.py +361 -0
- praisonai/cli/features/output_style.py +273 -0
- praisonai/cli/features/package.py +631 -0
- praisonai/cli/features/performance.py +308 -0
- praisonai/cli/features/persistence.py +636 -0
- praisonai/cli/features/profile.py +226 -0
- praisonai/cli/features/profiler/__init__.py +81 -0
- praisonai/cli/features/profiler/core.py +558 -0
- praisonai/cli/features/profiler/optimizations.py +652 -0
- praisonai/cli/features/profiler/suite.py +386 -0
- praisonai/cli/features/profiling.py +350 -0
- praisonai/cli/features/queue/__init__.py +73 -0
- praisonai/cli/features/queue/manager.py +395 -0
- praisonai/cli/features/queue/models.py +286 -0
- praisonai/cli/features/queue/persistence.py +564 -0
- praisonai/cli/features/queue/scheduler.py +484 -0
- praisonai/cli/features/queue/worker.py +372 -0
- praisonai/cli/features/recipe.py +1723 -0
- praisonai/cli/features/recipes.py +449 -0
- praisonai/cli/features/registry.py +229 -0
- praisonai/cli/features/repo_map.py +860 -0
- praisonai/cli/features/router.py +466 -0
- praisonai/cli/features/sandbox_executor.py +515 -0
- praisonai/cli/features/serve.py +829 -0
- praisonai/cli/features/session.py +222 -0
- praisonai/cli/features/skills.py +856 -0
- praisonai/cli/features/slash_commands.py +650 -0
- praisonai/cli/features/telemetry.py +179 -0
- praisonai/cli/features/templates.py +1384 -0
- praisonai/cli/features/thinking.py +305 -0
- praisonai/cli/features/todo.py +334 -0
- praisonai/cli/features/tools.py +680 -0
- praisonai/cli/features/tui/__init__.py +83 -0
- praisonai/cli/features/tui/app.py +580 -0
- praisonai/cli/features/tui/cli.py +566 -0
- praisonai/cli/features/tui/debug.py +511 -0
- praisonai/cli/features/tui/events.py +99 -0
- praisonai/cli/features/tui/mock_provider.py +328 -0
- praisonai/cli/features/tui/orchestrator.py +652 -0
- praisonai/cli/features/tui/screens/__init__.py +50 -0
- praisonai/cli/features/tui/screens/main.py +245 -0
- praisonai/cli/features/tui/screens/queue.py +174 -0
- praisonai/cli/features/tui/screens/session.py +124 -0
- praisonai/cli/features/tui/screens/settings.py +148 -0
- praisonai/cli/features/tui/widgets/__init__.py +56 -0
- praisonai/cli/features/tui/widgets/chat.py +261 -0
- praisonai/cli/features/tui/widgets/composer.py +224 -0
- praisonai/cli/features/tui/widgets/queue_panel.py +200 -0
- praisonai/cli/features/tui/widgets/status.py +167 -0
- praisonai/cli/features/tui/widgets/tool_panel.py +248 -0
- praisonai/cli/features/workflow.py +720 -0
- praisonai/cli/legacy.py +236 -0
- praisonai/cli/main.py +5559 -0
- praisonai/cli/schedule_cli.py +54 -0
- praisonai/cli/state/__init__.py +31 -0
- praisonai/cli/state/identifiers.py +161 -0
- praisonai/cli/state/sessions.py +313 -0
- praisonai/code/__init__.py +93 -0
- praisonai/code/agent_tools.py +344 -0
- praisonai/code/diff/__init__.py +21 -0
- praisonai/code/diff/diff_strategy.py +432 -0
- praisonai/code/tools/__init__.py +27 -0
- praisonai/code/tools/apply_diff.py +221 -0
- praisonai/code/tools/execute_command.py +275 -0
- praisonai/code/tools/list_files.py +274 -0
- praisonai/code/tools/read_file.py +206 -0
- praisonai/code/tools/search_replace.py +248 -0
- praisonai/code/tools/write_file.py +217 -0
- praisonai/code/utils/__init__.py +46 -0
- praisonai/code/utils/file_utils.py +307 -0
- praisonai/code/utils/ignore_utils.py +308 -0
- praisonai/code/utils/text_utils.py +276 -0
- praisonai/db/__init__.py +64 -0
- praisonai/db/adapter.py +531 -0
- praisonai/deploy/__init__.py +62 -0
- praisonai/deploy/api.py +231 -0
- praisonai/deploy/docker.py +454 -0
- praisonai/deploy/doctor.py +367 -0
- praisonai/deploy/main.py +327 -0
- praisonai/deploy/models.py +179 -0
- praisonai/deploy/providers/__init__.py +33 -0
- praisonai/deploy/providers/aws.py +331 -0
- praisonai/deploy/providers/azure.py +358 -0
- praisonai/deploy/providers/base.py +101 -0
- praisonai/deploy/providers/gcp.py +314 -0
- praisonai/deploy/schema.py +208 -0
- praisonai/deploy.py +185 -0
- praisonai/endpoints/__init__.py +53 -0
- praisonai/endpoints/a2u_server.py +410 -0
- praisonai/endpoints/discovery.py +165 -0
- praisonai/endpoints/providers/__init__.py +28 -0
- praisonai/endpoints/providers/a2a.py +253 -0
- praisonai/endpoints/providers/a2u.py +208 -0
- praisonai/endpoints/providers/agents_api.py +171 -0
- praisonai/endpoints/providers/base.py +231 -0
- praisonai/endpoints/providers/mcp.py +263 -0
- praisonai/endpoints/providers/recipe.py +206 -0
- praisonai/endpoints/providers/tools_mcp.py +150 -0
- praisonai/endpoints/registry.py +131 -0
- praisonai/endpoints/server.py +161 -0
- praisonai/inbuilt_tools/__init__.py +24 -0
- praisonai/inbuilt_tools/autogen_tools.py +117 -0
- praisonai/inc/__init__.py +2 -0
- praisonai/inc/config.py +96 -0
- praisonai/inc/models.py +155 -0
- praisonai/integrations/__init__.py +56 -0
- praisonai/integrations/base.py +303 -0
- praisonai/integrations/claude_code.py +270 -0
- praisonai/integrations/codex_cli.py +255 -0
- praisonai/integrations/cursor_cli.py +195 -0
- praisonai/integrations/gemini_cli.py +222 -0
- praisonai/jobs/__init__.py +67 -0
- praisonai/jobs/executor.py +425 -0
- praisonai/jobs/models.py +230 -0
- praisonai/jobs/router.py +314 -0
- praisonai/jobs/server.py +186 -0
- praisonai/jobs/store.py +203 -0
- praisonai/llm/__init__.py +66 -0
- praisonai/llm/registry.py +382 -0
- praisonai/mcp_server/__init__.py +152 -0
- praisonai/mcp_server/adapters/__init__.py +74 -0
- praisonai/mcp_server/adapters/agents.py +128 -0
- praisonai/mcp_server/adapters/capabilities.py +168 -0
- praisonai/mcp_server/adapters/cli_tools.py +568 -0
- praisonai/mcp_server/adapters/extended_capabilities.py +462 -0
- praisonai/mcp_server/adapters/knowledge.py +93 -0
- praisonai/mcp_server/adapters/memory.py +104 -0
- praisonai/mcp_server/adapters/prompts.py +306 -0
- praisonai/mcp_server/adapters/resources.py +124 -0
- praisonai/mcp_server/adapters/tools_bridge.py +280 -0
- praisonai/mcp_server/auth/__init__.py +48 -0
- praisonai/mcp_server/auth/api_key.py +291 -0
- praisonai/mcp_server/auth/oauth.py +460 -0
- praisonai/mcp_server/auth/oidc.py +289 -0
- praisonai/mcp_server/auth/scopes.py +260 -0
- praisonai/mcp_server/cli.py +852 -0
- praisonai/mcp_server/elicitation.py +445 -0
- praisonai/mcp_server/icons.py +302 -0
- praisonai/mcp_server/recipe_adapter.py +573 -0
- praisonai/mcp_server/recipe_cli.py +824 -0
- praisonai/mcp_server/registry.py +703 -0
- praisonai/mcp_server/sampling.py +422 -0
- praisonai/mcp_server/server.py +490 -0
- praisonai/mcp_server/tasks.py +443 -0
- praisonai/mcp_server/transports/__init__.py +18 -0
- praisonai/mcp_server/transports/http_stream.py +376 -0
- praisonai/mcp_server/transports/stdio.py +132 -0
- praisonai/persistence/__init__.py +84 -0
- praisonai/persistence/config.py +238 -0
- praisonai/persistence/conversation/__init__.py +25 -0
- praisonai/persistence/conversation/async_mysql.py +427 -0
- praisonai/persistence/conversation/async_postgres.py +410 -0
- praisonai/persistence/conversation/async_sqlite.py +371 -0
- praisonai/persistence/conversation/base.py +151 -0
- praisonai/persistence/conversation/json_store.py +250 -0
- praisonai/persistence/conversation/mysql.py +387 -0
- praisonai/persistence/conversation/postgres.py +401 -0
- praisonai/persistence/conversation/singlestore.py +240 -0
- praisonai/persistence/conversation/sqlite.py +341 -0
- praisonai/persistence/conversation/supabase.py +203 -0
- praisonai/persistence/conversation/surrealdb.py +287 -0
- praisonai/persistence/factory.py +301 -0
- praisonai/persistence/hooks/__init__.py +18 -0
- praisonai/persistence/hooks/agent_hooks.py +297 -0
- praisonai/persistence/knowledge/__init__.py +26 -0
- praisonai/persistence/knowledge/base.py +144 -0
- praisonai/persistence/knowledge/cassandra.py +232 -0
- praisonai/persistence/knowledge/chroma.py +295 -0
- praisonai/persistence/knowledge/clickhouse.py +242 -0
- praisonai/persistence/knowledge/cosmosdb_vector.py +438 -0
- praisonai/persistence/knowledge/couchbase.py +286 -0
- praisonai/persistence/knowledge/lancedb.py +216 -0
- praisonai/persistence/knowledge/langchain_adapter.py +291 -0
- praisonai/persistence/knowledge/lightrag_adapter.py +212 -0
- praisonai/persistence/knowledge/llamaindex_adapter.py +256 -0
- praisonai/persistence/knowledge/milvus.py +277 -0
- praisonai/persistence/knowledge/mongodb_vector.py +306 -0
- praisonai/persistence/knowledge/pgvector.py +335 -0
- praisonai/persistence/knowledge/pinecone.py +253 -0
- praisonai/persistence/knowledge/qdrant.py +301 -0
- praisonai/persistence/knowledge/redis_vector.py +291 -0
- praisonai/persistence/knowledge/singlestore_vector.py +299 -0
- praisonai/persistence/knowledge/surrealdb_vector.py +309 -0
- praisonai/persistence/knowledge/upstash_vector.py +266 -0
- praisonai/persistence/knowledge/weaviate.py +223 -0
- praisonai/persistence/migrations/__init__.py +10 -0
- praisonai/persistence/migrations/manager.py +251 -0
- praisonai/persistence/orchestrator.py +406 -0
- praisonai/persistence/state/__init__.py +21 -0
- praisonai/persistence/state/async_mongodb.py +200 -0
- praisonai/persistence/state/base.py +107 -0
- praisonai/persistence/state/dynamodb.py +226 -0
- praisonai/persistence/state/firestore.py +175 -0
- praisonai/persistence/state/gcs.py +155 -0
- praisonai/persistence/state/memory.py +245 -0
- praisonai/persistence/state/mongodb.py +158 -0
- praisonai/persistence/state/redis.py +190 -0
- praisonai/persistence/state/upstash.py +144 -0
- praisonai/persistence/tests/__init__.py +3 -0
- praisonai/persistence/tests/test_all_backends.py +633 -0
- praisonai/profiler.py +1214 -0
- praisonai/recipe/__init__.py +134 -0
- praisonai/recipe/bridge.py +278 -0
- praisonai/recipe/core.py +893 -0
- praisonai/recipe/exceptions.py +54 -0
- praisonai/recipe/history.py +402 -0
- praisonai/recipe/models.py +266 -0
- praisonai/recipe/operations.py +440 -0
- praisonai/recipe/policy.py +422 -0
- praisonai/recipe/registry.py +849 -0
- praisonai/recipe/runtime.py +214 -0
- praisonai/recipe/security.py +711 -0
- praisonai/recipe/serve.py +859 -0
- praisonai/recipe/server.py +613 -0
- praisonai/scheduler/__init__.py +45 -0
- praisonai/scheduler/agent_scheduler.py +552 -0
- praisonai/scheduler/base.py +124 -0
- praisonai/scheduler/daemon_manager.py +225 -0
- praisonai/scheduler/state_manager.py +155 -0
- praisonai/scheduler/yaml_loader.py +193 -0
- praisonai/scheduler.py +194 -0
- praisonai/setup/__init__.py +1 -0
- praisonai/setup/build.py +21 -0
- praisonai/setup/post_install.py +23 -0
- praisonai/setup/setup_conda_env.py +25 -0
- praisonai/setup.py +16 -0
- praisonai/templates/__init__.py +116 -0
- praisonai/templates/cache.py +364 -0
- praisonai/templates/dependency_checker.py +358 -0
- praisonai/templates/discovery.py +391 -0
- praisonai/templates/loader.py +564 -0
- praisonai/templates/registry.py +511 -0
- praisonai/templates/resolver.py +206 -0
- praisonai/templates/security.py +327 -0
- praisonai/templates/tool_override.py +498 -0
- praisonai/templates/tools_doctor.py +256 -0
- praisonai/test.py +105 -0
- praisonai/train.py +562 -0
- praisonai/train_vision.py +306 -0
- praisonai/ui/agents.py +824 -0
- praisonai/ui/callbacks.py +57 -0
- praisonai/ui/chainlit_compat.py +246 -0
- praisonai/ui/chat.py +532 -0
- praisonai/ui/code.py +717 -0
- praisonai/ui/colab.py +474 -0
- praisonai/ui/colab_chainlit.py +81 -0
- praisonai/ui/components/aicoder.py +284 -0
- praisonai/ui/context.py +283 -0
- praisonai/ui/database_config.py +56 -0
- praisonai/ui/db.py +294 -0
- praisonai/ui/realtime.py +488 -0
- praisonai/ui/realtimeclient/__init__.py +756 -0
- praisonai/ui/realtimeclient/tools.py +242 -0
- praisonai/ui/sql_alchemy.py +710 -0
- praisonai/upload_vision.py +140 -0
- praisonai/version.py +1 -0
- praisonai-3.0.0.dist-info/METADATA +3493 -0
- praisonai-3.0.0.dist-info/RECORD +393 -0
- praisonai-3.0.0.dist-info/WHEEL +5 -0
- praisonai-3.0.0.dist-info/entry_points.txt +4 -0
- 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)
|