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-agenttoolkit-26195.json
Original file line number Diff line number Diff line change
@@ -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."
}
4 changes: 4 additions & 0 deletions awscli/customizations/agenttoolkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
99 changes: 99 additions & 0 deletions awscli/customizations/agenttoolkit/check_updates.py
Original file line number Diff line number Diff line change
@@ -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
111 changes: 89 additions & 22 deletions awscli/customizations/agenttoolkit/update_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
]

Expand All @@ -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
)
Expand All @@ -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
34 changes: 33 additions & 1 deletion awscli/customizations/agenttoolkit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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):
Expand Down
Loading
Loading