From 0175c5570619ccd33d822b0166464730b371f0be Mon Sep 17 00:00:00 2001 From: "praisonai-triage-agent[bot]" <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:22:47 +0000 Subject: [PATCH 1/3] feat: add delete operations and envs update to managed CLI - Add sessions delete command with confirmation prompt - Add agents delete command with confirmation prompt - Add envs update command supporting --packages and --networking - Add envs delete command with confirmation prompt - All destructive commands support --yes/-y flag to skip confirmation - Add comprehensive unit tests for all new commands - Follows existing patterns in managed.py for consistency Fixes #1430 Co-authored-by: MervinPraison --- .../praisonai/cli/commands/managed.py | 115 +++++++ .../unit/cli/test_managed_cli_destructive.py | 322 ++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 src/praisonai/tests/unit/cli/test_managed_cli_destructive.py diff --git a/src/praisonai/praisonai/cli/commands/managed.py b/src/praisonai/praisonai/cli/commands/managed.py index 3e564a3a2..ea6a2b3bb 100644 --- a/src/praisonai/praisonai/cli/commands/managed.py +++ b/src/praisonai/praisonai/cli/commands/managed.py @@ -316,6 +316,32 @@ def sessions_resume( print(result) +@sessions_app.command("delete") +def sessions_delete( + session_id: str = typer.Argument(..., help="Session ID to delete (sesn_01...)"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +): + """Delete a session. + + Example: + praisonai managed sessions delete sesn_01AbCdEf + praisonai managed sessions delete sesn_01AbCdEf --yes + """ + if not yes: + confirm = typer.confirm(f"Are you sure you want to delete session {session_id}?") + if not confirm: + typer.echo("Deletion cancelled.") + raise typer.Exit(0) + + client = _get_client() + try: + client.beta.sessions.delete(session_id) + typer.echo(f"Session {session_id} deleted successfully.") + except Exception as e: + typer.echo(f"Error deleting session: {e}") + raise typer.Exit(1) + + # ───────────────────────────────────────────────────────────────────────────── # agents sub-commands # ───────────────────────────────────────────────────────────────────────────── @@ -397,6 +423,32 @@ def agents_update( typer.echo(f"Updated agent: {updated.id} (v{getattr(updated,'version','')})") +@agents_app.command("delete") +def agents_delete( + agent_id: str = typer.Argument(..., help="Agent ID to delete (agent_01...)"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +): + """Delete an agent. + + Example: + praisonai managed agents delete agent_01AbCdEf + praisonai managed agents delete agent_01AbCdEf --yes + """ + if not yes: + confirm = typer.confirm(f"Are you sure you want to delete agent {agent_id}?") + if not confirm: + typer.echo("Deletion cancelled.") + raise typer.Exit(0) + + client = _get_client() + try: + client.beta.agents.delete(agent_id) + typer.echo(f"Agent {agent_id} deleted successfully.") + except Exception as e: + typer.echo(f"Error deleting agent: {e}") + raise typer.Exit(1) + + # ───────────────────────────────────────────────────────────────────────────── # envs sub-commands # ───────────────────────────────────────────────────────────────────────────── @@ -441,6 +493,69 @@ def envs_get( typer.echo(f"Config: {cfg}") +@envs_app.command("update") +def envs_update( + env_id: str = typer.Argument(..., help="Environment ID (env_01...)"), + packages: Optional[str] = typer.Option(None, "--packages", "-p", help="Comma-separated pip packages"), + networking: Optional[str] = typer.Option(None, "--networking", help="Networking type: 'full' or 'limited'"), +): + """Update an environment's configuration. + + Example: + praisonai managed envs update env_01AbCdEf --packages "numpy,pandas" + praisonai managed envs update env_01AbCdEf --networking limited + """ + client = _get_client() + kwargs = {} + + if packages: + pkg_list = [p.strip() for p in packages.split(",")] + kwargs["packages"] = {"pip": pkg_list} + + if networking: + if networking not in ["full", "limited"]: + typer.echo("Error: --networking must be 'full' or 'limited'") + raise typer.Exit(1) + kwargs["networking"] = {"type": networking} + + if not kwargs: + typer.echo("Nothing to update. Pass --packages or --networking.") + raise typer.Exit(0) + + try: + updated = client.beta.environments.update(env_id, **kwargs) + typer.echo(f"Updated environment: {updated.id}") + except Exception as e: + typer.echo(f"Error updating environment: {e}") + raise typer.Exit(1) + + +@envs_app.command("delete") +def envs_delete( + env_id: str = typer.Argument(..., help="Environment ID to delete (env_01...)"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +): + """Delete an environment. + + Example: + praisonai managed envs delete env_01AbCdEf + praisonai managed envs delete env_01AbCdEf --yes + """ + if not yes: + confirm = typer.confirm(f"Are you sure you want to delete environment {env_id}?") + if not confirm: + typer.echo("Deletion cancelled.") + raise typer.Exit(0) + + client = _get_client() + try: + client.beta.environments.delete(env_id) + typer.echo(f"Environment {env_id} deleted successfully.") + except Exception as e: + typer.echo(f"Error deleting environment: {e}") + raise typer.Exit(1) + + # ───────────────────────────────────────────────────────────────────────────── # ids sub-commands (save / restore / show — no Anthropic IDs are user-defined) # ───────────────────────────────────────────────────────────────────────────── diff --git a/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py b/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py new file mode 100644 index 000000000..da3f315b5 --- /dev/null +++ b/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py @@ -0,0 +1,322 @@ +""" +Unit tests for managed CLI destructive operations. + +Tests the newly added delete commands and update functionality. +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +from typer.testing import CliRunner +import typer + +from praisonai.cli.commands.managed import ( + sessions_app, + agents_app, + envs_app, + _get_client, +) + + +@pytest.fixture +def cli_runner(): + """Create a CLI runner for testing.""" + return CliRunner() + + +@pytest.fixture +def mock_anthropic_client(): + """Mock Anthropic client.""" + with patch('praisonai.cli.commands.managed._get_client') as mock_get_client: + mock_client = Mock() + mock_get_client.return_value = mock_client + yield mock_client + + +class TestSessionsDelete: + """Test sessions delete command.""" + + def test_sessions_delete_success_with_yes_flag(self, cli_runner, mock_anthropic_client): + """Test successful session deletion with --yes flag.""" + mock_anthropic_client.beta.sessions.delete.return_value = None + + result = cli_runner.invoke(sessions_app, [ + "delete", "sesn_01test123", "--yes" + ]) + + assert result.exit_code == 0 + assert "Session sesn_01test123 deleted successfully" in result.stdout + mock_anthropic_client.beta.sessions.delete.assert_called_once_with("sesn_01test123") + + def test_sessions_delete_user_confirms(self, cli_runner, mock_anthropic_client): + """Test session deletion when user confirms.""" + mock_anthropic_client.beta.sessions.delete.return_value = None + + result = cli_runner.invoke(sessions_app, [ + "delete", "sesn_01test123" + ], input="y\n") + + assert result.exit_code == 0 + assert "Session sesn_01test123 deleted successfully" in result.stdout + mock_anthropic_client.beta.sessions.delete.assert_called_once_with("sesn_01test123") + + def test_sessions_delete_user_cancels(self, cli_runner, mock_anthropic_client): + """Test session deletion when user cancels.""" + result = cli_runner.invoke(sessions_app, [ + "delete", "sesn_01test123" + ], input="n\n") + + assert result.exit_code == 0 + assert "Deletion cancelled" in result.stdout + mock_anthropic_client.beta.sessions.delete.assert_not_called() + + def test_sessions_delete_api_error(self, cli_runner, mock_anthropic_client): + """Test session deletion with API error.""" + mock_anthropic_client.beta.sessions.delete.side_effect = Exception("API Error") + + result = cli_runner.invoke(sessions_app, [ + "delete", "sesn_01test123", "--yes" + ]) + + assert result.exit_code == 1 + assert "Error deleting session: API Error" in result.stdout + + +class TestAgentsDelete: + """Test agents delete command.""" + + def test_agents_delete_success_with_yes_flag(self, cli_runner, mock_anthropic_client): + """Test successful agent deletion with --yes flag.""" + mock_anthropic_client.beta.agents.delete.return_value = None + + result = cli_runner.invoke(agents_app, [ + "delete", "agent_01test123", "--yes" + ]) + + assert result.exit_code == 0 + assert "Agent agent_01test123 deleted successfully" in result.stdout + mock_anthropic_client.beta.agents.delete.assert_called_once_with("agent_01test123") + + def test_agents_delete_user_confirms(self, cli_runner, mock_anthropic_client): + """Test agent deletion when user confirms.""" + mock_anthropic_client.beta.agents.delete.return_value = None + + result = cli_runner.invoke(agents_app, [ + "delete", "agent_01test123" + ], input="y\n") + + assert result.exit_code == 0 + assert "Agent agent_01test123 deleted successfully" in result.stdout + mock_anthropic_client.beta.agents.delete.assert_called_once_with("agent_01test123") + + def test_agents_delete_user_cancels(self, cli_runner, mock_anthropic_client): + """Test agent deletion when user cancels.""" + result = cli_runner.invoke(agents_app, [ + "delete", "agent_01test123" + ], input="n\n") + + assert result.exit_code == 0 + assert "Deletion cancelled" in result.stdout + mock_anthropic_client.beta.agents.delete.assert_not_called() + + def test_agents_delete_api_error(self, cli_runner, mock_anthropic_client): + """Test agent deletion with API error.""" + mock_anthropic_client.beta.agents.delete.side_effect = Exception("API Error") + + result = cli_runner.invoke(agents_app, [ + "delete", "agent_01test123", "--yes" + ]) + + assert result.exit_code == 1 + assert "Error deleting agent: API Error" in result.stdout + + +class TestEnvsUpdate: + """Test envs update command.""" + + def test_envs_update_packages_success(self, cli_runner, mock_anthropic_client): + """Test successful environment update with packages.""" + mock_env = Mock() + mock_env.id = "env_01test123" + mock_anthropic_client.beta.environments.update.return_value = mock_env + + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", "--packages", "numpy,pandas" + ]) + + assert result.exit_code == 0 + assert "Updated environment: env_01test123" in result.stdout + mock_anthropic_client.beta.environments.update.assert_called_once_with( + "env_01test123", + packages={"pip": ["numpy", "pandas"]} + ) + + def test_envs_update_networking_success(self, cli_runner, mock_anthropic_client): + """Test successful environment update with networking.""" + mock_env = Mock() + mock_env.id = "env_01test123" + mock_anthropic_client.beta.environments.update.return_value = mock_env + + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", "--networking", "limited" + ]) + + assert result.exit_code == 0 + assert "Updated environment: env_01test123" in result.stdout + mock_anthropic_client.beta.environments.update.assert_called_once_with( + "env_01test123", + networking={"type": "limited"} + ) + + def test_envs_update_both_options(self, cli_runner, mock_anthropic_client): + """Test environment update with both packages and networking.""" + mock_env = Mock() + mock_env.id = "env_01test123" + mock_anthropic_client.beta.environments.update.return_value = mock_env + + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", + "--packages", "requests,beautifulsoup4", + "--networking", "full" + ]) + + assert result.exit_code == 0 + assert "Updated environment: env_01test123" in result.stdout + mock_anthropic_client.beta.environments.update.assert_called_once_with( + "env_01test123", + packages={"pip": ["requests", "beautifulsoup4"]}, + networking={"type": "full"} + ) + + def test_envs_update_invalid_networking(self, cli_runner, mock_anthropic_client): + """Test environment update with invalid networking option.""" + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", "--networking", "invalid" + ]) + + assert result.exit_code == 1 + assert "--networking must be 'full' or 'limited'" in result.stdout + mock_anthropic_client.beta.environments.update.assert_not_called() + + def test_envs_update_no_options(self, cli_runner, mock_anthropic_client): + """Test environment update with no options.""" + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123" + ]) + + assert result.exit_code == 0 + assert "Nothing to update. Pass --packages or --networking" in result.stdout + mock_anthropic_client.beta.environments.update.assert_not_called() + + def test_envs_update_api_error(self, cli_runner, mock_anthropic_client): + """Test environment update with API error.""" + mock_anthropic_client.beta.environments.update.side_effect = Exception("API Error") + + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", "--packages", "numpy" + ]) + + assert result.exit_code == 1 + assert "Error updating environment: API Error" in result.stdout + + +class TestEnvsDelete: + """Test envs delete command.""" + + def test_envs_delete_success_with_yes_flag(self, cli_runner, mock_anthropic_client): + """Test successful environment deletion with --yes flag.""" + mock_anthropic_client.beta.environments.delete.return_value = None + + result = cli_runner.invoke(envs_app, [ + "delete", "env_01test123", "--yes" + ]) + + assert result.exit_code == 0 + assert "Environment env_01test123 deleted successfully" in result.stdout + mock_anthropic_client.beta.environments.delete.assert_called_once_with("env_01test123") + + def test_envs_delete_user_confirms(self, cli_runner, mock_anthropic_client): + """Test environment deletion when user confirms.""" + mock_anthropic_client.beta.environments.delete.return_value = None + + result = cli_runner.invoke(envs_app, [ + "delete", "env_01test123" + ], input="y\n") + + assert result.exit_code == 0 + assert "Environment env_01test123 deleted successfully" in result.stdout + mock_anthropic_client.beta.environments.delete.assert_called_once_with("env_01test123") + + def test_envs_delete_user_cancels(self, cli_runner, mock_anthropic_client): + """Test environment deletion when user cancels.""" + result = cli_runner.invoke(envs_app, [ + "delete", "env_01test123" + ], input="n\n") + + assert result.exit_code == 0 + assert "Deletion cancelled" in result.stdout + mock_anthropic_client.beta.environments.delete.assert_not_called() + + def test_envs_delete_api_error(self, cli_runner, mock_anthropic_client): + """Test environment deletion with API error.""" + mock_anthropic_client.beta.environments.delete.side_effect = Exception("API Error") + + result = cli_runner.invoke(envs_app, [ + "delete", "env_01test123", "--yes" + ]) + + assert result.exit_code == 1 + assert "Error deleting environment: API Error" in result.stdout + + +class TestGetClient: + """Test the _get_client helper function.""" + + @patch.dict('os.environ', {'ANTHROPIC_API_KEY': 'test-key'}) + @patch('praisonai.cli.commands.managed.anthropic') + def test_get_client_with_anthropic_api_key(self, mock_anthropic): + """Test client creation with ANTHROPIC_API_KEY.""" + mock_client = Mock() + mock_anthropic.Anthropic.return_value = mock_client + + # Reset the patched _get_client to use the real function + from praisonai.cli.commands.managed import _get_client + + client = _get_client() + + mock_anthropic.Anthropic.assert_called_once_with(api_key='test-key') + assert client == mock_client + + @patch.dict('os.environ', {'CLAUDE_API_KEY': 'test-key-2'}) + @patch('praisonai.cli.commands.managed.anthropic') + def test_get_client_with_claude_api_key(self, mock_anthropic): + """Test client creation with CLAUDE_API_KEY.""" + mock_client = Mock() + mock_anthropic.Anthropic.return_value = mock_client + + from praisonai.cli.commands.managed import _get_client + + client = _get_client() + + mock_anthropic.Anthropic.assert_called_once_with(api_key='test-key-2') + assert client == mock_client + + @patch.dict('os.environ', {}, clear=True) + @patch('praisonai.cli.commands.managed.anthropic') + def test_get_client_no_api_key(self, mock_anthropic): + """Test client creation with no API key.""" + from praisonai.cli.commands.managed import _get_client + + with pytest.raises(typer.Exit) as exc_info: + _get_client() + + assert exc_info.value.exit_code == 1 + + @patch('praisonai.cli.commands.managed.anthropic', None) + def test_get_client_no_anthropic_package(self): + """Test client creation when anthropic package is not installed.""" + from praisonai.cli.commands.managed import _get_client + + with pytest.raises(typer.Exit) as exc_info: + _get_client() + + assert exc_info.value.exit_code == 1 \ No newline at end of file From d36aae517ed6d4e97afa43d6a5be5662ea3214d8 Mon Sep 17 00:00:00 2001 From: Mervin Praison Date: Fri, 17 Apr 2026 06:53:20 +0100 Subject: [PATCH 2/3] test(cli): patch anthropic via sys.modules since _get_client imports lazily --- .../unit/cli/test_managed_cli_destructive.py | 67 ++++++++++--------- 1 file changed, 37 insertions(+), 30 deletions(-) diff --git a/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py b/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py index da3f315b5..b546c4c15 100644 --- a/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py +++ b/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py @@ -272,51 +272,58 @@ class TestGetClient: """Test the _get_client helper function.""" @patch.dict('os.environ', {'ANTHROPIC_API_KEY': 'test-key'}) - @patch('praisonai.cli.commands.managed.anthropic') - def test_get_client_with_anthropic_api_key(self, mock_anthropic): + def test_get_client_with_anthropic_api_key(self): """Test client creation with ANTHROPIC_API_KEY.""" + import sys + mock_anthropic = MagicMock() mock_client = Mock() mock_anthropic.Anthropic.return_value = mock_client - - # Reset the patched _get_client to use the real function - from praisonai.cli.commands.managed import _get_client - - client = _get_client() - + with patch.dict(sys.modules, {'anthropic': mock_anthropic}): + from praisonai.cli.commands.managed import _get_client + client = _get_client() mock_anthropic.Anthropic.assert_called_once_with(api_key='test-key') assert client == mock_client - @patch.dict('os.environ', {'CLAUDE_API_KEY': 'test-key-2'}) - @patch('praisonai.cli.commands.managed.anthropic') - def test_get_client_with_claude_api_key(self, mock_anthropic): + @patch.dict('os.environ', {'CLAUDE_API_KEY': 'test-key-2'}, clear=True) + def test_get_client_with_claude_api_key(self): """Test client creation with CLAUDE_API_KEY.""" + import sys + mock_anthropic = MagicMock() mock_client = Mock() mock_anthropic.Anthropic.return_value = mock_client - - from praisonai.cli.commands.managed import _get_client - - client = _get_client() - + with patch.dict(sys.modules, {'anthropic': mock_anthropic}): + from praisonai.cli.commands.managed import _get_client + client = _get_client() mock_anthropic.Anthropic.assert_called_once_with(api_key='test-key-2') assert client == mock_client @patch.dict('os.environ', {}, clear=True) - @patch('praisonai.cli.commands.managed.anthropic') - def test_get_client_no_api_key(self, mock_anthropic): + def test_get_client_no_api_key(self): """Test client creation with no API key.""" - from praisonai.cli.commands.managed import _get_client - - with pytest.raises(typer.Exit) as exc_info: - _get_client() - + import sys + mock_anthropic = MagicMock() + with patch.dict(sys.modules, {'anthropic': mock_anthropic}): + from praisonai.cli.commands.managed import _get_client + with pytest.raises(typer.Exit) as exc_info: + _get_client() assert exc_info.value.exit_code == 1 - @patch('praisonai.cli.commands.managed.anthropic', None) def test_get_client_no_anthropic_package(self): """Test client creation when anthropic package is not installed.""" - from praisonai.cli.commands.managed import _get_client - - with pytest.raises(typer.Exit) as exc_info: - _get_client() - - assert exc_info.value.exit_code == 1 \ No newline at end of file + import sys + import builtins + real_import = builtins.__import__ + def fake_import(name, *args, **kwargs): + if name == 'anthropic': + raise ImportError("No module named 'anthropic'") + return real_import(name, *args, **kwargs) + saved = sys.modules.pop('anthropic', None) + try: + with patch('builtins.__import__', side_effect=fake_import): + from praisonai.cli.commands.managed import _get_client + with pytest.raises(typer.Exit) as exc_info: + _get_client() + assert exc_info.value.exit_code == 1 + finally: + if saved is not None: + sys.modules['anthropic'] = saved \ No newline at end of file From aebff2661900ba9b383f2231fc667c7910ba1755 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:58:01 +0000 Subject: [PATCH 3/3] fix(cli): validate and sanitize env package list on managed env update Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/1b7812ec-b08b-493c-a403-53a33364cc7a Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> --- .../praisonai/cli/commands/managed.py | 5 +++- .../unit/cli/test_managed_cli_destructive.py | 28 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/praisonai/praisonai/cli/commands/managed.py b/src/praisonai/praisonai/cli/commands/managed.py index ea6a2b3bb..ca5423f2a 100644 --- a/src/praisonai/praisonai/cli/commands/managed.py +++ b/src/praisonai/praisonai/cli/commands/managed.py @@ -509,7 +509,10 @@ def envs_update( kwargs = {} if packages: - pkg_list = [p.strip() for p in packages.split(",")] + pkg_list = [p.strip() for p in packages.split(",") if p.strip()] + if not pkg_list: + typer.echo("Error: --packages must include at least one package name") + raise typer.Exit(1) kwargs["packages"] = {"pip": pkg_list} if networking: diff --git a/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py b/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py index b546c4c15..25c9e9302 100644 --- a/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py +++ b/src/praisonai/tests/unit/cli/test_managed_cli_destructive.py @@ -167,6 +167,32 @@ def test_envs_update_networking_success(self, cli_runner, mock_anthropic_client) networking={"type": "limited"} ) + def test_envs_update_packages_filters_empty_values(self, cli_runner, mock_anthropic_client): + """Test package parsing removes empty entries.""" + mock_env = Mock() + mock_env.id = "env_01test123" + mock_anthropic_client.beta.environments.update.return_value = mock_env + + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", "--packages", "numpy, ,pandas,," + ]) + + assert result.exit_code == 0 + mock_anthropic_client.beta.environments.update.assert_called_once_with( + "env_01test123", + packages={"pip": ["numpy", "pandas"]} + ) + + def test_envs_update_packages_all_empty_values_error(self, cli_runner, mock_anthropic_client): + """Test package parsing rejects all-empty package input.""" + result = cli_runner.invoke(envs_app, [ + "update", "env_01test123", "--packages", " , , " + ]) + + assert result.exit_code == 1 + assert "--packages must include at least one package name" in result.stdout + mock_anthropic_client.beta.environments.update.assert_not_called() + def test_envs_update_both_options(self, cli_runner, mock_anthropic_client): """Test environment update with both packages and networking.""" mock_env = Mock() @@ -326,4 +352,4 @@ def fake_import(name, *args, **kwargs): assert exc_info.value.exit_code == 1 finally: if saved is not None: - sys.modules['anthropic'] = saved \ No newline at end of file + sys.modules['anthropic'] = saved