From 221ca2c52b8278b73f4e00fafa6bd1fedb418f45 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 22 Jul 2026 14:19:13 -0400 Subject: [PATCH 1/7] Suggest Agent Toolkit setup after configure/update and in install scripts --- .../enhancement-agenttoolkit-hint.json | 5 + awscli/customizations/agenttoolkit/agents.py | 8 ++ awscli/customizations/agenttoolkit/hint.py | 109 +++++++++++++++++ awscli/customizations/configure/configure.py | 4 + awscli/customizations/prompts.py | 21 ++++ awscli/customizations/update.py | 14 +++ exe/assets/install | 1 + macpkg/scripts/postinstall | 2 + .../agenttoolkit/test_agents.py | 23 ++++ .../customizations/agenttoolkit/test_hint.py | 110 ++++++++++++++++++ tests/unit/customizations/test_update.py | 28 +++++ 11 files changed, 325 insertions(+) create mode 100644 .changes/next-release/enhancement-agenttoolkit-hint.json create mode 100644 awscli/customizations/agenttoolkit/hint.py create mode 100644 tests/unit/customizations/agenttoolkit/test_hint.py diff --git a/.changes/next-release/enhancement-agenttoolkit-hint.json b/.changes/next-release/enhancement-agenttoolkit-hint.json new file mode 100644 index 000000000000..86934da853ee --- /dev/null +++ b/.changes/next-release/enhancement-agenttoolkit-hint.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: interactively after ``aws configure`` or ``aws update`` (Unix), and as a tip printed by the install scripts. The interactive prompt appears only when no AWS skills are installed and can be permanently suppressed by answering ``never``." +} 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..bdf5c9ae481f --- /dev/null +++ b/awscli/customizations/agenttoolkit/hint.py @@ -0,0 +1,109 @@ +# 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 +the toolkit is not already installed. The hint is interactive-only and can be +permanently suppressed. +""" + +import json +import logging +import os + +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' + +PROMPT_TEXT = ( + 'Configure AWS skills and the AWS MCP server for your AI coding ' + 'agent(s)? [Y/n/never]: ' +) + + +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 _is_eligible(detected_agents): + if not is_stdin_a_tty(): + return False + if _load_state().get('hint_dismissed'): + return False + if not detected_agents: + return False + if _has_installed_skills(detected_agents): + return False + return True + + +def maybe_prompt_agent_toolkit(session, parsed_globals, stream=None): + try: + detected_agents = get_detected_real_agents() + if not _is_eligible(detected_agents): + return + choice = yes_no_never_choice(PROMPT_TEXT) + if choice == 'never': + _dismiss_forever() + return + if choice == 'no': + return + command = ConfigureAgentToolkitCommand(session) + command([], parsed_globals) + except Exception as e: + LOG.debug('Agent toolkit hint failed: %s', e, exc_info=True) 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/prompts.py b/awscli/customizations/prompts.py index 339da49df273..32972c784a9f 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') or response == '': + 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..9e8c60a631c2 100644 --- a/awscli/customizations/update.py +++ b/awscli/customizations/update.py @@ -15,6 +15,9 @@ get_distribution_source, ) from awscli.compat import is_windows +from awscli.customizations.agenttoolkit.hint import ( + maybe_prompt_agent_toolkit, +) from awscli.customizations.commands import BasicCommand from awscli.customizations.utils import uni_print @@ -95,8 +98,12 @@ 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() + self._maybe_prompt_agent_toolkit(parsed_globals) return 0 + def _maybe_prompt_agent_toolkit(self, parsed_globals): + maybe_prompt_agent_toolkit(self._session, parsed_globals) + def _do_update(self): raise NotImplementedError @@ -220,6 +227,13 @@ def _do_update(self): 'update will complete shortly.\n' ) + def _maybe_prompt_agent_toolkit(self, parsed_globals): + # No-op on Windows: the update runs in a detached subprocess and the + # actual CLI update happens after this process exits, so there is no + # safe point to prompt (blocking here would hold the exe lock the + # detached-process workaround exists to avoid). + pass + def _run_install(self, cmd): subprocess.Popen( cmd, diff --git a/exe/assets/install b/exe/assets/install index d1f185889b63..30b90cff5d54 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." exit 0 } diff --git a/macpkg/scripts/postinstall b/macpkg/scripts/postinstall index ed6d1ef8c3c5..7dd631019cb1 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." 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..52b1f312829b --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -0,0 +1,110 @@ +# 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): + if agents is None: + agents = [_agent()] + 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(), MagicMock()) + + +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_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_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') + # Corrupt state must not suppress or crash; hint stays eligible. + _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') + ), + ): + # Must swallow errors so `aws configure` never fails because of it. + hint.maybe_prompt_agent_toolkit(MagicMock(), MagicMock()) + assert not wizard_cls.called diff --git a/tests/unit/customizations/test_update.py b/tests/unit/customizations/test_update.py index 7e7246085afb..dc5228df0cd2 100644 --- a/tests/unit/customizations/test_update.py +++ b/tests/unit/customizations/test_update.py @@ -69,6 +69,24 @@ def test_supported_distribution_source_runs_installer(self, source): assert command([], global_args()) == 0 runner.assert_called_once() + def test_prompts_agent_toolkit_after_successful_update(self): + command = self._command(USER_INSTALL) + with mock.patch.object( + update_module, 'maybe_prompt_agent_toolkit' + ) as prompt: + command([], global_args()) + prompt.assert_called_once() + + def test_no_agent_toolkit_prompt_when_update_fails(self): + runner = mock.Mock(side_effect=subprocess.CalledProcessError(1, 'x')) + command = self._command(USER_INSTALL, runner=runner) + with mock.patch.object( + update_module, 'maybe_prompt_agent_toolkit' + ) as prompt: + with pytest.raises(UpdateError): + command([], global_args()) + prompt.assert_not_called() + @pytest.mark.parametrize('source', ['source', 'other', 'pip', '']) def test_unsupported_distribution_source_raises(self, source): runner = mock.Mock() @@ -249,6 +267,16 @@ def test_spawns_detached_cmd_wrapper(self): assert cmd[2].endswith('.cmd') assert len(cmd) == 3 + def test_does_not_prompt_agent_toolkit(self): + # The Windows update runs detached and the parent exits before the + # update completes, so there is no safe point to prompt. + command = self._command(USER_INSTALL) + with mock.patch.object( + update_module, 'maybe_prompt_agent_toolkit' + ) as prompt: + command([], global_args()) + prompt.assert_not_called() + def test_downloads_install_script_referenced_by_wrapper(self): downloader = mock.Mock() _, wrapper = self._run(USER_INSTALL, downloader=downloader) From dd9751575200b09e89956a42dcb3c474b84c3aa3 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 22 Jul 2026 16:23:55 -0400 Subject: [PATCH 2/7] Add Agent Toolkit hint to aws login and configure sso --- .../enhancement-agenttoolkit-hint.json | 5 -- .../enhancement-configure-67593.json | 5 ++ .../customizations/configure/sso_commands.py | 4 ++ awscli/customizations/login/login.py | 11 +++- .../unit/customizations/configure/test_sso.py | 24 +++++++ tests/unit/customizations/login/test_login.py | 66 +++++++++++++++++++ 6 files changed, 109 insertions(+), 6 deletions(-) delete mode 100644 .changes/next-release/enhancement-agenttoolkit-hint.json create mode 100644 .changes/next-release/enhancement-configure-67593.json create mode 100644 tests/unit/customizations/login/test_login.py diff --git a/.changes/next-release/enhancement-agenttoolkit-hint.json b/.changes/next-release/enhancement-agenttoolkit-hint.json deleted file mode 100644 index 86934da853ee..000000000000 --- a/.changes/next-release/enhancement-agenttoolkit-hint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "type": "enhancement", - "category": "configure", - "description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected: interactively after ``aws configure`` or ``aws update`` (Unix), and as a tip printed by the install scripts. The interactive prompt appears only when no AWS skills are installed and can be permanently suppressed by answering ``never``." -} diff --git a/.changes/next-release/enhancement-configure-67593.json b/.changes/next-release/enhancement-configure-67593.json new file mode 100644 index 000000000000..80f1c6b28aa8 --- /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: interactively after ``aws configure``, ``aws configure sso``, ``aws update`` (Unix), and a first-time ``aws login`` that creates a new profile, and as a tip printed by the install scripts. The interactive prompt appears only when no AWS skills are installed and can be permanently suppressed by answering ``never``." +} 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/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/login/test_login.py b/tests/unit/customizations/login/test_login.py new file mode 100644 index 000000000000..5e9be1db73b9 --- /dev/null +++ b/tests/unit/customizations/login/test_login.py @@ -0,0 +1,66 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from argparse import Namespace +from unittest import mock + +import pytest + +from awscli.customizations.login import login as login_module +from awscli.customizations.login.login import LoginCommand + + +@pytest.fixture +def session(): + sess = mock.Mock() + sess.profile = None + sess.available_profiles = [] + sess._profile_map = {} + sess.full_config = {'profiles': {}} + sess.get_config_variable.return_value = 'us-east-1' + return sess + + +@pytest.fixture +def parsed_globals(): + return Namespace( + region='us-east-1', + endpoint_url=None, + verify_ssl=None, + ) + + +def _run_login(session, parsed_globals): + command = LoginCommand( + session, + token_loader=mock.Mock(), + config_file_writer=mock.Mock(), + ) + fetcher = mock.Mock() + fetcher.fetch_token.return_value = ('access.token', 'session-id') + with ( + mock.patch.object( + login_module, 'SameDeviceLoginTokenFetcher', return_value=fetcher + ), + mock.patch.object(login_module, 'AuthCodeFetcher'), + mock.patch.object(login_module, 'OpenBrowserHandler'), + mock.patch.object(login_module.EC, 'new_generate'), + mock.patch.object( + login_module, 'maybe_prompt_agent_toolkit' + ) as prompt, + ): + command._run_main(Namespace(remote=False), parsed_globals) + return prompt + + +def test_prompts_agent_toolkit_for_new_profile(session, parsed_globals): + session.available_profiles = [] + prompt = _run_login(session, parsed_globals) + prompt.assert_called_once() + + +def test_no_prompt_when_reauthenticating_existing_profile( + session, parsed_globals +): + session.available_profiles = ['default'] + prompt = _run_login(session, parsed_globals) + prompt.assert_not_called() From 60f043e06dbef04de8bce079570215c018ace2ac Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 28 Jul 2026 11:27:10 -0400 Subject: [PATCH 3/7] Address review feedback --- awscli/customizations/agenttoolkit/hint.py | 10 ++- awscli/customizations/prompts.py | 2 +- tests/functional/login/test_login.py | 58 ++++++++++++++++ .../customizations/agenttoolkit/test_hint.py | 12 ++++ tests/unit/customizations/login/test_login.py | 66 ------------------- 5 files changed, 79 insertions(+), 69 deletions(-) delete mode 100644 tests/unit/customizations/login/test_login.py diff --git a/awscli/customizations/agenttoolkit/hint.py b/awscli/customizations/agenttoolkit/hint.py index bdf5c9ae481f..fe1a9dbbe2e1 100644 --- a/awscli/customizations/agenttoolkit/hint.py +++ b/awscli/customizations/agenttoolkit/hint.py @@ -22,6 +22,8 @@ import logging import os +from botocore.utils import ensure_boolean + from awscli.customizations.agenttoolkit.agents import ( get_detected_real_agents, ) @@ -36,9 +38,11 @@ STATE_PATH = '~/.aws/cli/agent-toolkit/state.json' +HINT_DISABLED_ENV_VAR = 'AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED' + PROMPT_TEXT = ( - 'Configure AWS skills and the AWS MCP server for your AI coding ' - 'agent(s)? [Y/n/never]: ' + '\nConfigure AWS skills and the AWS MCP server for your AI coding ' + 'agent(s)? [y/n/never]: ' ) @@ -83,6 +87,8 @@ def _has_installed_skills(detected_agents): def _is_eligible(detected_agents): if not is_stdin_a_tty(): return False + if ensure_boolean(os.environ.get(HINT_DISABLED_ENV_VAR, '')): + return False if _load_state().get('hint_dismissed'): return False if not detected_agents: diff --git a/awscli/customizations/prompts.py b/awscli/customizations/prompts.py index 32972c784a9f..a1a9921d1b24 100644 --- a/awscli/customizations/prompts.py +++ b/awscli/customizations/prompts.py @@ -46,7 +46,7 @@ def yes_no_never_choice(prompt): while True: response = compat_input(prompt) - if response.lower() in ('y', 'yes') or response == '': + if response.lower() in ('y', 'yes'): return 'yes' elif response.lower() in ('n', 'no'): return 'no' 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_hint.py b/tests/unit/customizations/agenttoolkit/test_hint.py index 52b1f312829b..c57c6e2bcae3 100644 --- a/tests/unit/customizations/agenttoolkit/test_hint.py +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -73,6 +73,18 @@ def test_skipped_when_not_a_tty(state_file, wizard_cls): 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_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})) diff --git a/tests/unit/customizations/login/test_login.py b/tests/unit/customizations/login/test_login.py deleted file mode 100644 index 5e9be1db73b9..000000000000 --- a/tests/unit/customizations/login/test_login.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 -from argparse import Namespace -from unittest import mock - -import pytest - -from awscli.customizations.login import login as login_module -from awscli.customizations.login.login import LoginCommand - - -@pytest.fixture -def session(): - sess = mock.Mock() - sess.profile = None - sess.available_profiles = [] - sess._profile_map = {} - sess.full_config = {'profiles': {}} - sess.get_config_variable.return_value = 'us-east-1' - return sess - - -@pytest.fixture -def parsed_globals(): - return Namespace( - region='us-east-1', - endpoint_url=None, - verify_ssl=None, - ) - - -def _run_login(session, parsed_globals): - command = LoginCommand( - session, - token_loader=mock.Mock(), - config_file_writer=mock.Mock(), - ) - fetcher = mock.Mock() - fetcher.fetch_token.return_value = ('access.token', 'session-id') - with ( - mock.patch.object( - login_module, 'SameDeviceLoginTokenFetcher', return_value=fetcher - ), - mock.patch.object(login_module, 'AuthCodeFetcher'), - mock.patch.object(login_module, 'OpenBrowserHandler'), - mock.patch.object(login_module.EC, 'new_generate'), - mock.patch.object( - login_module, 'maybe_prompt_agent_toolkit' - ) as prompt, - ): - command._run_main(Namespace(remote=False), parsed_globals) - return prompt - - -def test_prompts_agent_toolkit_for_new_profile(session, parsed_globals): - session.available_profiles = [] - prompt = _run_login(session, parsed_globals) - prompt.assert_called_once() - - -def test_no_prompt_when_reauthenticating_existing_profile( - session, parsed_globals -): - session.available_profiles = ['default'] - prompt = _run_login(session, parsed_globals) - prompt.assert_not_called() From 6a1aa5a17e72e61390dded61acd729881bc97162 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 28 Jul 2026 12:03:01 -0400 Subject: [PATCH 4/7] strip interactive hint from update command --- .../enhancement-configure-67593.json | 2 +- awscli/customizations/update.py | 18 +++-------- tests/unit/customizations/test_update.py | 31 ++++++------------- 3 files changed, 15 insertions(+), 36 deletions(-) diff --git a/.changes/next-release/enhancement-configure-67593.json b/.changes/next-release/enhancement-configure-67593.json index 80f1c6b28aa8..6f8d54b68d49 100644 --- a/.changes/next-release/enhancement-configure-67593.json +++ b/.changes/next-release/enhancement-configure-67593.json @@ -1,5 +1,5 @@ { "type": "enhancement", "category": "configure", - "description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected: interactively after ``aws configure``, ``aws configure sso``, ``aws update`` (Unix), and a first-time ``aws login`` that creates a new profile, and as a tip printed by the install scripts. The interactive prompt appears only when no AWS skills are installed and can be permanently suppressed by answering ``never``." + "description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected. An interactive prompt is shown after ``aws configure``, ``aws configure sso``, and a first-time ``aws login`` that creates a new profile; it appears only when no AWS skills are installed and can be permanently suppressed by answering ``never`` or by setting the ``AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED`` environment variable to ``true``. A non-interactive tip is also printed by the install scripts and after ``aws update``." } diff --git a/awscli/customizations/update.py b/awscli/customizations/update.py index 9e8c60a631c2..b9954e03d764 100644 --- a/awscli/customizations/update.py +++ b/awscli/customizations/update.py @@ -15,9 +15,6 @@ get_distribution_source, ) from awscli.compat import is_windows -from awscli.customizations.agenttoolkit.hint import ( - maybe_prompt_agent_toolkit, -) from awscli.customizations.commands import BasicCommand from awscli.customizations.utils import uni_print @@ -98,12 +95,12 @@ 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() - self._maybe_prompt_agent_toolkit(parsed_globals) + uni_print( + "\nTip: run 'aws configure agent-toolkit' to set up AWS skills " + "and the AWS MCP server for your AI coding agent.\n" + ) return 0 - def _maybe_prompt_agent_toolkit(self, parsed_globals): - maybe_prompt_agent_toolkit(self._session, parsed_globals) - def _do_update(self): raise NotImplementedError @@ -227,13 +224,6 @@ def _do_update(self): 'update will complete shortly.\n' ) - def _maybe_prompt_agent_toolkit(self, parsed_globals): - # No-op on Windows: the update runs in a detached subprocess and the - # actual CLI update happens after this process exits, so there is no - # safe point to prompt (blocking here would hold the exe lock the - # detached-process workaround exists to avoid). - pass - def _run_install(self, cmd): subprocess.Popen( cmd, diff --git a/tests/unit/customizations/test_update.py b/tests/unit/customizations/test_update.py index dc5228df0cd2..2eb5133243ab 100644 --- a/tests/unit/customizations/test_update.py +++ b/tests/unit/customizations/test_update.py @@ -69,23 +69,17 @@ def test_supported_distribution_source_runs_installer(self, source): assert command([], global_args()) == 0 runner.assert_called_once() - def test_prompts_agent_toolkit_after_successful_update(self): + def test_prints_agent_toolkit_tip_after_successful_update(self, capsys): command = self._command(USER_INSTALL) - with mock.patch.object( - update_module, 'maybe_prompt_agent_toolkit' - ) as prompt: - command([], global_args()) - prompt.assert_called_once() + command([], global_args()) + assert 'aws configure agent-toolkit' in capsys.readouterr().out - def test_no_agent_toolkit_prompt_when_update_fails(self): + 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 mock.patch.object( - update_module, 'maybe_prompt_agent_toolkit' - ) as prompt: - with pytest.raises(UpdateError): - command([], global_args()) - prompt.assert_not_called() + 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): @@ -267,15 +261,10 @@ def test_spawns_detached_cmd_wrapper(self): assert cmd[2].endswith('.cmd') assert len(cmd) == 3 - def test_does_not_prompt_agent_toolkit(self): - # The Windows update runs detached and the parent exits before the - # update completes, so there is no safe point to prompt. + def test_prints_agent_toolkit_tip(self, capsys): command = self._command(USER_INSTALL) - with mock.patch.object( - update_module, 'maybe_prompt_agent_toolkit' - ) as prompt: - command([], global_args()) - prompt.assert_not_called() + command([], global_args()) + assert 'aws configure agent-toolkit' in capsys.readouterr().out def test_downloads_install_script_referenced_by_wrapper(self): downloader = mock.Mock() From 05f3aa36432c760415a7260ada8329001e4cefb9 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 29 Jul 2026 11:03:20 -0400 Subject: [PATCH 5/7] Address review feedback --- awscli/customizations/agenttoolkit/hint.py | 19 ++++++++++--------- .../customizations/agenttoolkit/test_hint.py | 6 ++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/awscli/customizations/agenttoolkit/hint.py b/awscli/customizations/agenttoolkit/hint.py index fe1a9dbbe2e1..647e70209897 100644 --- a/awscli/customizations/agenttoolkit/hint.py +++ b/awscli/customizations/agenttoolkit/hint.py @@ -84,13 +84,14 @@ def _has_installed_skills(detected_agents): return any(agent.get_installed_skills() for agent in detected_agents) -def _is_eligible(detected_agents): +def _is_eligible(): if not is_stdin_a_tty(): return False if ensure_boolean(os.environ.get(HINT_DISABLED_ENV_VAR, '')): 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): @@ -98,18 +99,18 @@ def _is_eligible(detected_agents): return True -def maybe_prompt_agent_toolkit(session, parsed_globals, stream=None): +def maybe_prompt_agent_toolkit(session, parsed_globals): try: - detected_agents = get_detected_real_agents() - if not _is_eligible(detected_agents): + if not _is_eligible(): return choice = yes_no_never_choice(PROMPT_TEXT) if choice == 'never': _dismiss_forever() - return - if choice == 'no': - return - command = ConfigureAgentToolkitCommand(session) - command([], parsed_globals) + 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/tests/unit/customizations/agenttoolkit/test_hint.py b/tests/unit/customizations/agenttoolkit/test_hint.py index c57c6e2bcae3..384020370824 100644 --- a/tests/unit/customizations/agenttoolkit/test_hint.py +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -120,3 +120,9 @@ def test_detection_failure_does_not_raise(state_file, wizard_cls): # Must swallow errors so `aws configure` never fails because of it. 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') From 5965e726f24f2a261c271fe04dde106c1d75d1f1 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Wed, 29 Jul 2026 14:38:33 -0400 Subject: [PATCH 6/7] Show hint instead of prompt outside us-east-1 --- awscli/customizations/agenttoolkit/hint.py | 38 ++++++++++++-- awscli/customizations/update.py | 10 ++-- .../customizations/agenttoolkit/test_hint.py | 52 +++++++++++++++++-- tests/unit/customizations/test_update.py | 8 +++ 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/awscli/customizations/agenttoolkit/hint.py b/awscli/customizations/agenttoolkit/hint.py index 647e70209897..c6be84d690f2 100644 --- a/awscli/customizations/agenttoolkit/hint.py +++ b/awscli/customizations/agenttoolkit/hint.py @@ -14,8 +14,9 @@ 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 -the toolkit is not already installed. The hint is interactive-only and can be -permanently suppressed. +the toolkit is not already installed. In the supported region the offer is an +interactive prompt; elsewhere it is a non-interactive tip. Either way it only +shows on a TTY and can be suppressed permanently. """ import json @@ -40,11 +41,21 @@ HINT_DISABLED_ENV_VAR = 'AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED' +# The Agent Toolkit skill APIs are only available in this region today. When +# the caller's region differs, running the wizard inline would fail, so we +# show a non-interactive hint pointing at the working invocation instead. +SUPPORTED_REGION = 'us-east-1' + 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 --region us-east-1' 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) @@ -84,10 +95,14 @@ 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 ensure_boolean(os.environ.get(HINT_DISABLED_ENV_VAR, '')): + if hint_disabled(): return False if _load_state().get('hint_dismissed'): return False @@ -99,10 +114,27 @@ def _is_eligible(): 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 maybe_prompt_agent_toolkit(session, parsed_globals): try: if not _is_eligible(): return + # The wizard can only install skills from SUPPORTED_REGION. If the + # caller is elsewhere, offering an inline "yes" would run the wizard + # and fail, so we print a hint pointing at the working command + # instead of prompting. + if _resolve_region(session, parsed_globals) != SUPPORTED_REGION: + uni_print(HINT_TEXT) + return choice = yes_no_never_choice(PROMPT_TEXT) if choice == 'never': _dismiss_forever() diff --git a/awscli/customizations/update.py b/awscli/customizations/update.py index b9954e03d764..aa2518812d2d 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_disabled from awscli.customizations.commands import BasicCommand from awscli.customizations.utils import uni_print @@ -95,10 +96,11 @@ 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() - uni_print( - "\nTip: run 'aws configure agent-toolkit' to set up AWS skills " - "and the AWS MCP server for your AI coding agent.\n" - ) + if not hint_disabled(): + uni_print( + "\nTip: run 'aws configure agent-toolkit' to set up AWS " + "skills and the AWS MCP server for your AI coding agent.\n" + ) return 0 def _do_update(self): diff --git a/tests/unit/customizations/agenttoolkit/test_hint.py b/tests/unit/customizations/agenttoolkit/test_hint.py index 384020370824..bdf574263fd0 100644 --- a/tests/unit/customizations/agenttoolkit/test_hint.py +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -37,15 +37,17 @@ def wizard_cls(): yield cls -def _run(choice='yes', agents=None, tty=True): +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(), MagicMock()) + hint.maybe_prompt_agent_toolkit(MagicMock(), parsed_globals) def test_launches_wizard_on_yes(state_file, wizard_cls): @@ -85,6 +87,15 @@ def test_not_skipped_when_env_var_false(state_file, wizard_cls, monkeypatch): 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='us-west-2') + 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})) @@ -102,10 +113,44 @@ def test_skipped_when_skills_already_installed(state_file, wizard_cls): assert not wizard_cls.called +def test_non_supported_region_prints_hint_and_does_not_prompt( + state_file, wizard_cls, capsys +): + 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 = 'us-west-2' + hint.maybe_prompt_agent_toolkit(MagicMock(), parsed_globals) + + assert not prompt.called + assert not wizard_cls.called + assert '--region us-east-1' in capsys.readouterr().out + + +def test_region_falls_back_to_session_config(state_file, wizard_cls): + session = MagicMock() + session.get_config_variable.return_value = 'us-east-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', return_value='yes'), + ): + hint.maybe_prompt_agent_toolkit(session, parsed_globals) + assert 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') - # Corrupt state must not suppress or crash; hint stays eligible. _run(choice='yes') assert wizard_cls.called @@ -117,7 +162,6 @@ def test_detection_failure_does_not_raise(state_file, wizard_cls): hint, 'get_detected_real_agents', side_effect=OSError('boom') ), ): - # Must swallow errors so `aws configure` never fails because of it. hint.maybe_prompt_agent_toolkit(MagicMock(), MagicMock()) assert not wizard_cls.called diff --git a/tests/unit/customizations/test_update.py b/tests/unit/customizations/test_update.py index 2eb5133243ab..5cdbb103da7e 100644 --- a/tests/unit/customizations/test_update.py +++ b/tests/unit/customizations/test_update.py @@ -74,6 +74,14 @@ def test_prints_agent_toolkit_tip_after_successful_update(self, capsys): 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) From 51e7a13d9ac7a39676065b4f1f50c23ce440bf54 Mon Sep 17 00:00:00 2001 From: Andrew Asseily Date: Tue, 4 Aug 2026 12:17:56 -0400 Subject: [PATCH 7/7] Dropped the interactive prompt entirely, so everyone gets the same static aws configure agent-toolkit --region us-east-1 --- .../enhancement-configure-67593.json | 2 +- awscli/customizations/agenttoolkit/hint.py | 106 ++----------- awscli/customizations/configure/configure.py | 4 +- .../customizations/configure/sso_commands.py | 4 +- awscli/customizations/login/login.py | 4 +- awscli/customizations/prompts.py | 21 --- awscli/customizations/update.py | 7 +- exe/assets/install | 2 +- macpkg/scripts/postinstall | 2 +- tests/functional/login/test_login.py | 16 +- .../customizations/agenttoolkit/test_hint.py | 146 +++--------------- .../unit/customizations/configure/test_sso.py | 8 +- 12 files changed, 62 insertions(+), 260 deletions(-) diff --git a/.changes/next-release/enhancement-configure-67593.json b/.changes/next-release/enhancement-configure-67593.json index 6f8d54b68d49..82ee67d61513 100644 --- a/.changes/next-release/enhancement-configure-67593.json +++ b/.changes/next-release/enhancement-configure-67593.json @@ -1,5 +1,5 @@ { "type": "enhancement", "category": "configure", - "description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected. An interactive prompt is shown after ``aws configure``, ``aws configure sso``, and a first-time ``aws login`` that creates a new profile; it appears only when no AWS skills are installed and can be permanently suppressed by answering ``never`` or by setting the ``AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED`` environment variable to ``true``. A non-interactive tip is also printed by the install scripts and after ``aws update``." + "description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected. A one-line tip pointing at ``aws configure agent-toolkit`` is printed after ``aws configure``, ``aws configure sso``, and a first-time ``aws login`` that creates a new profile; it appears only on a terminal when no AWS skills are installed yet, and can be suppressed by setting the ``AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED`` environment variable to ``true``. The same tip is also printed by the install scripts and after ``aws update``." } diff --git a/awscli/customizations/agenttoolkit/hint.py b/awscli/customizations/agenttoolkit/hint.py index c6be84d690f2..6b08968dc976 100644 --- a/awscli/customizations/agenttoolkit/hint.py +++ b/awscli/customizations/agenttoolkit/hint.py @@ -10,16 +10,15 @@ # 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. +"""End-of-command hint pointing at 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 -the toolkit is not already installed. In the supported region the offer is an -interactive prompt; elsewhere it is a non-interactive tip. Either way it only -shows on a TTY and can be suppressed permanently. +After a successful ``aws configure``, ``aws configure sso``, or first-time +``aws login``, print a one-line tip suggesting ``aws configure agent-toolkit`` +when a supported AI coding agent is present and no AWS skills are installed +yet. The tip only shows on a TTY and can be suppressed with an environment +variable. """ -import json import logging import os @@ -28,84 +27,35 @@ 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 +from awscli.utils import is_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 Agent Toolkit skill APIs are only available in this region today. When -# the caller's region differs, running the wizard inline would fail, so we -# show a non-interactive hint pointing at the working invocation instead. -SUPPORTED_REGION = 'us-east-1' - -PROMPT_TEXT = ( - '\nConfigure AWS skills and the AWS MCP server for your AI coding ' - 'agent(s)? [y/n/never]: ' -) - +# The Agent Toolkit skill APIs are only available in us-east-1 today, so the +# tip pins the region explicitly rather than routing the caller's configured +# region through a hidden cross-region call. HINT_TEXT = ( "\nTip: run 'aws configure agent-toolkit --region us-east-1' 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 hint_disabled(): + return ensure_boolean(os.environ.get(HINT_DISABLED_ENV_VAR, '')) 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(): + if not is_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 @@ -114,35 +64,9 @@ def _is_eligible(): 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 maybe_prompt_agent_toolkit(session, parsed_globals): +def maybe_print_agent_toolkit_hint(): try: - if not _is_eligible(): - return - # The wizard can only install skills from SUPPORTED_REGION. If the - # caller is elsewhere, offering an inline "yes" would run the wizard - # and fail, so we print a hint pointing at the working command - # instead of prompting. - if _resolve_region(session, parsed_globals) != SUPPORTED_REGION: + if _is_eligible(): 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/configure/configure.py b/awscli/customizations/configure/configure.py index c1c9a8d7a4c7..3f9d8e3d1e1b 100644 --- a/awscli/customizations/configure/configure.py +++ b/awscli/customizations/configure/configure.py @@ -21,7 +21,7 @@ ConfigureAgentToolkitCommand, ) from awscli.customizations.agenttoolkit.hint import ( - maybe_prompt_agent_toolkit, + maybe_print_agent_toolkit_hint, ) from awscli.customizations.commands import BasicCommand from awscli.customizations.configure.addmodel import AddModelCommand @@ -196,7 +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) + maybe_print_agent_toolkit_hint() 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 265d8608fc45..444cf5d89149 100644 --- a/awscli/customizations/configure/sso_commands.py +++ b/awscli/customizations/configure/sso_commands.py @@ -36,7 +36,7 @@ from botocore.useragent import register_feature_id from awscli.customizations.agenttoolkit.hint import ( - maybe_prompt_agent_toolkit, + maybe_print_agent_toolkit_hint, ) from awscli.customizations.configure import ( get_section_header, @@ -355,7 +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) + maybe_print_agent_toolkit_hint() 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 7f338769e0a2..8a7ddc248268 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -15,7 +15,7 @@ from awscli.compat import compat_input from awscli.customizations.agenttoolkit.hint import ( - maybe_prompt_agent_toolkit, + maybe_print_agent_toolkit_hint, ) from awscli.customizations.commands import BasicCommand from awscli.customizations.configure.writer import ConfigFileWriter @@ -160,7 +160,7 @@ def _run_main(self, parsed_args, parsed_globals): # 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) + maybe_print_agent_toolkit_hint() 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 a1a9921d1b24..339da49df273 100644 --- a/awscli/customizations/prompts.py +++ b/awscli/customizations/prompts.py @@ -35,27 +35,6 @@ 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 aa2518812d2d..a36d1600f5f6 100644 --- a/awscli/customizations/update.py +++ b/awscli/customizations/update.py @@ -15,7 +15,7 @@ get_distribution_source, ) from awscli.compat import is_windows -from awscli.customizations.agenttoolkit.hint import hint_disabled +from awscli.customizations.agenttoolkit.hint import HINT_TEXT, hint_disabled from awscli.customizations.commands import BasicCommand from awscli.customizations.utils import uni_print @@ -97,10 +97,7 @@ def _run_main(self, parsed_args, parsed_globals): self._no_color = parsed_globals.color == 'off' self._do_update() if not hint_disabled(): - uni_print( - "\nTip: run 'aws configure agent-toolkit' to set up AWS " - "skills and the AWS MCP server for your AI coding agent.\n" - ) + uni_print(HINT_TEXT) return 0 def _do_update(self): diff --git a/exe/assets/install b/exe/assets/install index 30b90cff5d54..317379d41ca7 100755 --- a/exe/assets/install +++ b/exe/assets/install @@ -163,7 +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." + echo "Tip: run 'aws configure agent-toolkit --region us-east-1' 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 7dd631019cb1..439ee674f59f 100755 --- a/macpkg/scripts/postinstall +++ b/macpkg/scripts/postinstall @@ -49,4 +49,4 @@ 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." +echo "Tip: run 'aws configure agent-toolkit --region us-east-1' 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 b02ae77a6c7b..357f72aaf333 100644 --- a/tests/functional/login/test_login.py +++ b/tests/functional/login/test_login.py @@ -302,9 +302,9 @@ def test_abort_if_profile_has_existing_credentials( @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.patch('awscli.customizations.login.login.maybe_print_agent_toolkit_hint') +def test_hints_agent_toolkit_for_new_profile( + mock_hint, mock_token_fetcher, mock_base_sign_in_uri, mock_login_command, @@ -324,16 +324,16 @@ def test_prompts_agent_toolkit_for_new_profile( mock_session.full_config = {'profiles': {}} mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) - mock_prompt.assert_called_once() + mock_hint.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.patch('awscli.customizations.login.login.maybe_print_agent_toolkit_hint') +def test_no_agent_toolkit_hint_for_existing_profile( + mock_hint, mock_token_fetcher, mock_base_sign_in_uri, mock_login_command, @@ -353,4 +353,4 @@ def test_no_agent_toolkit_prompt_for_existing_profile( mock_session.full_config = {'profiles': {'profile-name': {}}} mock_login_command._run_main(DEFAULT_ARGS, DEFAULT_GLOBAL_ARGS) - mock_prompt.assert_not_called() + mock_hint.assert_not_called() diff --git a/tests/unit/customizations/agenttoolkit/test_hint.py b/tests/unit/customizations/agenttoolkit/test_hint.py index bdf574263fd0..aaabe5ee7626 100644 --- a/tests/unit/customizations/agenttoolkit/test_hint.py +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -10,163 +10,65 @@ # 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'): +def _run(agents=None, tty=True): 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, 'is_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() - + hint.maybe_print_agent_toolkit_hint() -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_prints_tip_when_eligible(capsys): + _run() + assert '--region us-east-1' in capsys.readouterr().out -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_no_tip_when_not_a_tty(capsys): + _run(tty=False) + assert capsys.readouterr().out == '' -def test_env_var_also_suppresses_the_tip( - state_file, wizard_cls, capsys, monkeypatch -): +def test_no_tip_when_env_var_true(capsys, monkeypatch): monkeypatch.setenv(hint.HINT_DISABLED_ENV_VAR, 'true') - _run(choice='yes', region='us-west-2') - assert not wizard_cls.called + _run() 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_non_supported_region_prints_hint_and_does_not_prompt( - state_file, wizard_cls, capsys -): - 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 = 'us-west-2' - hint.maybe_prompt_agent_toolkit(MagicMock(), parsed_globals) - - assert not prompt.called - assert not wizard_cls.called +def test_tip_shown_when_env_var_false(capsys, monkeypatch): + monkeypatch.setenv(hint.HINT_DISABLED_ENV_VAR, 'false') + _run() assert '--region us-east-1' in capsys.readouterr().out -def test_region_falls_back_to_session_config(state_file, wizard_cls): - session = MagicMock() - session.get_config_variable.return_value = 'us-east-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', return_value='yes'), - ): - hint.maybe_prompt_agent_toolkit(session, parsed_globals) - assert wizard_cls.called +def test_no_tip_when_no_agents(capsys): + _run(agents=[]) + assert capsys.readouterr().out == '' -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_no_tip_when_skills_already_installed(capsys): + _run(agents=[_agent(installed_skills=['s'])]) + assert capsys.readouterr().out == '' -def test_detection_failure_does_not_raise(state_file, wizard_cls): +def test_detection_failure_does_not_raise(capsys): with ( - patch.object(hint, 'is_stdin_a_tty', return_value=True), + patch.object(hint, 'is_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') + hint.maybe_print_agent_toolkit_hint() + assert capsys.readouterr().out == '' diff --git a/tests/unit/customizations/configure/test_sso.py b/tests/unit/customizations/configure/test_sso.py index 61fe200598a2..29945437788c 100644 --- a/tests/unit/customizations/configure/test_sso.py +++ b/tests/unit/customizations/configure/test_sso.py @@ -1035,7 +1035,7 @@ def test_single_account_single_role_flow( ], ) - def test_prompts_agent_toolkit_after_configuring_profile( + def test_hints_agent_toolkit_after_configuring_profile( self, sso_cmd, ptk_stubber, @@ -1054,10 +1054,10 @@ def test_prompts_agent_toolkit_after_configuring_profile( with mock.patch( 'awscli.customizations.configure.sso_commands.' - 'maybe_prompt_agent_toolkit' - ) as prompt: + 'maybe_print_agent_toolkit_hint' + ) as hint: sso_cmd(args, parsed_globals) - prompt.assert_called_once() + hint.assert_called_once() def test_no_accounts_flow_raises_error( self,