Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-configure-67593.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "configure",
"description": "Suggest installing the Agent Toolkit for AWS when a supported AI coding agent is detected. In the commercial partition an interactive prompt is shown after ``aws configure``, ``aws configure sso``, and a first-time ``aws login`` that creates a new profile; in other partitions a non-interactive tip is shown instead. The prompt only appears on a terminal when no AWS skills are installed yet, and can be permanently suppressed by answering ``never`` or by setting the ``AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED`` environment variable to ``true``. ``aws configure agent-toolkit`` now defaults to the ``us-east-1`` control-plane region unless ``--region`` is given, and a tip is also printed by the install scripts and after ``aws update``."
}
8 changes: 8 additions & 0 deletions awscli/customizations/agenttoolkit/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
169 changes: 169 additions & 0 deletions awscli/customizations/agenttoolkit/hint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.
"""End-of-``aws configure`` hint suggesting the Agent Toolkit wizard.

After a successful ``aws configure`` that writes profile values, offer to run
``aws configure agent-toolkit`` when a supported AI coding agent is present
and no AWS skills are installed yet. The wizard defaults to the Agent Toolkit
region (us-east-1), so in the commercial partition the offer is
an interactive prompt that can run the wizard directly. In other partitions
that region is unreachable, so we fall back to a non-interactive tip instead
of routing the user into a cross-partition call. Either way the hint only
shows on a TTY and can be suppressed.
"""

import json
import logging
import os
import re

from botocore.loaders import Loader
from botocore.utils import ensure_boolean

from awscli.customizations.agenttoolkit.agents import (
get_detected_real_agents,
)
from awscli.customizations.agenttoolkit.configure import (
ConfigureAgentToolkitCommand,
)
from awscli.customizations.prompts import yes_no_never_choice
from awscli.customizations.utils import uni_print
from awscli.utils import is_stdin_a_tty

LOG = logging.getLogger(__name__)

STATE_PATH = '~/.aws/cli/agent-toolkit/state.json'

HINT_DISABLED_ENV_VAR = 'AWS_CLI_AGENT_TOOLKIT_HINT_DISABLED'

# The wizard runs against the Agent Toolkit region, which only
# exists in the commercial partition. Elsewhere we cannot run it inline, so we
# only offer the interactive prompt to callers in this partition.
COMMERCIAL_PARTITION = 'aws'

PROMPT_TEXT = (
'\nConfigure AWS skills and the AWS MCP server for your AI coding '
'agent(s)? [y/n/never]: '
)

HINT_TEXT = (
"\nTip: run 'aws configure agent-toolkit' to set up AWS skills and the "
'AWS MCP server for your AI coding agent(s).\n'
)


def _state_file():
return os.path.expanduser(STATE_PATH)


def _load_state():
try:
with open(_state_file()) as f:
return json.load(f)
except FileNotFoundError:
return {}
except (OSError, json.JSONDecodeError) as e:
LOG.debug('Could not read agent toolkit hint state: %s', e)
return {}


def _save_state(state):
path = _state_file()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f'{path}.tmp'
with open(tmp_path, 'w') as f:
json.dump(state, f)
f.write('\n')
os.replace(tmp_path, path)
except OSError as e:
LOG.debug('Could not write agent toolkit hint state: %s', e)


def _dismiss_forever():
state = _load_state()
state['hint_dismissed'] = True
_save_state(state)


def _has_installed_skills(detected_agents):
return any(agent.get_installed_skills() for agent in detected_agents)


def hint_disabled():
return ensure_boolean(os.environ.get(HINT_DISABLED_ENV_VAR, ''))


def _is_eligible():
if not is_stdin_a_tty():
return False
if hint_disabled():
return False
if _load_state().get('hint_dismissed'):
return False
detected_agents = get_detected_real_agents()
if not detected_agents:
return False
if _has_installed_skills(detected_agents):
return False
return True


def _resolve_region(session, parsed_globals):
region = getattr(parsed_globals, 'region', None)
if region:
return region
try:
return session.get_config_variable('region')
except Exception:
return None


def _region_partition(region):
for partition in Loader().load_data('partitions')['partitions']:
if region in partition.get('regions', {}):
return partition['id']
regex = partition.get('regionRegex')
if regex and re.match(regex, region):
return partition['id']
return None


def _can_run_wizard(session, parsed_globals):
region = _resolve_region(session, parsed_globals)
if not region:
return True
return _region_partition(region) == COMMERCIAL_PARTITION


def maybe_prompt_agent_toolkit(session, parsed_globals):
try:
if not _is_eligible():
return
# Outside the commercial partition the wizard's region is unreachable,
# so print a tip instead of prompting and routing the user into a
# cross-partition call that would fail.
if not _can_run_wizard(session, parsed_globals):
uni_print(HINT_TEXT)
return
choice = yes_no_never_choice(PROMPT_TEXT)
if choice == 'never':
_dismiss_forever()
run_wizard = choice == 'yes'
except Exception as e:
LOG.debug('Agent toolkit hint failed: %s', e, exc_info=True)
Comment thread
AndrewAsseily marked this conversation as resolved.
return

if run_wizard:
command = ConfigureAgentToolkitCommand(session)
command([], parsed_globals)
11 changes: 11 additions & 0 deletions awscli/customizations/agenttoolkit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,22 @@
}


# The Agent Toolkit API is served from a single region today,
# Default there unless the caller passes an explicit ``--region``,
# otherwise a user whose configured region is elsewhere would hit
# an endpoint that does not exist.
AGENT_TOOLKIT_REGION = 'us-east-1'


def create_client(session, parsed_globals):
overrides = {}
if not getattr(parsed_globals, 'region', None):
overrides['region_name'] = AGENT_TOOLKIT_REGION
return create_client_from_parsed_globals(
session,
'agenttoolkit',
parsed_globals,
overrides=overrides,
)


Expand Down
4 changes: 4 additions & 0 deletions awscli/customizations/configure/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions awscli/customizations/configure/sso_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
11 changes: 10 additions & 1 deletion awscli/customizations/login/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
):
Expand Down
21 changes: 21 additions & 0 deletions awscli/customizations/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ def yes_no_choice(prompt):
uni_print('Invalid response. Please enter "y" or "n"\n')


def yes_no_never_choice(prompt):
"""
Prompts the user with a yes/no/never question.
Continually re-prompts for invalid selections.

:param prompt: Prompt text.
:returns: 'yes', 'no', or 'never'.
"""
while True:
response = compat_input(prompt)

if response.lower() in ('y', 'yes'):
return 'yes'
elif response.lower() in ('n', 'no'):
return 'no'
elif response.lower() == 'never':
return 'never'
else:
uni_print('Invalid response. Please enter "y", "n", or "never"\n')


def multiselect_choice(
message,
items,
Expand Down
3 changes: 3 additions & 0 deletions awscli/customizations/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions exe/assets/install
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ main() {
create_bin_symlinks
write_install_json
echo "You can now run: $BIN_AWS_EXE --version"
echo "Tip: run 'aws configure agent-toolkit' to set up AWS skills and the AWS MCP server for your AI coding agent(s)."
exit 0
}

Expand Down
2 changes: 2 additions & 0 deletions macpkg/scripts/postinstall
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ EOF
EOF
fi
fi

echo "Tip: run 'aws configure agent-toolkit' to set up AWS skills and the AWS MCP server for your AI coding agent(s)."
58 changes: 58 additions & 0 deletions tests/functional/login/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading
Loading