diff --git a/.changes/next-release/enhancement-configure-67593.json b/.changes/next-release/enhancement-configure-67593.json new file mode 100644 index 000000000000..82ee67d61513 --- /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. 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/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..6b08968dc976 --- /dev/null +++ b/awscli/customizations/agenttoolkit/hint.py @@ -0,0 +1,72 @@ +# 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-command hint pointing at the Agent Toolkit wizard. + +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 logging +import os + +from botocore.utils import ensure_boolean + +from awscli.customizations.agenttoolkit.agents import ( + get_detected_real_agents, +) +from awscli.customizations.utils import uni_print +from awscli.utils import is_a_tty + +LOG = logging.getLogger(__name__) + +HINT_DISABLED_ENV_VAR = 'AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED' + +# 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 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 _is_eligible(): + if not is_a_tty(): + return False + if hint_disabled(): + 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 maybe_print_agent_toolkit_hint(): + try: + if _is_eligible(): + uni_print(HINT_TEXT) + 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..3f9d8e3d1e1b 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_print_agent_toolkit_hint, +) 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_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 3c7d0054127c..444cf5d89149 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_print_agent_toolkit_hint, +) 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_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 7acf9ecec5d1..8a7ddc248268 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_print_agent_toolkit_hint, +) 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_print_agent_toolkit_hint() + def accept_change_to_existing_profile_if_needed( self, profile_name, new_session_id ): 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..317379d41ca7 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 --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 ed6d1ef8c3c5..439ee674f59f 100755 --- a/macpkg/scripts/postinstall +++ b/macpkg/scripts/postinstall @@ -48,3 +48,5 @@ EOF EOF fi fi + +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 a89fa0a31670..357f72aaf333 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_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, + 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_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_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, + 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_hint.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..aaabe5ee7626 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_hint.py @@ -0,0 +1,74 @@ +# 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 unittest.mock import MagicMock, patch + +from awscli.customizations.agenttoolkit import hint + + +def _agent(installed_skills=None): + agent = MagicMock() + agent.get_installed_skills.return_value = installed_skills or [] + return agent + + +def _run(agents=None, tty=True): + if agents is None: + agents = [_agent()] + with ( + patch.object(hint, 'is_a_tty', return_value=tty), + patch.object(hint, 'get_detected_real_agents', return_value=agents), + ): + hint.maybe_print_agent_toolkit_hint() + + +def test_prints_tip_when_eligible(capsys): + _run() + assert '--region us-east-1' in capsys.readouterr().out + + +def test_no_tip_when_not_a_tty(capsys): + _run(tty=False) + assert capsys.readouterr().out == '' + + +def test_no_tip_when_env_var_true(capsys, monkeypatch): + monkeypatch.setenv(hint.HINT_DISABLED_ENV_VAR, 'true') + _run() + assert capsys.readouterr().out == '' + + +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_no_tip_when_no_agents(capsys): + _run(agents=[]) + assert capsys.readouterr().out == '' + + +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(capsys): + with ( + patch.object(hint, 'is_a_tty', return_value=True), + patch.object( + hint, 'get_detected_real_agents', side_effect=OSError('boom') + ), + ): + 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 e7d5c7065da1..29945437788c 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_hints_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_print_agent_toolkit_hint' + ) as hint: + sso_cmd(args, parsed_globals) + hint.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)