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/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/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..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/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() 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)