diff --git a/.changes/next-release/enhancement-agenttoolkit-26195.json b/.changes/next-release/enhancement-agenttoolkit-26195.json new file mode 100644 index 000000000000..88a042c35537 --- /dev/null +++ b/.changes/next-release/enhancement-agenttoolkit-26195.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "agent-toolkit", + "description": "Adds ``aws agent-toolkit check-updates`` to report the installed version, latest available version, and whether an update is available for each installed AWS skill, and adds ``--all`` to ``aws agent-toolkit update-skill`` to update every installed skill that is out of date." +} diff --git a/awscli/customizations/agenttoolkit/__init__.py b/awscli/customizations/agenttoolkit/__init__.py index 32667db797a9..07df3fcfab1d 100644 --- a/awscli/customizations/agenttoolkit/__init__.py +++ b/awscli/customizations/agenttoolkit/__init__.py @@ -13,6 +13,9 @@ import os from awscli.customizations.agenttoolkit.add_skill import AddSkillCommand +from awscli.customizations.agenttoolkit.check_updates import ( + CheckUpdatesCommand, +) from awscli.customizations.agenttoolkit.get_skill_file import ( GetSkillFileCommand, ) @@ -76,3 +79,4 @@ def _inject_commands(command_table, session, **kwargs): ) command_table['remove-skill'] = RemoveCommand(session) command_table['update-skill'] = UpdateSkillCommand(session) + command_table['check-updates'] = CheckUpdatesCommand(session) diff --git a/awscli/customizations/agenttoolkit/check_updates.py b/awscli/customizations/agenttoolkit/check_updates.py new file mode 100644 index 000000000000..02a39986b510 --- /dev/null +++ b/awscli/customizations/agenttoolkit/check_updates.py @@ -0,0 +1,99 @@ +# 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 os + +from awscli.customizations.agenttoolkit.utils import ( + AGENT_ARG, + collect_installed_skills, + create_client, + read_installed_version, + resolve_agents, + resolve_latest_version, +) +from awscli.customizations.commands import BasicCommand +from awscli.formatter import get_formatter +from awscli.utils import OutputStreamFactory + + +class CheckUpdatesCommand(BasicCommand): + NAME = 'check-updates' + DESCRIPTION = ( + 'Check installed AWS skills for available updates. For each installed ' + 'skill this reports the version currently on disk, the latest version ' + 'available, and whether an update is available. Nothing is downloaded ' + 'or modified, run ``aws agent-toolkit update-skill`` to apply an ' + 'update. By default it checks skills for all detected agents, use ' + '``--agent`` to check only a specific tool.' + ) + ARG_TABLE = [AGENT_ARG] + + def __init__( + self, + session, + agent_configs=None, + client=None, + output_stream_factory=None, + ): + super().__init__(session) + self._agent_configs = agent_configs + self._client = client + if output_stream_factory is None: + output_stream_factory = OutputStreamFactory(session) + self._output_stream_factory = output_stream_factory + + def _run_main(self, parsed_args, parsed_globals): + agent_filter = getattr(parsed_args, 'agent', None) + agents = resolve_agents(agent_filter, self._agent_configs) + installed_skills = collect_installed_skills(agents) + + result = {'skills': []} + if installed_skills: + client = self._client or create_client( + self._session, parsed_globals + ) + result['skills'] = self._build_rows(client, installed_skills) + + output = parsed_globals.output + if output is None: + output = self._session.get_config_variable('output') + formatter = get_formatter(output, parsed_globals) + with self._output_stream_factory.get_output_stream() as stream: + formatter(self.NAME, result, stream=stream) + return 0 + + def _build_rows(self, client, installed_skills): + # The same skill is often installed for several agents. Look up each + # name once so the number of API calls tracks distinct skills rather + # than installs. + latest_versions = {} + rows = [] + for skill in installed_skills: + if skill.name not in latest_versions: + latest_versions[skill.name] = resolve_latest_version( + client, skill.name + ) + latest_version = latest_versions[skill.name] + installed_version = read_installed_version( + os.path.dirname(skill.path) + ) + rows.append( + { + 'agent': skill.agent.display_name, + 'name': skill.name, + 'path': skill.path, + 'installedVersion': installed_version, + 'latestVersion': latest_version, + 'updateAvailable': installed_version != latest_version, + } + ) + return rows diff --git a/awscli/customizations/agenttoolkit/update_skill.py b/awscli/customizations/agenttoolkit/update_skill.py index bfb3bf8a7791..3c73fcd7bf37 100644 --- a/awscli/customizations/agenttoolkit/update_skill.py +++ b/awscli/customizations/agenttoolkit/update_skill.py @@ -17,6 +17,8 @@ from awscli.customizations.agenttoolkit.utils import ( AGENT_ARG, SKILL_NAME_ARG, + agents_with_skill, + collect_installed_skills, create_client, get_skill_download, install_skill, @@ -27,18 +29,33 @@ from awscli.customizations.commands import BasicCommand from awscli.customizations.exceptions import ParamValidationError +UPDATE_SKILL_NAME_ARG = {**SKILL_NAME_ARG, 'required': False} + +ALL_SKILLS_ARG = { + 'name': 'all', + 'help_text': ( + 'Update every installed AWS skill that is out of date. Cannot be ' + 'combined with ``--skill-name``.' + ), + 'action': 'store_true', + 'required': False, +} + class UpdateSkillCommand(BasicCommand): NAME = 'update-skill' DESCRIPTION = ( - 'Update an installed AWS skill to the latest version. ' + 'Update installed AWS skills to the latest version. ' 'Compares the locally installed version against the available skills ' - 'and downloads the newer version if available. By default the skill is ' - 'updated for all detected agents, use ``--agent`` to update the skill ' + 'and downloads the newer version if available. Pass ``--skill-name`` ' + 'to update a single skill or ``--all`` to update every installed ' + 'skill that is out of date. By default skills are ' + 'updated for all detected agents, use ``--agent`` to update ' 'for only a specific tool.' ) ARG_TABLE = [ - SKILL_NAME_ARG, + UPDATE_SKILL_NAME_ARG, + ALL_SKILLS_ARG, AGENT_ARG, ] @@ -54,41 +71,92 @@ def __init__(self, session, stream=None, client=None, agent_configs=None): def _run_main(self, parsed_args, parsed_globals): skill_name = parsed_args.skill_name + update_all = getattr(parsed_args, 'all', False) agent_filter = getattr(parsed_args, 'agent', None) + if update_all and skill_name: + raise ParamValidationError( + 'Cannot use --skill-name together with --all.' + ) + if not update_all and not skill_name: + raise ParamValidationError( + 'Either --skill-name or --all is required.' + ) + agents = resolve_agents(agent_filter, self._agent_configs) if not agents: raise ParamValidationError('No supported AI coding agents found.') - installed_agents = [ - agent - for agent in agents - if any( - skill.name == skill_name - for skill in agent.get_installed_skills() - ) - ] + if update_all: + return self._update_all_skills(agents, parsed_globals) + return self._update_one_skill(agents, parsed_globals, skill_name) + + def _create_client(self, parsed_globals): + return self._client or create_client(self._session, parsed_globals) + + def _update_one_skill(self, agents, parsed_globals, skill_name): + installed_agents = agents_with_skill(agents, skill_name) if not installed_agents: raise ParamValidationError( f'Skill "{skill_name}" is not installed.' ) - client = self._client or create_client(self._session, parsed_globals) - remote_version = resolve_latest_version(client, skill_name) + # Build the client only once we know there is something to update, so + # local failures are not masked by endpoint or credential errors. + client = self._create_client(parsed_globals) + remote_version, outdated = self._find_outdated( + installed_agents, client, skill_name + ) + if not outdated: + self._stream.write( + f'{skill_name} is already up to date ({remote_version}).\n' + ) + return 0 + + self._install_version(client, skill_name, remote_version, outdated) + return 0 + + def _update_all_skills(self, agents, parsed_globals): + installed_skills = collect_installed_skills(agents) + if not installed_skills: + self._stream.write('No installed AWS skills found.\n') + return 0 + + # Group by name from the scan above. Asking each agent which skills it + # has once per skill name would rescan every skills directory for every + # skill. + agents_by_skill = {} + for skill in installed_skills: + agents_by_skill.setdefault(skill.name, []).append(skill.agent) + + client = self._create_client(parsed_globals) + updated_any = False + for skill_name in sorted(agents_by_skill): + remote_version, outdated = self._find_outdated( + agents_by_skill[skill_name], client, skill_name + ) + if not outdated: + continue + self._install_version(client, skill_name, remote_version, outdated) + updated_any = True + + if not updated_any: + self._stream.write( + 'All installed AWS skills are already up to date.\n' + ) + return 0 + def _find_outdated(self, installed_agents, client, skill_name): + remote_version = resolve_latest_version(client, skill_name) outdated = [] for agent in installed_agents: skill_dir = os.path.join(agent.skills_path, skill_name) local_version = read_installed_version(skill_dir) if local_version != remote_version: outdated.append(agent) + return remote_version, outdated - if not outdated: - self._stream.write( - f'{skill_name} is already up to date ({remote_version}).\n' - ) - return 0 - + def _install_version(self, client, skill_name, remote_version, agents): zip_bytes, checksum, version = get_skill_download( client, skill_name, version=remote_version ) @@ -97,9 +165,8 @@ def _run_main(self, parsed_args, parsed_globals): version, zip_bytes, checksum, - outdated, + agents, self._stream, action='Updated', overwrite_existing=True, ) - return 0 diff --git a/awscli/customizations/agenttoolkit/utils.py b/awscli/customizations/agenttoolkit/utils.py index 8113967f7def..014f3669bfe1 100644 --- a/awscli/customizations/agenttoolkit/utils.py +++ b/awscli/customizations/agenttoolkit/utils.py @@ -103,6 +103,29 @@ def get_skill_download(client, skill_name, version=None): return zip_bytes, checksum, version +def collect_installed_skills(agents): + seen_paths = set() + skills = [] + for agent in universal_first(agents): + for skill in agent.get_installed_skills(): + real_path = os.path.realpath(skill.path) + if real_path in seen_paths: + continue + seen_paths.add(real_path) + skills.append(skill) + return skills + + +def agents_with_skill(agents, skill_name): + return [ + agent + for agent in agents + if any( + skill.name == skill_name for skill in agent.get_installed_skills() + ) + ] + + def read_installed_version(skill_dir): metadata = read_skill_metadata(skill_dir) if metadata is None: @@ -114,7 +137,7 @@ def read_skill_metadata(skill_dir): path = os.path.join(skill_dir, SKILL_METADATA_FILENAME) try: with open(path) as f: - return json.load(f) + metadata = json.load(f) except FileNotFoundError: return None except json.JSONDecodeError as e: @@ -126,6 +149,15 @@ def read_skill_metadata(skill_dir): e, ) return None + if not isinstance(metadata, dict): + # Valid JSON, but not an object, so there are no fields to read. + LOG.debug( + 'Ignoring skill metadata at %s: expected a JSON object, got %s.', + path, + type(metadata).__name__, + ) + return None + return metadata def write_skill_metadata(skill_dir, version): diff --git a/awscli/examples/agenttoolkit/check-updates.rst b/awscli/examples/agenttoolkit/check-updates.rst new file mode 100644 index 000000000000..37706322d2d1 --- /dev/null +++ b/awscli/examples/agenttoolkit/check-updates.rst @@ -0,0 +1,72 @@ +**Example 1: To check installed skills for updates** + +The following ``check-updates`` example reports the installed and latest available version of each AWS skill installed on detected agents. :: + + aws agent-toolkit check-updates + +Output:: + + { + "skills": [ + { + "agent": "Kiro", + "name": "aws-serverless", + "path": "/Users/username/.kiro/skills/aws-serverless/SKILL.md", + "installedVersion": "v1", + "latestVersion": "v2", + "updateAvailable": true + }, + { + "agent": "Kiro", + "name": "aws-cloudformation", + "path": "/Users/username/.kiro/skills/aws-cloudformation/SKILL.md", + "installedVersion": "v2", + "latestVersion": "v2", + "updateAvailable": false + } + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To check skills for a specific agent** + +The following ``check-updates`` example checks only the skills installed for Kiro. :: + + aws agent-toolkit check-updates \ + --agent kiro + +Output:: + + { + "skills": [ + { + "agent": "Kiro", + "name": "aws-serverless", + "path": "/Users/username/.kiro/skills/aws-serverless/SKILL.md", + "installedVersion": "v1", + "latestVersion": "v2", + "updateAvailable": true + } + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 3: To list only the skills that have an update available** + +The following ``check-updates`` example uses ``--query`` to return just the skills that are out of date. :: + + aws agent-toolkit check-updates \ + --query 'skills[?updateAvailable].[name,installedVersion,latestVersion]' \ + --output table + +Output:: + + -------------------------------- + | check-updates | + +-----------------+-----+------+ + | aws-serverless | v1 | v2 | + +-----------------+-----+------+ + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/update-skill.rst b/awscli/examples/agenttoolkit/update-skill.rst index ec34c18cbb72..25d792314649 100644 --- a/awscli/examples/agenttoolkit/update-skill.rst +++ b/awscli/examples/agenttoolkit/update-skill.rst @@ -37,3 +37,44 @@ Output:: aws-serverless is already up to date (v2). For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 4: To update every installed skill** + +The following ``update-skill`` example updates all installed skills that are out of date. Skills that are already at the latest version are left alone. :: + + aws agent-toolkit update-skill \ + --all + +Output:: + + Updated aws-cloudformation (v2) to Kiro. + Updated aws-serverless (v2) to Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 5: To update every installed skill for a specific agent** + +The following ``update-skill`` example updates all out-of-date skills, but only for Kiro. :: + + aws agent-toolkit update-skill \ + --all \ + --agent kiro + +Output:: + + Updated aws-serverless (v2) to Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 6: When all installed skills are up to date** + +The following ``update-skill`` example shows the output when every installed skill is already at the latest version. :: + + aws agent-toolkit update-skill \ + --all + +Output:: + + All installed AWS skills are already up to date. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/tests/unit/customizations/agenttoolkit/test_check_updates.py b/tests/unit/customizations/agenttoolkit/test_check_updates.py new file mode 100644 index 000000000000..b2576b5e465d --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_check_updates.py @@ -0,0 +1,247 @@ +# 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 io import StringIO +from unittest.mock import Mock, patch + +import pytest + +from awscli.customizations.agenttoolkit.agents import ( + SKILL_METADATA_FILENAME, +) +from awscli.customizations.agenttoolkit.check_updates import ( + CheckUpdatesCommand, +) +from awscli.customizations.exceptions import ParamValidationError +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_parsed_globals, + make_session, + make_skill, +) + + +def _run_check(monkeypatch, agent_configs, args=None, latest_versions=None): + """Run check-updates and return (parsed output, latest version mock).""" + if latest_versions is None: + latest_versions = {} + stream = StringIO() + monkeypatch.setattr('sys.stdout', stream) + + mock_client = Mock() + resolve_latest = Mock( + side_effect=lambda _client, name: latest_versions[name] + ) + with ( + patch( + 'awscli.customizations.agenttoolkit.check_updates.' + 'resolve_latest_version', + resolve_latest, + ), + patch( + 'awscli.customizations.agenttoolkit.check_updates.create_client', + return_value=mock_client, + ) as create, + ): + cmd = CheckUpdatesCommand(make_session(), agent_configs=agent_configs) + rc = cmd(args=args or [], parsed_globals=make_parsed_globals()) + assert rc == 0 + return json.loads(stream.getvalue()), resolve_latest, create + + +def test_check_updates_no_agents(monkeypatch): + result, resolve_latest, create = _run_check(monkeypatch, []) + assert result == {'skills': []} + # Nothing installed means there is nothing to look up, so we should not + # even build a client. + assert create.call_count == 0 + assert resolve_latest.call_count == 0 + + +def test_check_updates_no_skills_installed(tmp_path, monkeypatch): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + result, resolve_latest, create = _run_check( + monkeypatch, [make_config(tmp_path)] + ) + assert result == {'skills': []} + assert create.call_count == 0 + assert resolve_latest.call_count == 0 + + +def test_check_updates_reports_available_update(tmp_path, monkeypatch): + make_skill(tmp_path, '.test-agent', 'aws-s3') + skill_path = str( + tmp_path / '.test-agent' / 'skills' / 'aws-s3' / 'SKILL.md' + ) + result, _, _ = _run_check( + monkeypatch, + [make_config(tmp_path)], + latest_versions={'aws-s3': 'v2'}, + ) + assert result == { + 'skills': [ + { + 'agent': 'Test Agent', + 'name': 'aws-s3', + 'path': skill_path, + 'installedVersion': 'v1', + 'latestVersion': 'v2', + 'updateAvailable': True, + } + ] + } + + +def test_check_updates_reports_up_to_date(tmp_path, monkeypatch): + make_skill(tmp_path, '.test-agent', 'aws-s3') + result, _, _ = _run_check( + monkeypatch, + [make_config(tmp_path)], + latest_versions={'aws-s3': 'v1'}, + ) + assert result['skills'][0]['updateAvailable'] is False + assert result['skills'][0]['installedVersion'] == 'v1' + assert result['skills'][0]['latestVersion'] == 'v1' + + +@pytest.mark.parametrize( + 'marker_contents', + [ + '{ not valid json', + '', + 'null', + # Valid JSON, but not an object, so there is no version field to read. + '[]', + '["v1"]', + '"v1"', + '42', + ], +) +def test_check_updates_unreadable_metadata_reports_null_version( + tmp_path, monkeypatch, marker_contents +): + make_skill(tmp_path, '.test-agent', 'aws-s3') + marker = ( + tmp_path + / '.test-agent' + / 'skills' + / 'aws-s3' + / SKILL_METADATA_FILENAME + ) + marker.write_text(marker_contents) + result, _, _ = _run_check( + monkeypatch, + [make_config(tmp_path)], + latest_versions={'aws-s3': 'v1'}, + ) + assert result['skills'][0]['installedVersion'] is None + assert result['skills'][0]['updateAvailable'] is True + + +def test_check_updates_looks_up_each_skill_once(tmp_path, monkeypatch): + # The same skill installed for two agents should still only cost one + # lookup, and each distinct skill should cost exactly one. + make_skill(tmp_path, '.agent-a', 'aws-s3') + make_skill(tmp_path, '.agent-b', 'aws-s3') + make_skill(tmp_path, '.agent-a', 'aws-lambda') + configs = [ + make_config( + tmp_path, + id='agent-a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + make_config( + tmp_path, + id='agent-b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + ), + ] + result, resolve_latest, _ = _run_check( + monkeypatch, + configs, + latest_versions={'aws-s3': 'v2', 'aws-lambda': 'v1'}, + ) + assert len(result['skills']) == 3 + assert resolve_latest.call_count == 2 + + +def test_check_updates_with_agent_filter(tmp_path, monkeypatch): + make_skill(tmp_path, '.agent-a', 'aws-s3') + make_skill(tmp_path, '.agent-b', 'aws-s3') + configs = [ + make_config( + tmp_path, + id='agent-a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + make_config( + tmp_path, + id='agent-b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + ), + ] + result, _, _ = _run_check( + monkeypatch, + configs, + args=['--agent', 'agent-a'], + latest_versions={'aws-s3': 'v1'}, + ) + assert [s['agent'] for s in result['skills']] == ['Agent A'] + + +def test_check_updates_shared_skills_dir_reported_once(tmp_path, monkeypatch): + # Agents that point at the shared universal skills directory must not + # produce a duplicate row for the same install. + (tmp_path / '.codex').mkdir() + universal_base = tmp_path / '.agents' + shared = universal_base / 'skills' + skill_dir = shared / 'aws-cdk' + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text('test') + (skill_dir / SKILL_METADATA_FILENAME).write_text( + json.dumps({'version': 'v1'}) + ) + configs = [ + make_config( + tmp_path, + id='codex', + display_name='Codex', + detection_path=str(tmp_path / '.codex'), + skills_path_override=str(shared), + ), + make_config( + tmp_path, + id='universal', + display_name='Universal (Codex)', + detection_path=str(universal_base), + ), + ] + result, resolve_latest, _ = _run_check( + monkeypatch, configs, latest_versions={'aws-cdk': 'v2'} + ) + assert len(result['skills']) == 1 + assert result['skills'][0]['agent'] == 'Universal (Codex)' + assert resolve_latest.call_count == 1 + + +def test_check_updates_invalid_agent(tmp_path, monkeypatch): + with pytest.raises(ParamValidationError, match='Invalid agent'): + _run_check( + monkeypatch, + [make_config(tmp_path)], + args=['--agent', 'nonexistent'], + ) diff --git a/tests/unit/customizations/agenttoolkit/test_update_skill.py b/tests/unit/customizations/agenttoolkit/test_update_skill.py index fbdc1ca1fec8..fbab46475210 100644 --- a/tests/unit/customizations/agenttoolkit/test_update_skill.py +++ b/tests/unit/customizations/agenttoolkit/test_update_skill.py @@ -71,6 +71,235 @@ def _run_update(agent_configs, args, remote_version='v2', zip_bytes=None): return rc, stream.getvalue() +def _run_update_all(agent_configs, args, remote_versions, call_counts=None): + """Run update-skill with per-skill remote versions.""" + zip_bytes, checksum = make_skill_zip({'SKILL.md': 'new'}) + mock_client = Mock() + mock_client.meta.endpoint_url = 'https://example.com' + stream = StringIO() + + resolve_latest = Mock( + side_effect=lambda _client, name: remote_versions[name] + ) + download = Mock( + side_effect=lambda _client, name, version=None: ( + zip_bytes, + checksum, + version, + ) + ) + with ( + patch( + 'awscli.customizations.agenttoolkit.update_skill.resolve_latest_version', + resolve_latest, + ), + patch( + 'awscli.customizations.agenttoolkit.update_skill.get_skill_download', + download, + ), + patch( + 'awscli.customizations.agenttoolkit.update_skill.create_client', + return_value=mock_client, + ), + ): + cmd = UpdateSkillCommand( + make_session(), stream=stream, agent_configs=agent_configs + ) + rc = cmd(args=args, parsed_globals=Mock()) + if call_counts is not None: + call_counts['resolve_latest_version'] = resolve_latest.call_count + call_counts['get_skill_download'] = download.call_count + return rc, stream.getvalue() + + +def test_update_all_fetches_each_skill_once_for_all_agents(tmp_path): + # Two skills installed for three agents is six installs, but each skill + # should only be looked up and downloaded once and then written to every + # outdated agent. + agent_names = ['.agent-a', '.agent-b', '.agent-c'] + for agent_dir in agent_names: + for skill in ['aws-s3', 'aws-lambda']: + _install_skill_at_version(tmp_path, agent_dir, skill, 'v1') + configs = [ + make_config( + tmp_path, + id=agent_dir.lstrip('.'), + display_name=f'Agent {agent_dir}', + detection_path=str(tmp_path / agent_dir), + ) + for agent_dir in agent_names + ] + counts = {} + rc, _ = _run_update_all( + configs, + ['--all'], + remote_versions={'aws-s3': 'v2', 'aws-lambda': 'v2'}, + call_counts=counts, + ) + assert rc == 0 + assert counts == { + 'resolve_latest_version': 2, + 'get_skill_download': 2, + } + # All six installs were written from those two downloads. + for agent_dir in agent_names: + for skill in ['aws-s3', 'aws-lambda']: + marker = ( + tmp_path + / agent_dir + / 'skills' + / skill + / SKILL_METADATA_FILENAME + ) + assert json.loads(marker.read_text()) == {'version': 'v2'} + + +def test_update_skill_requires_skill_name_or_all(tmp_path): + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='Either --skill-name'): + _run_update(configs, []) + + +def test_update_skill_rejects_skill_name_with_all(tmp_path): + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='Cannot use --skill-name'): + _run_update(configs, ['--all', '--skill-name', 'aws-s3']) + + +def test_update_all_no_client_when_nothing_installed(tmp_path): + # Creating a client can fail on its own (no region, bad credentials), so + # local state must be checked first. + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + with patch( + 'awscli.customizations.agenttoolkit.update_skill.create_client' + ) as create: + cmd = UpdateSkillCommand( + make_session(), + stream=StringIO(), + agent_configs=[make_config(tmp_path)], + ) + rc = cmd(args=['--all'], parsed_globals=Mock()) + assert rc == 0 + assert create.call_count == 0 + + +def test_update_one_no_client_when_skill_not_installed(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + with patch( + 'awscli.customizations.agenttoolkit.update_skill.create_client' + ) as create: + cmd = UpdateSkillCommand( + make_session(), + stream=StringIO(), + agent_configs=[make_config(tmp_path)], + ) + with pytest.raises(ParamValidationError, match='not installed'): + cmd(args=['--skill-name', 'aws-s3'], parsed_globals=Mock()) + assert create.call_count == 0 + + +def test_update_all_updates_only_outdated_skills(tmp_path): + _install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1') + _install_skill_at_version(tmp_path, '.test-agent', 'aws-lambda', 'v3') + configs = [make_config(tmp_path)] + rc, output = _run_update_all( + configs, + ['--all'], + remote_versions={'aws-s3': 'v2', 'aws-lambda': 'v3'}, + ) + assert rc == 0 + assert 'Updated aws-s3 (v2)' in output + assert 'aws-lambda' not in output + s3_marker = ( + tmp_path + / '.test-agent' + / 'skills' + / 'aws-s3' + / SKILL_METADATA_FILENAME + ) + assert json.loads(s3_marker.read_text()) == {'version': 'v2'} + + +def test_update_all_when_everything_current(tmp_path): + _install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1') + configs = [make_config(tmp_path)] + rc, output = _run_update_all( + configs, ['--all'], remote_versions={'aws-s3': 'v1'} + ) + assert rc == 0 + assert output == 'All installed AWS skills are already up to date.\n' + + +def test_update_all_with_no_skills_installed(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + rc, output = _run_update_all(configs, ['--all'], remote_versions={}) + assert rc == 0 + assert output == 'No installed AWS skills found.\n' + + +def test_update_all_skips_agents_without_the_skill(tmp_path): + # aws-s3 is only installed for Agent A. Updating everything must not + # create the skill for Agent B, which never had it. + _install_skill_at_version(tmp_path, '.agent-a', 'aws-s3', 'v1') + _install_skill_at_version(tmp_path, '.agent-b', 'aws-lambda', 'v1') + configs = [ + make_config( + tmp_path, + id='agent-a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + make_config( + tmp_path, + id='agent-b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + ), + ] + rc, output = _run_update_all( + configs, + ['--all'], + remote_versions={'aws-s3': 'v2', 'aws-lambda': 'v2'}, + ) + assert rc == 0 + assert 'Updated aws-s3 (v2) to Agent A' in output + assert 'Updated aws-lambda (v2) to Agent B' in output + assert not (tmp_path / '.agent-b' / 'skills' / 'aws-s3').exists() + assert not (tmp_path / '.agent-a' / 'skills' / 'aws-lambda').exists() + + +def test_update_all_with_agent_filter(tmp_path): + _install_skill_at_version(tmp_path, '.agent-a', 'aws-s3', 'v1') + _install_skill_at_version(tmp_path, '.agent-b', 'aws-s3', 'v1') + configs = [ + make_config( + tmp_path, + id='agent-a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + make_config( + tmp_path, + id='agent-b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + ), + ] + rc, output = _run_update_all( + configs, + ['--all', '--agent', 'agent-a'], + remote_versions={'aws-s3': 'v2'}, + ) + assert rc == 0 + assert 'Agent A' in output + assert 'Agent B' not in output + b_marker = ( + tmp_path / '.agent-b' / 'skills' / 'aws-s3' / SKILL_METADATA_FILENAME + ) + assert json.loads(b_marker.read_text()) == {'version': 'v1'} + + def test_update_skill_outdated(tmp_path): _install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1') configs = [make_config(tmp_path)]