diff --git a/.changes/next-release/enhancement-configure-67593.json b/.changes/next-release/enhancement-configure-67593.json new file mode 100644 index 000000000000..80bc58ca30f8 --- /dev/null +++ b/.changes/next-release/enhancement-configure-67593.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "configure", + "description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected. In the commercial partition an interactive prompt is shown after ``aws configure``, ``aws configure sso``, and a first-time ``aws login`` that creates a new profile; in other partitions a non-interactive tip is shown instead. The prompt only appears on a terminal when no AWS skills are installed yet, and can be permanently suppressed by answering ``never`` or by setting the ``AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED`` environment variable to ``true``. ``aws configure agent-toolkit`` now defaults to the ``us-east-1`` control-plane region unless ``--region`` is given, and a tip is also printed by the install scripts and after ``aws update``." +} diff --git a/awscli/customizations/agenttoolkit/agents.py b/awscli/customizations/agenttoolkit/agents.py index 7b2d13fc71f4..2a19f9070911 100644 --- a/awscli/customizations/agenttoolkit/agents.py +++ b/awscli/customizations/agenttoolkit/agents.py @@ -419,3 +419,11 @@ def get_detected_agents(agent_configs=None): if agent is not None: detected.append(agent) return detected + + +def get_detected_real_agents(agent_configs=None): + return [ + agent + for agent in get_detected_agents(agent_configs) + if agent.config.id != UNIVERSAL_ROW_ID + ] diff --git a/awscli/customizations/agenttoolkit/hint.py b/awscli/customizations/agenttoolkit/hint.py new file mode 100644 index 000000000000..b6bc57dd167c --- /dev/null +++ b/awscli/customizations/agenttoolkit/hint.py @@ -0,0 +1,169 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +"""End-of-``aws configure`` hint suggesting the Agent Toolkit wizard. + +After a successful ``aws configure`` that writes profile values, offer to run +``aws configure agent-toolkit`` when a supported AI coding agent is present +and no AWS skills are installed yet. The wizard defaults to the Agent Toolkit +region (us-east-1), so in the commercial partition the offer is +an interactive prompt that can run the wizard directly. In other partitions +that region is unreachable, so we fall back to a non-interactive tip instead +of routing the user into a cross-partition call. Either way the hint only +shows on a TTY and can be suppressed. +""" + +import json +import logging +import os +import re + +from botocore.loaders import Loader +from botocore.utils import ensure_boolean + +from awscli.customizations.agenttoolkit.agents import ( + get_detected_real_agents, +) +from awscli.customizations.agenttoolkit.configure import ( + ConfigureAgentToolkitCommand, +) +from awscli.customizations.prompts import yes_no_never_choice +from awscli.customizations.utils import uni_print +from awscli.utils import is_stdin_a_tty + +LOG = logging.getLogger(__name__) + +STATE_PATH = '~/.aws/cli/agent-toolkit/state.json' + +HINT_DISABLED_ENV_VAR = 'AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED' + +# The wizard runs against the Agent Toolkit region, which only +# exists in the commercial partition. Elsewhere we cannot run it inline, so we +# only offer the interactive prompt to callers in this partition. +COMMERCIAL_PARTITION = 'aws' + +PROMPT_TEXT = ( + '\nConfigure AWS skills and the AWS MCP server for your AI coding ' + 'agent(s)? [y/n/never]: ' +) + +HINT_TEXT = ( + "\nTip: run 'aws configure agent-toolkit' to set up AWS skills and the " + 'AWS MCP server for your AI coding agent(s).\n' +) + + +def _state_file(): + return os.path.expanduser(STATE_PATH) + + +def _load_state(): + try: + with open(_state_file()) as f: + return json.load(f) + except FileNotFoundError: + return {} + except (OSError, json.JSONDecodeError) as e: + LOG.debug('Could not read agent toolkit hint state: %s', e) + return {} + + +def _save_state(state): + path = _state_file() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp_path = f'{path}.tmp' + with open(tmp_path, 'w') as f: + json.dump(state, f) + f.write('\n') + os.replace(tmp_path, path) + except OSError as e: + LOG.debug('Could not write agent toolkit hint state: %s', e) + + +def _dismiss_forever(): + state = _load_state() + state['hint_dismissed'] = True + _save_state(state) + + +def _has_installed_skills(detected_agents): + return any(agent.get_installed_skills() for agent in detected_agents) + + +def hint_disabled(): + return ensure_boolean(os.environ.get(HINT_DISABLED_ENV_VAR, '')) + + +def _is_eligible(): + if not is_stdin_a_tty(): + return False + if hint_disabled(): + return False + if _load_state().get('hint_dismissed'): + return False + detected_agents = get_detected_real_agents() + if not detected_agents: + return False + if _has_installed_skills(detected_agents): + return False + return True + + +def _resolve_region(session, parsed_globals): + region = getattr(parsed_globals, 'region', None) + if region: + return region + try: + return session.get_config_variable('region') + except Exception: + return None + + +def _region_partition(region): + for partition in Loader().load_data('partitions')['partitions']: + if region in partition.get('regions', {}): + return partition['id'] + regex = partition.get('regionRegex') + if regex and re.match(regex, region): + return partition['id'] + return None + + +def _can_run_wizard(session, parsed_globals): + region = _resolve_region(session, parsed_globals) + if not region: + return True + return _region_partition(region) == COMMERCIAL_PARTITION + + +def maybe_prompt_agent_toolkit(session, parsed_globals): + try: + if not _is_eligible(): + return + # Outside the commercial partition the wizard's region is unreachable, + # so print a tip instead of prompting and routing the user into a + # cross-partition call that would fail. + if not _can_run_wizard(session, parsed_globals): + uni_print(HINT_TEXT) + return + choice = yes_no_never_choice(PROMPT_TEXT) + if choice == 'never': + _dismiss_forever() + run_wizard = choice == 'yes' + except Exception as e: + LOG.debug('Agent toolkit hint failed: %s', e, exc_info=True) + return + + if run_wizard: + command = ConfigureAgentToolkitCommand(session) + command([], parsed_globals) diff --git a/awscli/customizations/agenttoolkit/utils.py b/awscli/customizations/agenttoolkit/utils.py index 8113967f7def..2765371582e5 100644 --- a/awscli/customizations/agenttoolkit/utils.py +++ b/awscli/customizations/agenttoolkit/utils.py @@ -65,11 +65,22 @@ } +# The Agent Toolkit API is served from a single region today, +# Default there unless the caller passes an explicit ``--region``, +# otherwise a user whose configured region is elsewhere would hit +# an endpoint that does not exist. +AGENT_TOOLKIT_REGION = 'us-east-1' + + def create_client(session, parsed_globals): + overrides = {} + if not getattr(parsed_globals, 'region', None): + overrides['region_name'] = AGENT_TOOLKIT_REGION return create_client_from_parsed_globals( session, 'agenttoolkit', parsed_globals, + overrides=overrides, ) diff --git a/awscli/customizations/configure/configure.py b/awscli/customizations/configure/configure.py index d378730eb85d..c1c9a8d7a4c7 100644 --- a/awscli/customizations/configure/configure.py +++ b/awscli/customizations/configure/configure.py @@ -20,6 +20,9 @@ from awscli.customizations.agenttoolkit.configure import ( ConfigureAgentToolkitCommand, ) +from awscli.customizations.agenttoolkit.hint import ( + maybe_prompt_agent_toolkit, +) from awscli.customizations.commands import BasicCommand from awscli.customizations.configure.addmodel import AddModelCommand from awscli.customizations.configure.exportcreds import ( @@ -193,6 +196,7 @@ def _run_main(self, parsed_args, parsed_globals): section = profile_to_section(profile) new_values['__section__'] = section self._config_writer.update_config(new_values, config_filename) + maybe_prompt_agent_toolkit(self._session, parsed_globals) return 0 def _write_out_creds_file_values(self, new_values, profile_name): diff --git a/awscli/customizations/configure/sso_commands.py b/awscli/customizations/configure/sso_commands.py index 3c7d0054127c..265d8608fc45 100644 --- a/awscli/customizations/configure/sso_commands.py +++ b/awscli/customizations/configure/sso_commands.py @@ -35,6 +35,9 @@ from botocore.exceptions import ProfileNotFound from botocore.useragent import register_feature_id +from awscli.customizations.agenttoolkit.hint import ( + maybe_prompt_agent_toolkit, +) from awscli.customizations.configure import ( get_section_header, profile_to_section, @@ -352,6 +355,7 @@ def _run_main(self, parsed_args, parsed_globals): self._write_new_config(profile_name) self._print_conclusion(configured_for_aws_credentials, profile_name) + maybe_prompt_agent_toolkit(self._session, parsed_globals) return 0 def _prompt_for_sso_registration_args(self, verify=None): diff --git a/awscli/customizations/login/login.py b/awscli/customizations/login/login.py index 7acf9ecec5d1..7f338769e0a2 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -14,6 +14,9 @@ ) from awscli.compat import compat_input +from awscli.customizations.agenttoolkit.hint import ( + maybe_prompt_agent_toolkit, +) from awscli.customizations.commands import BasicCommand from awscli.customizations.configure.writer import ConfigFileWriter from awscli.customizations.exceptions import ConfigurationError @@ -92,7 +95,8 @@ def _run_main(self, parsed_args, parsed_globals): # If the profile specified via --profile doesn't already exist # add it to the session so the client creation still succeeds. # If the login is successful we'll save the profile at the end. - if profile_name not in self._session.available_profiles: + is_new_profile = profile_name not in self._session.available_profiles + if is_new_profile: self._session._profile_map[profile_name] = {} # Abort if the profile is already configured with a different style @@ -153,6 +157,11 @@ def _run_main(self, parsed_args, parsed_globals): f'such as "aws sts get-caller-identity --profile {profile_name}"\n' ) + # Only nudge on first-time setup (a newly created profile), not on + # routine re-auth of an existing profile. + if is_new_profile: + maybe_prompt_agent_toolkit(self._session, parsed_globals) + def accept_change_to_existing_profile_if_needed( self, profile_name, new_session_id ): diff --git a/awscli/customizations/prompts.py b/awscli/customizations/prompts.py index 339da49df273..a1a9921d1b24 100644 --- a/awscli/customizations/prompts.py +++ b/awscli/customizations/prompts.py @@ -35,6 +35,27 @@ def yes_no_choice(prompt): uni_print('Invalid response. Please enter "y" or "n"\n') +def yes_no_never_choice(prompt): + """ + Prompts the user with a yes/no/never question. + Continually re-prompts for invalid selections. + + :param prompt: Prompt text. + :returns: 'yes', 'no', or 'never'. + """ + while True: + response = compat_input(prompt) + + if response.lower() in ('y', 'yes'): + return 'yes' + elif response.lower() in ('n', 'no'): + return 'no' + elif response.lower() == 'never': + return 'never' + else: + uni_print('Invalid response. Please enter "y", "n", or "never"\n') + + def multiselect_choice( message, items, diff --git a/awscli/customizations/update.py b/awscli/customizations/update.py index 14949c32245c..a36d1600f5f6 100644 --- a/awscli/customizations/update.py +++ b/awscli/customizations/update.py @@ -15,6 +15,7 @@ get_distribution_source, ) from awscli.compat import is_windows +from awscli.customizations.agenttoolkit.hint import HINT_TEXT, hint_disabled from awscli.customizations.commands import BasicCommand from awscli.customizations.utils import uni_print @@ -95,6 +96,8 @@ def _run_main(self, parsed_args, parsed_globals): uni_print(f"Updating AWS CLI (source: {source})\n") self._no_color = parsed_globals.color == 'off' self._do_update() + if not hint_disabled(): + uni_print(HINT_TEXT) return 0 def _do_update(self): diff --git a/exe/assets/install b/exe/assets/install index d1f185889b63..a22ac06ee799 100755 --- a/exe/assets/install +++ b/exe/assets/install @@ -163,6 +163,7 @@ main() { create_bin_symlinks write_install_json echo "You can now run: $BIN_AWS_EXE --version" + echo "Tip: run 'aws configure agent-toolkit' to set up AWS skills and the AWS MCP server for your AI coding agent(s)." exit 0 } diff --git a/macpkg/scripts/postinstall b/macpkg/scripts/postinstall index ed6d1ef8c3c5..5e8c1fe280d7 100755 --- a/macpkg/scripts/postinstall +++ b/macpkg/scripts/postinstall @@ -48,3 +48,5 @@ EOF EOF fi fi + +echo "Tip: run 'aws configure agent-toolkit' to set up AWS skills and the AWS MCP server for your AI coding agent(s)." diff --git a/tests/functional/login/test_login.py b/tests/functional/login/test_login.py index a89fa0a31670..b02ae77a6c7b 100644 --- a/tests/functional/login/test_login.py +++ b/tests/functional/login/test_login.py @@ -296,3 +296,61 @@ def test_abort_if_profile_has_existing_credentials( else: mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) mock_token_fetcher.assert_called_once() + + +@mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') +@mock.patch( + 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' +) +@mock.patch('awscli.customizations.login.login.maybe_prompt_agent_toolkit') +def test_prompts_agent_toolkit_for_new_profile( + mock_prompt, + mock_token_fetcher, + mock_base_sign_in_uri, + mock_login_command, + mock_session, +): + mock_base_sign_in_uri.return_value = 'https://foo' + mock_token_fetcher.return_value = ( + { + 'accessToken': 'access_token', + 'idToken': SAMPLE_ID_TOKEN, + 'expiresIn': 3600, + }, + 'arn:aws:iam::0123456789012:user/Admin', + ) + # Profile does not exist yet — this is a new-profile setup. + mock_session.available_profiles = [] + mock_session.full_config = {'profiles': {}} + + mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) + mock_prompt.assert_called_once() + + +@mock.patch('awscli.customizations.login.utils.get_base_sign_in_uri') +@mock.patch( + 'awscli.customizations.login.utils.SameDeviceLoginTokenFetcher.fetch_token' +) +@mock.patch('awscli.customizations.login.login.maybe_prompt_agent_toolkit') +def test_no_agent_toolkit_prompt_for_existing_profile( + mock_prompt, + mock_token_fetcher, + mock_base_sign_in_uri, + mock_login_command, + mock_session, +): + mock_base_sign_in_uri.return_value = 'https://foo' + mock_token_fetcher.return_value = ( + { + 'accessToken': 'access_token', + 'idToken': SAMPLE_ID_TOKEN, + 'expiresIn': 3600, + }, + 'arn:aws:iam::0123456789012:user/Admin', + ) + # Profile already exists — this is re-auth, not setup. + mock_session.available_profiles = ['profile-name'] + mock_session.full_config = {'profiles': {'profile-name': {}}} + + mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) + mock_prompt.assert_not_called() diff --git a/tests/unit/customizations/agenttoolkit/test_agents.py b/tests/unit/customizations/agenttoolkit/test_agents.py index 9d651db36331..ef0676619641 100644 --- a/tests/unit/customizations/agenttoolkit/test_agents.py +++ b/tests/unit/customizations/agenttoolkit/test_agents.py @@ -16,10 +16,12 @@ from awscli.customizations.agenttoolkit.agents import ( AGENT_CONFIGS, + UNIVERSAL_ROW_ID, AgentConfig, DetectedAgent, McpConfigureAction, get_detected_agents, + get_detected_real_agents, ) from awscli.testutils import skip_if_windows from tests.unit.customizations.agenttoolkit.utils import ( @@ -136,6 +138,27 @@ def test_get_detected_agents(tmp_path): assert detected[0].display_name == 'Kiro' +def test_get_detected_real_agents_excludes_universal_row(tmp_path): + # Only the universal row detects (``~/.agents`` exists) but no real + # per-agent directory does. The wizard would find nothing here, so the + # hint must not treat this as an eligible detection. + (tmp_path / '.agents').mkdir() + test_configs = [ + AgentConfig( + id='cursor', + display_name='Cursor', + detection_path=str(tmp_path / '.cursor'), + ), + AgentConfig( + id=UNIVERSAL_ROW_ID, + display_name='Universal', + detection_path=str(tmp_path / '.agents'), + ), + ] + assert get_detected_agents(agent_configs=test_configs) + assert get_detected_real_agents(agent_configs=test_configs) == [] + + def test_mcp_config_path_honors_detection_env_override(tmp_path, monkeypatch): (tmp_path / '.test-agent').mkdir() override_dir = tmp_path / '.custom-location' diff --git a/tests/unit/customizations/agenttoolkit/test_hint.py b/tests/unit/customizations/agenttoolkit/test_hint.py new file mode 100644 index 000000000000..9738c5fd5ec4 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -0,0 +1,200 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +import json +from unittest.mock import MagicMock, patch + +import pytest + +from awscli.customizations.agenttoolkit import hint + + +@pytest.fixture +def state_file(tmp_path): + path = tmp_path / 'agent-toolkit' / 'state.json' + with patch.object(hint, 'STATE_PATH', str(path)): + yield path + + +def _agent(installed_skills=None): + agent = MagicMock() + agent.get_installed_skills.return_value = installed_skills or [] + return agent + + +@pytest.fixture +def wizard_cls(): + with patch.object(hint, 'ConfigureAgentToolkitCommand') as cls: + yield cls + + +def _run(choice='yes', agents=None, tty=True, region='us-east-1'): + if agents is None: + agents = [_agent()] + parsed_globals = MagicMock() + parsed_globals.region = region + with ( + patch.object(hint, 'is_stdin_a_tty', return_value=tty), + patch.object(hint, 'get_detected_real_agents', return_value=agents), + patch.object(hint, 'yes_no_never_choice', return_value=choice), + ): + hint.maybe_prompt_agent_toolkit(MagicMock(), parsed_globals) + + +def test_launches_wizard_on_yes(state_file, wizard_cls): + _run(choice='yes') + assert wizard_cls.called + wizard_cls.return_value.assert_called_once() + + +def test_no_launch_on_no(state_file, wizard_cls): + _run(choice='no') + assert not wizard_cls.called + assert not state_file.exists() + + +def test_never_persists_dismissal(state_file, wizard_cls): + _run(choice='never') + assert not wizard_cls.called + assert json.loads(state_file.read_text())['hint_dismissed'] is True + # The atomic write must not leave its temp file behind. + assert not (state_file.parent / f'{state_file.name}.tmp').exists() + + +def test_skipped_when_not_a_tty(state_file, wizard_cls): + _run(choice='yes', tty=False) + assert not wizard_cls.called + + +def test_skipped_when_env_var_true(state_file, wizard_cls, monkeypatch): + monkeypatch.setenv(hint.HINT_DISABLED_ENV_VAR, 'true') + _run(choice='yes') + assert not wizard_cls.called + + +def test_not_skipped_when_env_var_false(state_file, wizard_cls, monkeypatch): + monkeypatch.setenv(hint.HINT_DISABLED_ENV_VAR, 'false') + _run(choice='yes') + assert wizard_cls.called + + +def test_env_var_also_suppresses_the_tip( + state_file, wizard_cls, capsys, monkeypatch +): + monkeypatch.setenv(hint.HINT_DISABLED_ENV_VAR, 'true') + _run(choice='yes', region='cn-north-1') + assert not wizard_cls.called + assert capsys.readouterr().out == '' + + +def test_skipped_when_already_dismissed(state_file, wizard_cls): + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text(json.dumps({'hint_dismissed': True})) + _run(choice='yes') + assert not wizard_cls.called + + +def test_skipped_when_no_agents(state_file, wizard_cls): + _run(choice='yes', agents=[]) + assert not wizard_cls.called + + +def test_skipped_when_skills_already_installed(state_file, wizard_cls): + _run(choice='yes', agents=[_agent(installed_skills=['s'])]) + assert not wizard_cls.called + + +def test_prompts_when_region_in_commercial_partition(state_file, wizard_cls): + # A non-us-east-1 commercial region still prompts: the wizard defaults to + # the control-plane region on its own, so "yes" works. + _run(choice='yes', region='us-west-2') + assert wizard_cls.called + + +@pytest.mark.parametrize('region', ['us-gov-west-1', 'cn-north-1']) +def test_non_commercial_partition_prints_tip_and_does_not_prompt( + state_file, wizard_cls, capsys, region +): + with ( + patch.object(hint, 'is_stdin_a_tty', return_value=True), + patch.object( + hint, 'get_detected_real_agents', return_value=[_agent()] + ), + patch.object(hint, 'yes_no_never_choice') as prompt, + ): + parsed_globals = MagicMock() + parsed_globals.region = region + hint.maybe_prompt_agent_toolkit(MagicMock(), parsed_globals) + + assert not prompt.called + assert not wizard_cls.called + assert 'aws configure agent-toolkit' in capsys.readouterr().out + + +def test_prompts_when_no_region_configured(state_file, wizard_cls): + # No region anywhere: the wizard defaults to the control-plane region, so + # prompting is safe. + session = MagicMock() + session.get_config_variable.return_value = None + parsed_globals = MagicMock() + parsed_globals.region = None + with ( + patch.object(hint, 'is_stdin_a_tty', return_value=True), + patch.object( + hint, 'get_detected_real_agents', return_value=[_agent()] + ), + patch.object(hint, 'yes_no_never_choice', return_value='yes'), + ): + hint.maybe_prompt_agent_toolkit(session, parsed_globals) + assert wizard_cls.called + + +def test_region_falls_back_to_session_config(state_file, wizard_cls): + session = MagicMock() + session.get_config_variable.return_value = 'cn-north-1' + parsed_globals = MagicMock() + parsed_globals.region = None + with ( + patch.object(hint, 'is_stdin_a_tty', return_value=True), + patch.object( + hint, 'get_detected_real_agents', return_value=[_agent()] + ), + patch.object(hint, 'yes_no_never_choice') as prompt, + ): + hint.maybe_prompt_agent_toolkit(session, parsed_globals) + # Configured region is in the China partition, so we tip, not prompt. + assert not prompt.called + assert not wizard_cls.called + + +def test_corrupt_state_file_is_ignored(state_file, wizard_cls): + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text('{ not valid json') + _run(choice='yes') + assert wizard_cls.called + + +def test_detection_failure_does_not_raise(state_file, wizard_cls): + with ( + patch.object(hint, 'is_stdin_a_tty', return_value=True), + patch.object( + hint, 'get_detected_real_agents', side_effect=OSError('boom') + ), + ): + hint.maybe_prompt_agent_toolkit(MagicMock(), MagicMock()) + assert not wizard_cls.called + + +def test_wizard_errors_are_not_swallowed(state_file, wizard_cls): + wizard_cls.return_value.side_effect = RuntimeError('wizard boom') + with pytest.raises(RuntimeError, match='wizard boom'): + _run(choice='yes') diff --git a/tests/unit/customizations/agenttoolkit/test_utils.py b/tests/unit/customizations/agenttoolkit/test_utils.py new file mode 100644 index 000000000000..d4cd5f448bb6 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_utils.py @@ -0,0 +1,36 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +from argparse import Namespace +from unittest.mock import MagicMock + +from awscli.customizations.agenttoolkit.utils import ( + AGENT_TOOLKIT_REGION, + create_client, +) + + +def _create_client(region): + session = MagicMock() + parsed_globals = Namespace(region=region) + create_client(session, parsed_globals) + return session.create_client.call_args + + +def test_defaults_region_when_none_configured(): + args = _create_client(region=None) + assert args.kwargs['region_name'] == AGENT_TOOLKIT_REGION + + +def test_honors_explicit_region(): + args = _create_client(region='us-west-2') + assert args.kwargs.get('region_name') == 'us-west-2' diff --git a/tests/unit/customizations/configure/test_sso.py b/tests/unit/customizations/configure/test_sso.py index e7d5c7065da1..61fe200598a2 100644 --- a/tests/unit/customizations/configure/test_sso.py +++ b/tests/unit/customizations/configure/test_sso.py @@ -1035,6 +1035,30 @@ def test_single_account_single_role_flow( ], ) + def test_prompts_agent_toolkit_after_configuring_profile( + self, + sso_cmd, + ptk_stubber, + aws_config, + stub_simple_single_item_sso_responses, + args, + parsed_globals, + configure_sso_legacy_inputs, + account_id, + role_name, + ): + inputs = configure_sso_legacy_inputs + inputs.skip_account_and_role_selection() + ptk_stubber.user_inputs = inputs + stub_simple_single_item_sso_responses(account_id, role_name) + + with mock.patch( + 'awscli.customizations.configure.sso_commands.' + 'maybe_prompt_agent_toolkit' + ) as prompt: + sso_cmd(args, parsed_globals) + prompt.assert_called_once() + def test_no_accounts_flow_raises_error( self, sso_cmd, diff --git a/tests/unit/customizations/test_update.py b/tests/unit/customizations/test_update.py index 7e7246085afb..5cdbb103da7e 100644 --- a/tests/unit/customizations/test_update.py +++ b/tests/unit/customizations/test_update.py @@ -69,6 +69,26 @@ def test_supported_distribution_source_runs_installer(self, source): assert command([], global_args()) == 0 runner.assert_called_once() + def test_prints_agent_toolkit_tip_after_successful_update(self, capsys): + command = self._command(USER_INSTALL) + command([], global_args()) + assert 'aws configure agent-toolkit' in capsys.readouterr().out + + def test_no_agent_toolkit_tip_when_hint_disabled( + self, capsys, monkeypatch + ): + monkeypatch.setenv('AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED', 'true') + command = self._command(USER_INSTALL) + command([], global_args()) + assert 'aws configure agent-toolkit' not in capsys.readouterr().out + + def test_no_agent_toolkit_tip_when_update_fails(self, capsys): + runner = mock.Mock(side_effect=subprocess.CalledProcessError(1, 'x')) + command = self._command(USER_INSTALL, runner=runner) + with pytest.raises(UpdateError): + command([], global_args()) + assert 'aws configure agent-toolkit' not in capsys.readouterr().out + @pytest.mark.parametrize('source', ['source', 'other', 'pip', '']) def test_unsupported_distribution_source_raises(self, source): runner = mock.Mock() @@ -249,6 +269,11 @@ def test_spawns_detached_cmd_wrapper(self): assert cmd[2].endswith('.cmd') assert len(cmd) == 3 + def test_prints_agent_toolkit_tip(self, capsys): + command = self._command(USER_INSTALL) + command([], global_args()) + assert 'aws configure agent-toolkit' in capsys.readouterr().out + def test_downloads_install_script_referenced_by_wrapper(self): downloader = mock.Mock() _, wrapper = self._run(USER_INSTALL, downloader=downloader)