Skip to content

Commit 1138b89

Browse files
feat: Phase 1b CLI Backend Protocol - YAML cli_backend field + --cli-backend CLI flag + back-compat gate (#1531)
* feat: Phase 1b CLI Backend Protocol - YAML cli_backend field + --cli-backend CLI flag + back-compat gate - Add cli_backend field support in YAML agents_generator.py - Add --cli-backend CLI flag with dynamic choices from registry - Add mutual exclusion check between --cli-backend and --external-agent - Add 'praisonai backends list' subcommand - Create comprehensive unit tests for YAML, CLI, and back-compat - Add example YAML with cli_backend configuration - Maintain backward compatibility with existing --external-agent flag Implements the three equivalent ways to use CLI backends: 1. Python: Agent(cli_backend='claude-code') 2. YAML: cli_backend: claude-code 3. CLI: praisonai 'prompt' --cli-backend claude-code Fixes #1530 Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com> * fix: CLI Backend Protocol P1/P2 issues - resolve type mismatch, improve UX, fix tests - P1: Fix CLI backend type mismatch - resolve string to backend instance in handle_direct_prompt - P2: Fix choices=[] fallback to None for better UX when imports fail - P2: Add 'backends' to special_commands so 'praisonai backends list' works - P2: Move mutual exclusion check into argparse mutually exclusive group - Security: Avoid logging sensitive overrides in cli_backend error messages - Tests: Fix mutual exclusion test for argparse-level enforcement - Tests: Fix help output assertions to run outside SystemExit context - Tests: Improve direct-prompt backend test to exercise full execution path - Tests: Fix YAML backend tests to assert resolved object attachment - Tests: Use _tasks prefix for intentionally unused variables Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> --------- Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Co-authored-by: MervinPraison <MervinPraison@users.noreply.github.com>
1 parent 6821fb3 commit 1138b89

6 files changed

Lines changed: 562 additions & 3 deletions

File tree

examples/yaml/cli_backend.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
framework: praisonai
2+
topic: coding
3+
roles:
4+
coder:
5+
role: Code refactorer
6+
goal: Refactor Python modules for better maintainability
7+
backstory: Senior engineer with expertise in clean code principles
8+
cli_backend: claude-code
9+
tasks:
10+
refactor:
11+
description: Refactor utils.py to improve code quality
12+
expected_output: Refactored code with improved structure and documentation

src/praisonai/praisonai/agents_generator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,33 @@ def _run_praisonai(self, config, topic, tools_dict):
12471247
# string, or a full SkillsConfig dict (paths/dirs/auto_discover).
12481248
agent_skills = details.get('skills')
12491249

1250+
# H17: CLI Backend support - delegates full turns to external CLI tools
1251+
cli_backend_config = details.get('cli_backend')
1252+
cli_backend_resolved = None
1253+
cli_backend_label = None
1254+
if cli_backend_config:
1255+
try:
1256+
from praisonai.cli_backends import resolve_cli_backend
1257+
if isinstance(cli_backend_config, str):
1258+
# Simple string ID: "claude-code"
1259+
cli_backend_label = cli_backend_config
1260+
cli_backend_resolved = resolve_cli_backend(cli_backend_config)
1261+
elif isinstance(cli_backend_config, dict):
1262+
# Dict format: {id: "claude-code", overrides: {timeout_ms: 60000}}
1263+
backend_id = cli_backend_config.get('id')
1264+
cli_backend_label = backend_id or "<missing>"
1265+
overrides = cli_backend_config.get('overrides', {})
1266+
if not backend_id:
1267+
raise ValueError("cli_backend dict must contain an 'id' field")
1268+
cli_backend_resolved = resolve_cli_backend(backend_id, overrides=overrides)
1269+
else:
1270+
cli_backend_label = type(cli_backend_config).__name__
1271+
raise ValueError(f"cli_backend must be string or dict, got: {type(cli_backend_config).__name__}")
1272+
except ImportError:
1273+
self.logger.warning("CLI backend '%s' requested but not available", cli_backend_label or "<unknown>")
1274+
except Exception as e:
1275+
self.logger.warning("Failed to resolve CLI backend '%s': %s", cli_backend_label or "<unknown>", e)
1276+
12501277
agent = PraisonAgent(
12511278
name=role_filled,
12521279
role=role_filled,
@@ -1264,6 +1291,7 @@ def _run_praisonai(self, config, topic, tools_dict):
12641291
approval=approval_config,
12651292
output=output_config,
12661293
skills=agent_skills,
1294+
cli_backend=cli_backend_resolved,
12671295
)
12681296

12691297
if self.agent_callback:

src/praisonai/praisonai/cli/main.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ def main(self):
350350
self.args = args
351351
invocation_cmd = "praisonai"
352352
version_string = f"PraisonAI version {__version__}"
353+
353354

354355
# Handle -p/--prompt flag - treat as direct prompt
355356
if getattr(args, 'prompt_flag', None):
@@ -393,6 +394,28 @@ def main(self):
393394
exit_code = AgentSchedulerHandler.handle_schedule_command(args, unknown_args, daemon_mode=daemon_mode)
394395

395396
sys.exit(exit_code)
397+
398+
# Handle backends command
399+
elif args.command == "backends":
400+
from rich import print
401+
subcommand = unknown_args[0] if unknown_args and not unknown_args[0].startswith('-') else None
402+
403+
if subcommand == "list" or subcommand is None:
404+
# List registered CLI backends
405+
try:
406+
from praisonai.cli_backends import list_cli_backends
407+
backends = list_cli_backends()
408+
for backend in backends:
409+
print(backend)
410+
return ""
411+
except ImportError:
412+
print("[red]CLI backends not available[/red]")
413+
return None
414+
else:
415+
print(f"[red]Unknown backends subcommand: {subcommand}[/red]")
416+
print("Available subcommands: list")
417+
return None
418+
396419
elif args.command.startswith("tests.test") or args.command.startswith("tests/test"): # Argument used for testing purposes
397420
print("test")
398421
return "test"
@@ -875,7 +898,7 @@ def parse_args(self):
875898
return default_args
876899

877900
# Define special commands
878-
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths', 'claw', 'github', 'managed', 'flow', 'dashboard']
901+
special_commands = ['chat', 'code', 'call', 'realtime', 'train', 'ui', 'context', 'research', 'memory', 'rules', 'workflow', 'hooks', 'knowledge', 'session', 'tools', 'todo', 'docs', 'mcp', 'commit', 'serve', 'schedule', 'skills', 'profile', 'eval', 'agents', 'run', 'thinking', 'compaction', 'output', 'deploy', 'templates', 'recipe', 'endpoints', 'audio', 'embed', 'embedding', 'images', 'moderate', 'files', 'batches', 'vector-stores', 'rerank', 'ocr', 'assistants', 'fine-tuning', 'completions', 'messages', 'guardrails', 'rag', 'videos', 'a2a', 'containers', 'passthrough', 'responses', 'search', 'realtime-api', 'doctor', 'registry', 'package', 'install', 'uninstall', 'acp', 'debug', 'lsp', 'diag', 'browser', 'replay', 'bot', 'gateway', 'sandbox', 'wizard', 'migrate', 'security', 'persistence', 'paths', 'claw', 'github', 'managed', 'flow', 'dashboard', 'backends']
879902

880903
parser = argparse.ArgumentParser(prog="praisonai", description="praisonAI command-line interface")
881904
parser.add_argument("--framework", choices=["crewai", "autogen", "praisonai"], help="Specify the framework")
@@ -1057,9 +1080,23 @@ def parse_args(self):
10571080
# Sandbox Execution - secure command execution
10581081
parser.add_argument("--sandbox", type=str, choices=["off", "basic", "strict"], help="Enable sandboxed command execution")
10591082

1060-
# External Agent - use external AI CLI tools
1061-
parser.add_argument("--external-agent", type=str, choices=["claude", "gemini", "codex", "cursor"],
1083+
# Backend group - mutually exclusive external agent and CLI backend options
1084+
backend_group = parser.add_mutually_exclusive_group()
1085+
backend_group.add_argument("--external-agent", type=str, choices=["claude", "gemini", "codex", "cursor"],
10621086
help="Use external AI CLI tool (claude, gemini, codex, cursor)")
1087+
1088+
# CLI Backend - delegate agent turns to CLI backend
1089+
# Dynamically populate choices from registered backends
1090+
try:
1091+
from praisonai.cli_backends import list_cli_backends
1092+
cli_backend_choices = list_cli_backends() or None
1093+
except ImportError:
1094+
cli_backend_choices = None
1095+
1096+
backend_group.add_argument("--cli-backend", type=str, choices=cli_backend_choices,
1097+
help="Delegate agent turns to a CLI backend (see praisonai backends list)")
1098+
1099+
# External agent direct mode (not mutually exclusive with backend choice)
10631100
parser.add_argument("--external-agent-direct", action="store_true",
10641101
help="Use external agent as direct proxy (skip manager Agent delegation)")
10651102

@@ -4507,6 +4544,14 @@ def level_based_approve(function_name, arguments, risk_level):
45074544

45084545
return result
45094546

4547+
# CLI Backend - delegate agent turns to external CLI tools
4548+
if hasattr(self, 'args') and getattr(self.args, 'cli_backend', None):
4549+
try:
4550+
from praisonai.cli_backends import resolve_cli_backend
4551+
agent_config["cli_backend"] = resolve_cli_backend(self.args.cli_backend)
4552+
except Exception as e:
4553+
self.logger.warning(f"Failed to resolve CLI backend '{self.args.cli_backend}': {e}")
4554+
45104555
# Flow Display - Visual workflow tracking
45114556
if hasattr(self, 'args') and getattr(self.args, 'flow_display', False):
45124557
from .features.flow_display import FlowDisplayHandler
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Unit tests for CLI backend flag and backends list command."""
2+
3+
import pytest
4+
import sys
5+
from unittest.mock import patch, MagicMock
6+
from io import StringIO
7+
8+
def test_cli_backend_flag_choices():
9+
"""Test that CLI backend flag includes registered backend choices."""
10+
from praisonai.cli.main import PraisonAI
11+
12+
with patch('praisonai.cli_backends.list_cli_backends', return_value=['claude-code', 'test-backend']):
13+
praison = PraisonAI()
14+
15+
# Parse help to check choices are included
16+
with patch('sys.argv', ['praisonai', '--help']):
17+
with patch('sys.stdout', new_callable=StringIO) as mock_stdout:
18+
with pytest.raises(SystemExit):
19+
praison.parse_args()
20+
help_output = mock_stdout.getvalue()
21+
22+
assert '--cli-backend' in help_output
23+
assert 'claude-code' in help_output
24+
assert 'test-backend' in help_output
25+
26+
def test_cli_backend_flag_with_prompt():
27+
"""Test CLI backend flag with direct prompt."""
28+
from praisonai.cli.main import PraisonAI
29+
30+
with patch('praisonai.cli_backends.list_cli_backends', return_value=['claude-code']):
31+
with patch('praisonai.cli_backends.resolve_cli_backend') as mock_resolve:
32+
mock_backend = MagicMock()
33+
mock_resolve.return_value = mock_backend
34+
35+
praison = PraisonAI()
36+
37+
# Mock agent creation and execution
38+
with patch.object(praison, 'handle_direct_prompt') as mock_handler:
39+
mock_handler.return_value = "test result"
40+
41+
# Test with CLI backend flag
42+
with patch('sys.argv', ['praisonai', '--cli-backend', 'claude-code', 'Hello']):
43+
args = praison.parse_args()
44+
assert args.cli_backend == 'claude-code'
45+
assert args.command == 'Hello'
46+
47+
# Test the full execution path
48+
praison.args = args
49+
result = praison.main()
50+
51+
assert result == "test result"
52+
mock_handler.assert_called_once()
53+
mock_resolve.assert_called_once_with('claude-code')
54+
55+
def test_mutual_exclusion_cli_backend_external_agent():
56+
"""Test mutual exclusion between --cli-backend and --external-agent."""
57+
from praisonai.cli.main import PraisonAI
58+
59+
with patch('praisonai.cli_backends.list_cli_backends', return_value=['claude-code']):
60+
praison = PraisonAI()
61+
62+
# Test that both flags trigger argparse error
63+
with patch('sys.argv', ['praisonai', '--cli-backend', 'claude-code', '--external-agent', 'claude', 'Hello']):
64+
with pytest.raises(SystemExit):
65+
praison.parse_args()
66+
67+
def test_backends_list_command():
68+
"""Test 'praisonai backends list' command."""
69+
from praisonai.cli.main import PraisonAI
70+
71+
with patch('praisonai.cli_backends.list_cli_backends', return_value=['claude-code', 'test-backend']):
72+
praison = PraisonAI()
73+
74+
with patch('sys.argv', ['praisonai', 'backends', 'list']):
75+
args = praison.parse_args()
76+
assert args.command == 'backends'
77+
78+
# Mock main method execution
79+
with patch('builtins.print') as mock_print:
80+
result = praison.main()
81+
82+
# Should print each backend
83+
expected_calls = [
84+
(('claude-code',),),
85+
(('test-backend',),)
86+
]
87+
mock_print.assert_has_calls(expected_calls)
88+
assert result == ""
89+
90+
def test_backends_command_no_subcommand():
91+
"""Test 'praisonai backends' command without subcommand (defaults to list)."""
92+
from praisonai.cli.main import PraisonAI
93+
94+
with patch('praisonai.cli_backends.list_cli_backends', return_value=['claude-code']):
95+
praison = PraisonAI()
96+
97+
with patch('sys.argv', ['praisonai', 'backends']):
98+
args = praison.parse_args()
99+
assert args.command == 'backends'
100+
101+
# Mock main method execution
102+
with patch('builtins.print') as mock_print:
103+
result = praison.main()
104+
105+
# Should print backend
106+
mock_print.assert_called_with('claude-code')
107+
assert result == ""
108+
109+
def test_backends_command_unknown_subcommand():
110+
"""Test 'praisonai backends unknown' with invalid subcommand."""
111+
from praisonai.cli.main import PraisonAI
112+
113+
praison = PraisonAI()
114+
115+
with patch('sys.argv', ['praisonai', 'backends', 'unknown']):
116+
args = praison.parse_args()
117+
assert args.command == 'backends'
118+
119+
# Mock main method execution
120+
with patch('builtins.print') as mock_print:
121+
result = praison.main()
122+
123+
# Should print error
124+
expected_calls = [
125+
(('[red]Unknown backends subcommand: unknown[/red]',),),
126+
(('Available subcommands: list',),)
127+
]
128+
mock_print.assert_has_calls(expected_calls)
129+
assert result is None
130+
131+
def test_backends_command_import_error():
132+
"""Test backends command when CLI backends not available."""
133+
from praisonai.cli.main import PraisonAI
134+
135+
praison = PraisonAI()
136+
137+
with patch('sys.argv', ['praisonai', 'backends', 'list']):
138+
args = praison.parse_args()
139+
assert args.command == 'backends'
140+
141+
# Mock import error
142+
with patch('builtins.__import__', side_effect=ImportError("No module")):
143+
with patch('builtins.print') as mock_print:
144+
result = praison.main()
145+
146+
# Should print error
147+
mock_print.assert_called_with("[red]CLI backends not available[/red]")
148+
assert result is None
149+
150+
def test_cli_backend_flag_no_choices_when_import_fails():
151+
"""Test CLI backend flag when list_cli_backends import fails."""
152+
from praisonai.cli.main import PraisonAI
153+
154+
with patch('praisonai.cli_backends.list_cli_backends', side_effect=ImportError("Not available")):
155+
praison = PraisonAI()
156+
157+
# Should not crash during argument parsing
158+
with patch('sys.argv', ['praisonai', '--help']):
159+
with pytest.raises(SystemExit):
160+
with patch('sys.stdout', new_callable=StringIO):
161+
praison.parse_args()

0 commit comments

Comments
 (0)