diff --git a/.changes/next-release/feature-configure-32033.json b/.changes/next-release/feature-configure-32033.json new file mode 100644 index 000000000000..39cc7f040243 --- /dev/null +++ b/.changes/next-release/feature-configure-32033.json @@ -0,0 +1,5 @@ +{ + "type": "feature", + "category": "``configure``", + "description": "Adds the ``agent-toolkit`` and ``aws configure agent-toolkit`` commands, which can be used for configuring your agentic tools with AWS skills and MCP server." +} diff --git a/awscli/autocomplete/local/indexer.py b/awscli/autocomplete/local/indexer.py index 0649189f75d0..21183ea43a40 100644 --- a/awscli/autocomplete/local/indexer.py +++ b/awscli/autocomplete/local/indexer.py @@ -25,7 +25,14 @@ class ModelIndexer: 'ddb': 'High level DynamoDB commands', } - _NON_SERVICE_COMMANDS = ['configure', 'history', 'cli-dev', 'login', 'logout'] + _NON_SERVICE_COMMANDS = [ + 'configure', + 'history', + 'cli-dev', + 'login', + 'logout', + 'agent-toolkit', + ] _CREATE_CMD_TABLE = """\ CREATE TABLE IF NOT EXISTS command_table ( diff --git a/awscli/botocore/data/agenttoolkit/2026-04-22/endpoint-rule-set-1.json b/awscli/botocore/data/agenttoolkit/2026-04-22/endpoint-rule-set-1.json new file mode 100644 index 000000000000..bdda3514ea2a --- /dev/null +++ b/awscli/botocore/data/agenttoolkit/2026-04-22/endpoint-rule-set-1.json @@ -0,0 +1,60 @@ +{ + "version": "1.0", + "parameters": { + "Endpoint": { + "type": "String", + "builtIn": "SDK::Endpoint", + "required": false, + "documentation": "Override the endpoint URL" + }, + "Region": { + "required": true, + "type": "String", + "builtIn": "AWS::Region", + "documentation": "AWS region" + } + }, + "rules": [ + { + "documentation": "Use custom endpoint if provided", + "type": "endpoint", + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Endpoint" + } + ] + } + ], + "endpoint": { + "url": "{Endpoint}" + } + }, + { + "documentation": "Default endpoint for us-east-1", + "type": "endpoint", + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + { + "ref": "Region" + }, + "us-east-1" + ] + } + ], + "endpoint": { + "url": "https://agent-toolkit.us-east-1.api.aws" + } + }, + { + "documentation": "Error for unsupported regions", + "type": "error", + "conditions": [], + "error": "AgentToolkit is only available in us-east-1" + } + ] +} \ No newline at end of file diff --git a/awscli/botocore/data/agenttoolkit/2026-04-22/paginators-1.json b/awscli/botocore/data/agenttoolkit/2026-04-22/paginators-1.json new file mode 100644 index 000000000000..5cdc7e8cb728 --- /dev/null +++ b/awscli/botocore/data/agenttoolkit/2026-04-22/paginators-1.json @@ -0,0 +1,16 @@ +{ + "pagination": { + "ListSkills": { + "input_token": "nextToken", + "output_token": "nextToken", + "limit_key": "maxResults", + "result_key": "skills" + }, + "SearchSkills": { + "input_token": "nextToken", + "output_token": "nextToken", + "limit_key": "maxResults", + "result_key": "skills" + } + } +} diff --git a/awscli/botocore/data/agenttoolkit/2026-04-22/service-2.json b/awscli/botocore/data/agenttoolkit/2026-04-22/service-2.json new file mode 100644 index 000000000000..dbd209b9521b --- /dev/null +++ b/awscli/botocore/data/agenttoolkit/2026-04-22/service-2.json @@ -0,0 +1,540 @@ +{ + "version":"2.0", + "metadata":{ + "apiVersion":"2026-04-22", + "auth":["aws.auth#sigv4"], + "endpointPrefix":"agenttoolkit", + "protocol":"rest-json", + "protocols":["rest-json"], + "serviceFullName":"AgentToolkit", + "serviceId":"AgentToolkit", + "signatureVersion":"v4", + "signingName":"execute-api", + "uid":"agenttoolkit-2026-04-22" + }, + "operations":{ + "GetLatestSkillVersion":{ + "name":"GetLatestSkillVersion", + "http":{ + "method":"GET", + "requestUri":"/skills/{name}/latest", + "responseCode":200 + }, + "input":{"shape":"GetLatestSkillVersionInput"}, + "output":{"shape":"GetLatestSkillVersionOutput"}, + "documentation":"

Returns the latest version identifier for a skill as a plain-text file. Response Content-Type: text/plain. The body contains the version string (e.g. "v1"), optionally followed by a trailing newline.

", + "auth":["smithy.api#noAuth"], + "readonly":true, + "authtype":"none" + }, + "GetSkill":{ + "name":"GetSkill", + "http":{ + "method":"GET", + "requestUri":"/api/skills/{name}", + "responseCode":200 + }, + "input":{"shape":"GetSkillInput"}, + "output":{"shape":"GetSkillOutput"}, + "errors":[ + {"shape":"ThrottlingException"}, + {"shape":"NotFoundException"}, + {"shape":"InternalServerException"}, + {"shape":"ValidationException"} + ], + "documentation":"

Retrieves metadata for a skill, including its version, description, categories, and file list. Use --skill-version to get the metadata for a specific version of the skill.

", + "auth":["smithy.api#noAuth"], + "readonly":true, + "authtype":"none" + }, + "GetSkillFile":{ + "name":"GetSkillFile", + "http":{ + "method":"GET", + "requestUri":"/skills/{name}/versions/{skillVersion}/files/{filePath+}", + "responseCode":200 + }, + "input":{"shape":"GetSkillFileInput"}, + "output":{"shape":"GetSkillFileOutput"}, + "errors":[ + {"shape":"ThrottlingException"}, + {"shape":"NotFoundException"}, + {"shape":"InternalServerException"}, + {"shape":"ValidationException"} + ], + "documentation":"

Returns a specific file from a skill version. Content-Type varies by file extension. The {skillVersion} label accepts either "latest" or a versioned tag like "v1".

", + "auth":["smithy.api#noAuth"], + "readonly":true, + "authtype":"none" + }, + "GetSkillFileChecksum":{ + "name":"GetSkillFileChecksum", + "http":{ + "method":"GET", + "requestUri":"/skills/{name}/versions/{skillVersion}/files/{filePath+}/checksum", + "responseCode":200 + }, + "input":{"shape":"GetSkillFileChecksumInput"}, + "output":{"shape":"GetSkillFileChecksumOutput"}, + "errors":[ + {"shape":"ThrottlingException"}, + {"shape":"NotFoundException"}, + {"shape":"InternalServerException"}, + {"shape":"ValidationException"} + ], + "documentation":"

Returns the SHA-256 hex checksum of a specific file from a skill version. Response Content-Type: text/plain. The {skillVersion} label accepts either "latest" or a versioned tag like "v1".

", + "auth":["smithy.api#noAuth"], + "readonly":true, + "authtype":"none" + }, + "ListSkills":{ + "name":"ListSkills", + "http":{ + "method":"GET", + "requestUri":"/api/skills", + "responseCode":200 + }, + "input":{"shape":"ListSkillsInput"}, + "output":{"shape":"ListSkillsOutput"}, + "errors":[ + {"shape":"ThrottlingException"}, + {"shape":"InternalServerException"}, + {"shape":"ValidationException"} + ], + "documentation":"

Lists skills available in the remote catalog. Use --category-filter to filter by a single category.

", + "auth":["smithy.api#noAuth"], + "readonly":true, + "authtype":"none" + }, + "SearchSkills":{ + "name":"SearchSkills", + "http":{ + "method":"GET", + "requestUri":"/api/search/skills", + "responseCode":200 + }, + "input":{"shape":"SearchSkillsInput"}, + "output":{"shape":"SearchSkillsOutput"}, + "errors":[ + {"shape":"ThrottlingException"}, + {"shape":"InternalServerException"}, + {"shape":"ValidationException"} + ], + "documentation":"

Searches the remote catalog for skills matching a query.

", + "auth":["smithy.api#noAuth"], + "readonly":true, + "authtype":"none" + } + }, + "shapes":{ + "CategoryList":{ + "type":"list", + "member":{"shape":"String"} + }, + "File":{ + "type":"structure", + "members":{ + "path":{"shape":"String"} + } + }, + "FileList":{ + "type":"list", + "member":{"shape":"File"} + }, + "GetLatestSkillVersionInput":{ + "type":"structure", + "required":["name"], + "members":{ + "name":{ + "shape":"GetLatestSkillVersionInputNameString", + "location":"uri", + "locationName":"name" + } + } + }, + "GetLatestSkillVersionInputNameString":{ + "type":"string", + "max":64, + "min":1, + "pattern":"[a-zA-Z0-9\\-]+" + }, + "GetLatestSkillVersionOutput":{ + "type":"structure", + "required":["body"], + "members":{ + "contentType":{ + "shape":"String", + "location":"header", + "locationName":"Content-Type" + }, + "eTag":{ + "shape":"String", + "location":"header", + "locationName":"ETag" + }, + "cacheControl":{ + "shape":"String", + "location":"header", + "locationName":"Cache-Control" + }, + "lastModified":{ + "shape":"Timestamp", + "location":"header", + "locationName":"Last-Modified" + }, + "body":{ + "shape":"String", + "documentation":"

The latest version identifier as plain text (e.g. "v1", "v2"), optionally with a trailing newline.

" + } + }, + "documentation":"

Common HTTP response headers for static-asset responses

", + "payload":"body" + }, + "GetSkillFileChecksumInput":{ + "type":"structure", + "required":[ + "name", + "skillVersion", + "filePath" + ], + "members":{ + "name":{ + "shape":"GetSkillFileChecksumInputNameString", + "location":"uri", + "locationName":"name" + }, + "skillVersion":{ + "shape":"SkillVersion", + "location":"uri", + "locationName":"skillVersion" + }, + "filePath":{ + "shape":"GetSkillFileChecksumInputFilePathString", + "location":"uri", + "locationName":"filePath" + } + } + }, + "GetSkillFileChecksumInputFilePathString":{ + "type":"string", + "max":1024, + "min":1, + "pattern":"[A-Za-z0-9._/\\-]+" + }, + "GetSkillFileChecksumInputNameString":{ + "type":"string", + "max":64, + "min":1, + "pattern":"[a-zA-Z0-9\\-]+" + }, + "GetSkillFileChecksumOutput":{ + "type":"structure", + "required":["body"], + "members":{ + "contentType":{ + "shape":"String", + "location":"header", + "locationName":"Content-Type" + }, + "eTag":{ + "shape":"String", + "location":"header", + "locationName":"ETag" + }, + "cacheControl":{ + "shape":"String", + "location":"header", + "locationName":"Cache-Control" + }, + "lastModified":{ + "shape":"Timestamp", + "location":"header", + "locationName":"Last-Modified" + }, + "body":{"shape":"String"} + }, + "documentation":"

Common HTTP response headers for static-asset responses

", + "payload":"body" + }, + "GetSkillFileInput":{ + "type":"structure", + "required":[ + "name", + "skillVersion", + "filePath" + ], + "members":{ + "name":{ + "shape":"GetSkillFileInputNameString", + "location":"uri", + "locationName":"name" + }, + "skillVersion":{ + "shape":"SkillVersion", + "location":"uri", + "locationName":"skillVersion" + }, + "filePath":{ + "shape":"GetSkillFileInputFilePathString", + "location":"uri", + "locationName":"filePath" + } + } + }, + "GetSkillFileInputFilePathString":{ + "type":"string", + "max":1024, + "min":1, + "pattern":"[A-Za-z0-9._/\\-]+" + }, + "GetSkillFileInputNameString":{ + "type":"string", + "max":64, + "min":1, + "pattern":"[a-zA-Z0-9\\-]+" + }, + "GetSkillFileOutput":{ + "type":"structure", + "required":["body"], + "members":{ + "contentType":{ + "shape":"String", + "location":"header", + "locationName":"Content-Type" + }, + "eTag":{ + "shape":"String", + "location":"header", + "locationName":"ETag" + }, + "cacheControl":{ + "shape":"String", + "location":"header", + "locationName":"Cache-Control" + }, + "lastModified":{ + "shape":"Timestamp", + "location":"header", + "locationName":"Last-Modified" + }, + "body":{"shape":"SkillFileBlob"} + }, + "documentation":"

Common HTTP response headers for static-asset responses

", + "payload":"body" + }, + "GetSkillInput":{ + "type":"structure", + "required":["name"], + "members":{ + "name":{ + "shape":"GetSkillInputNameString", + "location":"uri", + "locationName":"name" + }, + "skillVersion":{ + "shape":"GetSkillInputSkillVersionString", + "location":"querystring", + "locationName":"skill_version" + } + } + }, + "GetSkillInputNameString":{ + "type":"string", + "max":64, + "min":1, + "pattern":"[a-zA-Z0-9\\-]+" + }, + "GetSkillInputSkillVersionString":{ + "type":"string", + "pattern":"v[0-9]+" + }, + "GetSkillOutput":{ + "type":"structure", + "required":[ + "name", + "skillVersion", + "description", + "categories", + "files" + ], + "members":{ + "name":{"shape":"String"}, + "skillVersion":{"shape":"String"}, + "description":{"shape":"String"}, + "categories":{"shape":"CategoryList"}, + "files":{"shape":"FileList"} + } + }, + "InternalServerException":{ + "type":"structure", + "required":["message"], + "members":{ + "message":{"shape":"String"} + }, + "error":{"httpStatusCode":500}, + "exception":true, + "fault":true + }, + "ListSkillsInput":{ + "type":"structure", + "members":{ + "categoryFilter":{ + "shape":"ListSkillsInputCategoryFilterString", + "location":"querystring", + "locationName":"category_filter" + }, + "maxResults":{ + "shape":"ListSkillsInputMaxResultsInteger", + "location":"querystring", + "locationName":"max_results" + }, + "nextToken":{ + "shape":"String", + "location":"querystring", + "locationName":"next_token" + } + } + }, + "ListSkillsInputCategoryFilterString":{ + "type":"string", + "max":100, + "min":1, + "pattern":"[a-zA-Z0-9-_]+" + }, + "ListSkillsInputMaxResultsInteger":{ + "type":"integer", + "box":true, + "max":100, + "min":1 + }, + "ListSkillsOutput":{ + "type":"structure", + "required":["skills"], + "members":{ + "nextToken":{"shape":"String"}, + "skills":{"shape":"SkillSummaryList"} + } + }, + "NotFoundException":{ + "type":"structure", + "required":["message"], + "members":{ + "message":{"shape":"String"} + }, + "error":{ + "httpStatusCode":404, + "senderFault":true + }, + "exception":true + }, + "SearchSkillsInput":{ + "type":"structure", + "required":["query"], + "members":{ + "query":{ + "shape":"SearchSkillsInputQueryString", + "location":"querystring", + "locationName":"query" + }, + "maxResults":{ + "shape":"SearchSkillsInputMaxResultsInteger", + "location":"querystring", + "locationName":"max_results" + }, + "nextToken":{ + "shape":"String", + "location":"querystring", + "locationName":"next_token" + } + } + }, + "SearchSkillsInputMaxResultsInteger":{ + "type":"integer", + "box":true, + "max":10, + "min":1 + }, + "SearchSkillsInputQueryString":{ + "type":"string", + "max":1000, + "min":1, + "pattern":"[a-zA-Z0-9\\-_ .,;:?!'\"()/&@#+]+" + }, + "SearchSkillsOutput":{ + "type":"structure", + "required":["skills"], + "members":{ + "nextToken":{"shape":"String"}, + "skills":{"shape":"SkillSummaryList"} + } + }, + "SkillFileBlob":{ + "type":"blob", + "documentation":"

Streaming binary content for skill file or zip downloads. Content-Type is set per-response via the @httpHeader("Content-Type") member on the operation output.

", + "streaming":true + }, + "SkillSummary":{ + "type":"structure", + "required":[ + "name", + "description", + "skillVersion", + "categories" + ], + "members":{ + "name":{"shape":"SkillSummaryNameString"}, + "description":{"shape":"SkillSummaryDescriptionString"}, + "skillVersion":{"shape":"String"}, + "categories":{"shape":"CategoryList"} + } + }, + "SkillSummaryDescriptionString":{ + "type":"string", + "max":1024, + "min":1 + }, + "SkillSummaryList":{ + "type":"list", + "member":{"shape":"SkillSummary"}, + "min":0 + }, + "SkillSummaryNameString":{ + "type":"string", + "max":64, + "min":1 + }, + "SkillVersion":{ + "type":"string", + "documentation":"

A skill version identifier: either the literal "latest" or a versioned tag like "v1", "v2".

", + "pattern":"(latest|v[0-9]+)" + }, + "String":{"type":"string"}, + "ThrottlingException":{ + "type":"structure", + "required":["message"], + "members":{ + "message":{"shape":"String"} + }, + "error":{ + "httpStatusCode":429, + "senderFault":true + }, + "exception":true, + "retryable":{"throttling":true} + }, + "Timestamp":{"type":"timestamp"}, + "ValidationException":{ + "type":"structure", + "required":["message"], + "members":{ + "message":{"shape":"String"} + }, + "error":{ + "httpStatusCode":400, + "senderFault":true + }, + "exception":true + } + }, + "documentation":"

The Agent Toolkit for AWS gives AI coding agents the tools, knowledge, and guardrails they need to build, deploy, and manage applications on AWS. It works with the coding agents that developers already use — including Claude Code, Cursor, and Codex — without requiring you to switch tools or learn a new workflow. In the AWS CLI, the Agent Toolkit for AWS provides commands to set up AI coding agents with the AWS MCP and to manage installed AWS skills from the command line. It works with any AI coding agent that the Agent Toolkit detects on your system — including Kiro, Cursor, and Claude Code — and is the recommended setup path when you want to install AWS skills and configure the AWS MCP Server in a single step.

" +} diff --git a/awscli/customizations/agenttoolkit/__init__.py b/awscli/customizations/agenttoolkit/__init__.py new file mode 100644 index 000000000000..edf96cebb9dd --- /dev/null +++ b/awscli/customizations/agenttoolkit/__init__.py @@ -0,0 +1,86 @@ +# 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.add_skill import AddSkillCommand +from awscli.customizations.agenttoolkit.get_skill_file import ( + GetSkillFileCommand, +) +from awscli.customizations.agenttoolkit.list_installed_skills import ( + ListInstalledSkillsCommand, +) +from awscli.customizations.agenttoolkit.remove_skill import RemoveCommand +from awscli.customizations.agenttoolkit.update_skill import UpdateSkillCommand +from awscli.customizations.agenttoolkit.utils import ( + NONPROD_ACCESS_TOKEN_ENV_VAR, + NONPROD_ACCESS_TOKEN_HEADER, +) + +MODELED_COMMAND_ALLOWLIST = { + 'get-skill', + 'list-skills', + 'search-skills', +} + +MODELED_COMMAND_RENAMES = { + 'get-skill': 'get-skill-metadata', + 'list-skills': 'list-available-skills', +} + + +def register_agent_toolkit_commands(event_handlers): + event_handlers.register('building-command-table.main', _rename_service) + event_handlers.register( + 'building-command-table.agent-toolkit', _inject_commands + ) + event_handlers.register( + 'before-sign.agenttoolkit.*', _inject_nonprod_header + ) + + +def _inject_nonprod_header(request, **kwargs): + # Only attach the token when testing internally against the gamma endpoint + if '.gamma.agent-toolkit' not in request.url: + return + token = os.environ.get(NONPROD_ACCESS_TOKEN_ENV_VAR) + if token: + request.headers[NONPROD_ACCESS_TOKEN_HEADER] = token + + +def _rename_service(command_table, session, **kwargs): + service_cmd = command_table.pop('agenttoolkit', None) + if service_cmd is not None: + service_cmd._name = 'agent-toolkit' + command_table['agent-toolkit'] = service_cmd + + +def _inject_commands(command_table, session, **kwargs): + # Remove any modeled commands not in our allowlist, then rename the rest + for name in list(command_table): + if name not in MODELED_COMMAND_ALLOWLIST: + del command_table[name] + + for old_name, new_name in MODELED_COMMAND_RENAMES.items(): + cmd = command_table.pop(old_name, None) + if cmd is not None: + cmd._name = new_name + command_table[new_name] = cmd + + # Add custom commands + command_table['get-skill-file'] = GetSkillFileCommand(session) + command_table['add-skill'] = AddSkillCommand(session) + command_table['list-installed-skills'] = ListInstalledSkillsCommand( + session + ) + command_table['remove-skill'] = RemoveCommand(session) + command_table['update-skill'] = UpdateSkillCommand(session) diff --git a/awscli/customizations/agenttoolkit/add_skill.py b/awscli/customizations/agenttoolkit/add_skill.py new file mode 100644 index 000000000000..80047f579f3b --- /dev/null +++ b/awscli/customizations/agenttoolkit/add_skill.py @@ -0,0 +1,70 @@ +# 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 sys + +from awscli.customizations.agenttoolkit.agents import AGENT_CONFIGS +from awscli.customizations.agenttoolkit.utils import ( + AGENT_ARG, + SKILL_NAME_ARG, + SKILL_VERSION_ARG, + create_client, + get_skill_download, + install_skill, + resolve_agents, +) +from awscli.customizations.commands import BasicCommand +from awscli.customizations.exceptions import ParamValidationError + + +class AddSkillCommand(BasicCommand): + NAME = 'add-skill' + DESCRIPTION = ( + 'Download and install an AWS skill to detected AI coding agents. ' + 'By default the latest version is installed globally to all detected ' + 'agents. Use ``--agent`` to target a specific tool, or ' + '``--skill-version`` to pin a specific version.' + ) + ARG_TABLE = [ + SKILL_NAME_ARG, + SKILL_VERSION_ARG, + AGENT_ARG, + ] + + def __init__(self, session, stream=None, client=None, agent_configs=None): + super().__init__(session) + if stream is None: + stream = sys.stdout + self._stream = stream + self._client = client + if agent_configs is None: + agent_configs = AGENT_CONFIGS + self._agent_configs = agent_configs + + def _run_main(self, parsed_args, parsed_globals): + skill_name = parsed_args.skill_name + version = getattr(parsed_args, 'skill_version', None) + agent_filter = getattr(parsed_args, 'agent', None) + + agents = resolve_agents(agent_filter, self._agent_configs) + if not agents: + raise ParamValidationError('No supported AI coding agents found.') + + client = self._client or create_client(self._session, parsed_globals) + zip_bytes, checksum, version = get_skill_download( + client, skill_name, version=version + ) + + install_skill( + skill_name, version, zip_bytes, checksum, agents, self._stream + ) + return 0 diff --git a/awscli/customizations/agenttoolkit/agents.py b/awscli/customizations/agenttoolkit/agents.py new file mode 100644 index 000000000000..4c38488f531b --- /dev/null +++ b/awscli/customizations/agenttoolkit/agents.py @@ -0,0 +1,424 @@ +# 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 dataclasses +import enum +import glob +import json +import os +import shutil +import subprocess +from typing import Optional + +SKILL_FILENAME = 'SKILL.md' +SKILL_METADATA_FILENAME = '.aws-skill-metadata' +AWS_MCP_SERVER_KEY = 'aws-mcp' +UNIVERSAL_ROW_ID = 'universal' + + +def collapse_home(path): + home = os.path.expanduser('~').rstrip(os.sep) + if path == home: + return '~' + if path.startswith(home + os.sep): + return '~' + path[len(home) :] + return path + + +class McpConfigureAction(enum.Enum): + CONFIGURED = 'configured' + ALREADY_CONFIGURED = 'already_configured' + SKIPPED = 'skipped' + + +_AWS_MCP_PROXY_ARGS = [ + 'mcp-proxy-for-aws@latest', + 'https://aws-mcp.us-east-1.api.aws/mcp', + '--metadata', + 'INSTALL_SOURCE=aws-cli', +] + + +DEFAULT_MCP_SERVER_CONFIG = { + 'command': 'uvx', + 'args': _AWS_MCP_PROXY_ARGS, +} + + +@dataclasses.dataclass +class AgentConfig: + """Definition of a supported AI coding agent. + + To add a new agent, append an instance to ``AGENT_CONFIGS`` in this + module. Most agents only need ``id``, ``display_name``, and + ``detection_path``; the remaining fields cover variations across + different agents' filesystem layouts and MCP config formats. + """ + + id: str + """Stable identifier. Used as the value for ``--agent`` filters.""" + + display_name: str + """Human-readable name shown in wizard output and command results.""" + + detection_path: str + """Filesystem path probed to decide whether the agent is installed. + Tilde expansion is supported. The agent is considered detected when + this path resolves to an existing directory.""" + + detection_path_env_override: Optional[str] = None + """Environment variable name that overrides ``detection_path`` when + set (e.g. ``CLAUDE_CONFIG_DIR``).""" + + skills_dir: str = 'skills' + """Subdirectory of the resolved base directory where skills live. + Ignored when ``skills_path_override`` is set.""" + + skills_path_override: Optional[str] = None + """Absolute path to the skills directory when it is not under the + detection directory (e.g. Codex stores skills at ``~/.agents/skills/``). + Tilde expansion is supported.""" + + mcp_config_path: Optional[str] = None + """Path to the MCP config file. Relative paths are resolved against + the detection directory; tilde-prefixed paths are absolute. Leave + ``None`` for agents that don't have JSON-based MCP config.""" + + mcp_servers_key: str = 'mcpServers' + """Top-level JSON key under which MCP servers are stored. Most + agents use ``mcpServers``; OpenCode uses ``mcp``.""" + + mcp_extra_config: Optional[dict] = None + """Extra fields merged into the default MCP server entry (e.g. + Kiro requires ``timeout`` and ``transport``). Used only when + ``mcp_server_entry`` is not set.""" + + mcp_server_entry: Optional[dict] = None + """Complete MCP server entry, replacing the default schema. Use + when an agent expects a different shape than ``{command, args}`` + (e.g. OpenCode expects ``{type, command: [...]}``).""" + + mcp_shell_command: Optional[list] = None + """Argv for a CLI invocation that registers the AWS MCP server + (e.g. ``codex mcp add ...``). Used for agents whose MCP config is + not JSON. The wizard skips MCP setup if the executable is not on + PATH.""" + + def __post_init__(self): + if self.mcp_extra_config is None: + self.mcp_extra_config = {} + + @property + def _display_skills_path(self): + if self.skills_path_override: + return self.skills_path_override.rstrip('/') + base = self.detection_path.rstrip('/') + if self.skills_dir: + return f'{base}/{self.skills_dir}' + return base + + @property + def display_label(self): + return f'{self.display_name} — {self._display_skills_path}' + + def resolved_override_dir(self): + if not self.detection_path_env_override: + return None + env_value = os.environ.get(self.detection_path_env_override) + if not env_value: + return None + expanded = os.path.expanduser(env_value).rstrip(os.sep) + if not os.path.isdir(expanded): + return None + return expanded + + def resolve_base_dir(self): + override = self.resolved_override_dir() + if override is not None: + return override + expanded = os.path.expanduser(self.detection_path).rstrip(os.sep) + if os.path.isdir(expanded): + return expanded + return None + + def detect(self): + base_dir = self.resolve_base_dir() + if base_dir is None: + return None + return DetectedAgent(self, base_dir) + + +class DetectedAgent: + """An agent that was found installed on this machine.""" + + def __init__(self, config, base_dir): + self.config = config + self.base_dir = base_dir + + @property + def display_name(self): + return self.config.display_name + + @property + def display_label(self): + return f'{self.display_name} — {collapse_home(self.skills_path)}' + + @property + def skills_path(self): + if self.config.skills_path_override: + return os.path.expanduser(self.config.skills_path_override) + return os.path.join(self.base_dir, self.config.skills_dir) + + @property + def mcp_config_file(self): + if self.config.mcp_config_path is None: + return None + if self.config.mcp_config_path.startswith('~'): + override = self.config.resolved_override_dir() + if override is not None: + relative = self.config.mcp_config_path.removeprefix('~/') + return os.path.join(override, relative) + return os.path.expanduser(self.config.mcp_config_path) + return os.path.join(self.base_dir, self.config.mcp_config_path) + + def get_installed_skills(self): + pattern = os.path.join( + self.skills_path, + '*', + SKILL_FILENAME, + ) + results = [] + for skill_path in sorted(glob.glob(pattern)): + skill_dir_path = os.path.dirname(skill_path) + if not os.path.exists( + os.path.join(skill_dir_path, SKILL_METADATA_FILENAME) + ): + continue + skill_dir = os.path.basename(skill_dir_path) + results.append(InstalledSkill(self, skill_dir, skill_path)) + return results + + def configure_mcp_server(self): + """Configure the AWS MCP server entry for this agent. + + Returns a tuple of (action, detail) where action is a member of + McpConfigureAction. detail is the config file path for JSON-based + agents, the executable name for shell-based agents, or None when + no MCP setup is wired up. + """ + if self.config.mcp_shell_command is not None: + return self._configure_via_shell() + + config_path = self.mcp_config_file + if config_path is None: + return McpConfigureAction.SKIPPED, None + + existing_config = self._read_mcp_config(config_path) + servers = existing_config.setdefault(self.config.mcp_servers_key, {}) + + if AWS_MCP_SERVER_KEY in servers: + return McpConfigureAction.ALREADY_CONFIGURED, config_path + + if self.config.mcp_server_entry is not None: + server_entry = self.config.mcp_server_entry + else: + server_entry = { + **DEFAULT_MCP_SERVER_CONFIG, + **self.config.mcp_extra_config, + } + servers[AWS_MCP_SERVER_KEY] = server_entry + self._write_mcp_config(config_path, existing_config) + return McpConfigureAction.CONFIGURED, config_path + + def _configure_via_shell(self): + argv = list(self.config.mcp_shell_command) + executable = argv[0] + if shutil.which(executable) is None: + return McpConfigureAction.SKIPPED, executable + subprocess.run(argv, check=True) + return McpConfigureAction.CONFIGURED, executable + + @staticmethod + def _read_mcp_config(path): + if not os.path.exists(path): + return {} + with open(path) as f: + return json.load(f) + + @staticmethod + def _write_mcp_config(path, config): + os.makedirs(os.path.dirname(path), exist_ok=True) + is_new = not os.path.exists(path) + with open(path, 'w') as f: + json.dump(config, f, indent=2) + f.write('\n') + # If we created the MCP config file, set permissions to 600, otherwise + # the open call above preserves permissions for existing files + if is_new: + os.chmod(path, 0o600) + + +class InstalledSkill: + """A skill found in an agent's skills directory.""" + + def __init__(self, agent, name, path): + self.agent = agent + self.name = name + self.path = path + + +# TODO: Verify detection, skills, and MCP config paths against actual +# installations before release. Currently only tested with Kiro and +# simulated agent directories. +AGENT_CONFIGS = [ + # https://docs.anthropic.com/en/docs/claude-code/mcp + AgentConfig( + id='claude-code', + display_name='Claude Code', + detection_path='~/.claude/', + mcp_config_path='~/.claude.json', + mcp_servers_key='mcpServers', + detection_path_env_override='CLAUDE_CONFIG_DIR', + ), + # https://docs.cline.bot/mcp/mcp-overview + # https://docs.cline.bot/customization/skills + AgentConfig( + id='cline', + display_name='Cline', + detection_path='~/.cline/', + mcp_config_path='mcp.json', + mcp_servers_key='mcpServers', + ), + # https://developers.openai.com/codex/skills + # https://github.com/openai/codex/blob/main/codex-rs/cli/src/mcp_cmd.rs + # Codex stores MCP config in TOML, not JSON. We shell out to its CLI + # rather than add a TOML dependency. + AgentConfig( + id='codex', + display_name='Codex', + detection_path='~/.codex/', + skills_path_override='~/.agents/skills/', + detection_path_env_override='CODEX_HOME', + mcp_shell_command=[ + 'codex', + 'mcp', + 'add', + AWS_MCP_SERVER_KEY, + '--', + 'uvx', + *_AWS_MCP_PROXY_ARGS, + ], + ), + # https://docs.cursor.com/context/model-context-protocol + # https://cursor.com/docs/skills + AgentConfig( + id='cursor', + display_name='Cursor', + detection_path='~/.cursor/', + mcp_config_path='mcp.json', + mcp_servers_key='mcpServers', + ), + # https://geminicli.com/docs/cli/tutorials/mcp-setup/ + # https://geminicli.com/docs/cli/skills/ + AgentConfig( + id='gemini-cli', + display_name='Gemini CLI', + detection_path='~/.gemini/', + skills_path_override='~/.agents/skills/', + mcp_config_path='settings.json', + mcp_servers_key='mcpServers', + ), + # https://kiro.dev/docs/mcp/configuration/ + AgentConfig( + id='kiro', + display_name='Kiro', + detection_path='~/.kiro/', + mcp_config_path='settings/mcp.json', + mcp_servers_key='mcpServers', + mcp_extra_config={'timeout': 100000, 'transport': 'stdio'}, + ), + # https://openclaw-openclaw.mintlify.app/configuration + # OpenClaw does not document MCP support — install skills only. + AgentConfig( + id='openclaw', + display_name='OpenClaw', + detection_path='~/.openclaw/', + ), + # https://docs.opencode.ai/docs/mcp-servers + # https://docs.opencode.ai/docs/skills + AgentConfig( + id='opencode', + display_name='OpenCode', + detection_path='~/.config/opencode/', + skills_path_override='~/.agents/skills/', + mcp_config_path='opencode.json', + mcp_servers_key='mcp', + mcp_server_entry={ + 'type': 'local', + 'command': ['uvx', *_AWS_MCP_PROXY_ARGS], + }, + ), + # https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md + # Pi does not document MCP support — install skills only. + AgentConfig( + id='pi', + display_name='Pi', + detection_path='~/.pi/agent/', + ), + # https://docs.windsurf.com/plugins/cascade/mcp + # https://docs.windsurf.com/windsurf/cascade/skills + AgentConfig( + id='windsurf', + display_name='Windsurf', + detection_path='~/.codeium/windsurf/', + skills_path_override='~/.agents/skills/', + mcp_config_path='~/.codeium/mcp_config.json', + mcp_servers_key='mcpServers', + ), +] + + +def _build_universal_row(configs): + universal_path = os.path.expanduser('~/.agents/skills/').rstrip(os.sep) + consumers = [ + c.display_name + for c in configs + if c.skills_path_override + and os.path.expanduser(c.skills_path_override).rstrip(os.sep) + == universal_path + ] + return AgentConfig( + id=UNIVERSAL_ROW_ID, + display_name=f'Universal ({", ".join(consumers)})', + detection_path='~/.agents', + ) + + +AGENT_CONFIGS.append(_build_universal_row(AGENT_CONFIGS)) + + +def universal_first(items, get_id=lambda a: a.config.id): + return sorted( + items, + key=lambda i: 0 if get_id(i) == UNIVERSAL_ROW_ID else 1, + ) + + +def get_detected_agents(agent_configs=None): + if agent_configs is None: + agent_configs = AGENT_CONFIGS + detected = [] + for config in agent_configs: + agent = config.detect() + if agent is not None: + detected.append(agent) + return detected diff --git a/awscli/customizations/agenttoolkit/configure.py b/awscli/customizations/agenttoolkit/configure.py new file mode 100644 index 000000000000..3b53f5b17b86 --- /dev/null +++ b/awscli/customizations/agenttoolkit/configure.py @@ -0,0 +1,219 @@ +# 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 io +import sys + +from awscli.customizations.agenttoolkit.agents import ( + AGENT_CONFIGS, + UNIVERSAL_ROW_ID, + McpConfigureAction, + collapse_home, + universal_first, +) +from awscli.customizations.agenttoolkit.utils import ( + AgentToolkitServiceError, + create_client, + get_skill_download, + install_skill, +) +from awscli.customizations.commands import BasicCommand +from awscli.customizations.exceptions import ConfigurationError +from awscli.customizations.prompts import multiselect_choice, yes_no_choice +from awscli.customizations.utils import uni_print + + +class ConfigureAgentToolkitCommand(BasicCommand): + NAME = 'agent-toolkit' + DESCRIPTION = ( + 'Set up AI coding agents with AWS skills and the AWS MCP Server.\n\n' + 'Supported agents are determined by the presence of configuration ' + 'directories, such as ``~/.kiro``. Skills are installed globally in ' + 'those configuration directories, not per project.' + ) + SYNOPSIS = 'aws configure agent-toolkit' + + def __init__(self, session, stream=None, agent_configs=None, client=None): + super().__init__(session) + self._stream = stream or sys.stdout + self._agent_configs = ( + agent_configs if agent_configs is not None else AGENT_CONFIGS + ) + self._client = client + + def _run_main(self, parsed_args, parsed_globals): + uni_print('\nDetecting installed AI coding agents...\n', self._stream) + wizard_configs = [ + c for c in self._agent_configs if c.id != UNIVERSAL_ROW_ID + ] + universal_agent = next( + ( + c.detect() + for c in self._agent_configs + if c.id == UNIVERSAL_ROW_ID + ), + None, + ) + + detected = [] + for config in wizard_configs: + agent = config.detect() + if agent: + # Use the DetectedAgent's label so any env-overridden + # paths (e.g. CLAUDE_CONFIG_DIR) display the resolved + # location instead of the config-time literal. + uni_print(f' \u2713 {agent.display_label}\n', self._stream) + detected.append(agent) + else: + uni_print( + f' \u2717 {config.display_label} (not found)\n', + self._stream, + ) + + if not detected: + raise ConfigurationError( + 'No supported AI coding agents found. ' + 'Supported agents: ' + f'{", ".join(c.display_name for c in wizard_configs)}. ' + 'Install one and re-run \'aws configure agent-toolkit\'.' + ) + + selected_agents = multiselect_choice( + '\nSelect agents to configure', + detected, + display_format=lambda a: a.display_label, + ) + + if not selected_agents: + uni_print('No agents selected.\n', self._stream) + return 0 + + install_targets = list(selected_agents) + if universal_agent and any( + a.skills_path == universal_agent.skills_path + for a in selected_agents + ): + install_targets.append(universal_agent) + + client = self._client or create_client(self._session, parsed_globals) + self._install_default_skills(install_targets, client) + self._configure_mcp(selected_agents) + + uni_print( + '\nYou can discover additional skills with ' + '\'aws agent-toolkit search-skills --search-query \'\n', + self._stream, + ) + return 0 + + def _install_default_skills(self, selected_agents, client): + uni_print('\nFetching default AWS skills...\n', self._stream) + paginator = client.get_paginator('list_skills') + default_skills = [] + for page in paginator.paginate(categoryFilter='aws-core'): + default_skills.extend(page.get('skills', [])) + + if not default_skills: + return + + names = ', '.join(s['name'] for s in default_skills) + uni_print(f' Found: {names}\n', self._stream) + + if not yes_no_choice( + f'\nInstall {len(default_skills)} default AWS skills? [Y/n]: ' + ): + return + + uni_print( + f'\nInstalling {len(default_skills)} default AWS skills...\n', + self._stream, + ) + + installed_count = 0 + for i, skill in enumerate(default_skills, 1): + name = skill['name'] + try: + zip_bytes, checksum, version = get_skill_download(client, name) + install_skill( + name, + version, + zip_bytes, + checksum, + selected_agents, + ) + installed_count += 1 + uni_print( + f' [{i}/{len(default_skills)}] {name}\n', + self._stream, + ) + except AgentToolkitServiceError as e: + uni_print( + f' [{i}/{len(default_skills)}] {name}: {e}\n', + self._stream, + ) + + if installed_count: + uni_print('\nSkills installed to:\n', self._stream) + seen_paths = set() + for agent in universal_first(selected_agents): + if agent.skills_path in seen_paths: + continue + seen_paths.add(agent.skills_path) + uni_print( + f' {agent.display_label}\n', + self._stream, + ) + else: + uni_print( + '\nNo skills were installed successfully.\n', self._stream + ) + + def _configure_mcp(self, agents): + if not yes_no_choice('\nConfigure AWS MCP server connection? [Y/n]: '): + return + + uni_print('\nAWS MCP server configured for:\n', self._stream) + skipped = [] + for agent in agents: + action, detail = agent.configure_mcp_server() + if action is McpConfigureAction.SKIPPED: + skipped.append((agent, detail)) + elif action is McpConfigureAction.ALREADY_CONFIGURED: + uni_print( + f' \u2713 {agent.display_name} \u2014 ' + f'{collapse_home(detail)}: already configured\n', + self._stream, + ) + else: + uni_print( + f' \u2713 {agent.display_name} \u2014 ' + f'{collapse_home(detail)}: updated\n', + self._stream, + ) + + if skipped: + uni_print( + '\nMCP setup not available for the following agents. ' + 'See https://docs.aws.amazon.com/agent-toolkit/latest/userguide/getting-started-aws-mcp-server.html ' + 'for manual setup instructions.\n', + self._stream, + ) + for agent, detail in skipped: + reason = ( + f'requires {detail!r} on PATH' + if detail + else 'no automated setup' + ) + uni_print( + f' \u2717 {agent.display_name} ({reason})\n', + self._stream, + ) diff --git a/awscli/customizations/agenttoolkit/get_skill_file.py b/awscli/customizations/agenttoolkit/get_skill_file.py new file mode 100644 index 000000000000..bd647c573a70 --- /dev/null +++ b/awscli/customizations/agenttoolkit/get_skill_file.py @@ -0,0 +1,67 @@ +# 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 awscli.customizations.agenttoolkit.utils import ( + SKILL_NAME_ARG, + SKILL_VERSION_ARG, + create_client, +) +from awscli.customizations.commands import BasicCommand +from awscli.utils import OutputStreamFactory + + +class GetSkillFileCommand(BasicCommand): + NAME = 'get-skill-file' + DESCRIPTION = ( + 'Retrieve the contents of a single file from a skill. ' + 'Use ``aws agent-toolkit get-skill-metadata`` to discover available ' + 'file names for each skill. By default the latest version is ' + 'retrieved, use ``--skill-version`` for a specific skill version.' + ) + ARG_TABLE = [ + SKILL_NAME_ARG, + { + 'name': 'file-path', + 'help_text': ( + 'The file to fetch ' + '(such as SKILL.md, references/architecture.md).' + ), + 'action': 'store', + 'cli_type_name': 'string', + 'required': True, + }, + SKILL_VERSION_ARG, + ] + + def __init__(self, session, client=None, output_stream_factory=None): + super().__init__(session) + 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): + client = self._client or create_client(self._session, parsed_globals) + + skill_name = parsed_args.skill_name + file_path = parsed_args.file_path + skill_version = getattr(parsed_args, 'skill_version', None) or 'latest' + + response = client.get_skill_file( + name=skill_name, + skillVersion=skill_version, + filePath=file_path, + ) + body = response['body'].read() + with self._output_stream_factory.get_output_stream() as stream: + stream.write(body.decode('utf-8')) + return 0 diff --git a/awscli/customizations/agenttoolkit/list_installed_skills.py b/awscli/customizations/agenttoolkit/list_installed_skills.py new file mode 100644 index 000000000000..2d741856123e --- /dev/null +++ b/awscli/customizations/agenttoolkit/list_installed_skills.py @@ -0,0 +1,73 @@ +# 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 awscli.customizations.agenttoolkit.agents import universal_first +from awscli.customizations.agenttoolkit.utils import ( + AGENT_ARG, + resolve_agents, +) +from awscli.customizations.commands import BasicCommand +from awscli.formatter import get_formatter +from awscli.utils import OutputStreamFactory + + +class ListInstalledSkillsCommand(BasicCommand): + NAME = 'list-installed-skills' + DESCRIPTION = ( + 'List AWS skills that were previously installed by the ' + '``aws agent-toolkit`` commands. Shows the skill name, agent, and file ' + 'path for each installation. By default it lists skills from all ' + 'detected agents, use ``--agent`` to filter results to a specific tool.' + ) + ARG_TABLE = [AGENT_ARG] + + def __init__( + self, session, agent_configs=None, output_stream_factory=None + ): + super().__init__(session) + self._agent_configs = agent_configs + 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) + + seen_paths = set() + all_skills = [] + for agent in universal_first(agents): + for skill in agent.get_installed_skills(): + if skill.path in seen_paths: + continue + seen_paths.add(skill.path) + all_skills.append(skill) + + result = { + 'skills': [ + { + 'agent': skill.agent.display_name, + 'name': skill.name, + 'path': skill.path, + } + for skill in all_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) + stream.write('\n') + return 0 diff --git a/awscli/customizations/agenttoolkit/remove_skill.py b/awscli/customizations/agenttoolkit/remove_skill.py new file mode 100644 index 000000000000..442daddfe367 --- /dev/null +++ b/awscli/customizations/agenttoolkit/remove_skill.py @@ -0,0 +1,79 @@ +# 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 +import shutil +import sys + +from awscli.customizations.agenttoolkit.agents import ( + AGENT_CONFIGS, + universal_first, +) +from awscli.customizations.agenttoolkit.utils import ( + AGENT_ARG, + SKILL_NAME_ARG, + resolve_agents, +) +from awscli.customizations.commands import BasicCommand +from awscli.customizations.exceptions import ParamValidationError + + +class RemoveCommand(BasicCommand): + NAME = 'remove-skill' + DESCRIPTION = ( + 'Remove a previously installed AWS skill from detected agents. ' + 'By default the skill is removed from all detected agents, use ' + '``--agent`` to remove from a specific tool only.' + ) + ARG_TABLE = [ + SKILL_NAME_ARG, + AGENT_ARG, + ] + + def __init__(self, session, stream=None, agent_configs=None): + super().__init__(session) + if stream is None: + stream = sys.stdout + self._stream = stream + if agent_configs is None: + agent_configs = AGENT_CONFIGS + self._agent_configs = agent_configs + + def _run_main(self, parsed_args, parsed_globals): + skill_name = parsed_args.skill_name + agent_filter = getattr(parsed_args, 'agent', None) + + agents = resolve_agents(agent_filter, self._agent_configs) + + matches = [] + for agent in agents: + for skill in agent.get_installed_skills(): + if skill.name == skill_name: + matches.append(skill) + + if not matches: + raise ParamValidationError( + f'Skill "{skill_name}" is not installed.' + ) + + removed_paths = set() + ordered = universal_first(matches, get_id=lambda s: s.agent.config.id) + for skill in ordered: + skill_dir = os.path.dirname(skill.path) + if skill_dir in removed_paths: + continue + shutil.rmtree(skill_dir) + removed_paths.add(skill_dir) + self._stream.write( + f'Removed {skill.name} from {skill.agent.display_label}.\n' + ) + return 0 diff --git a/awscli/customizations/agenttoolkit/update_skill.py b/awscli/customizations/agenttoolkit/update_skill.py new file mode 100644 index 000000000000..780e2ad138d2 --- /dev/null +++ b/awscli/customizations/agenttoolkit/update_skill.py @@ -0,0 +1,104 @@ +# 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 +import sys + +from awscli.customizations.agenttoolkit.agents import AGENT_CONFIGS +from awscli.customizations.agenttoolkit.utils import ( + AGENT_ARG, + SKILL_NAME_ARG, + create_client, + get_skill_download, + install_skill, + read_installed_version, + resolve_agents, + resolve_latest_version, +) +from awscli.customizations.commands import BasicCommand +from awscli.customizations.exceptions import ParamValidationError + + +class UpdateSkillCommand(BasicCommand): + NAME = 'update-skill' + DESCRIPTION = ( + 'Update an installed AWS skill 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 ' + 'for only a specific tool.' + ) + ARG_TABLE = [ + SKILL_NAME_ARG, + AGENT_ARG, + ] + + def __init__(self, session, stream=None, client=None, agent_configs=None): + super().__init__(session) + if stream is None: + stream = sys.stdout + self._stream = stream + self._client = client + if agent_configs is None: + agent_configs = AGENT_CONFIGS + self._agent_configs = agent_configs + + def _run_main(self, parsed_args, parsed_globals): + skill_name = parsed_args.skill_name + agent_filter = getattr(parsed_args, 'agent', None) + + 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 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) + + 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) + + if not outdated: + self._stream.write( + f'{skill_name} is already up to date ({remote_version}).\n' + ) + return 0 + + zip_bytes, checksum, version = get_skill_download( + client, skill_name, version=remote_version + ) + install_skill( + skill_name, + version, + zip_bytes, + checksum, + outdated, + self._stream, + action='Updated', + ) + return 0 diff --git a/awscli/customizations/agenttoolkit/utils.py b/awscli/customizations/agenttoolkit/utils.py new file mode 100644 index 000000000000..601c7afcab82 --- /dev/null +++ b/awscli/customizations/agenttoolkit/utils.py @@ -0,0 +1,205 @@ +# 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 hashlib +import io +import json +import logging +import os +import shutil +import zipfile + +from awscli.customizations.agenttoolkit.agents import ( + AGENT_CONFIGS, + SKILL_METADATA_FILENAME, + get_detected_agents, + universal_first, +) +from awscli.customizations.exceptions import ParamValidationError +from awscli.customizations.utils import create_client_from_parsed_globals + +LOG = logging.getLogger(__name__) +MAX_UNCOMPRESSED_SIZE = 10 * 1024 * 1024 # 10 MB + +NONPROD_ACCESS_TOKEN_HEADER = 'x-nonprod-access-token' +NONPROD_ACCESS_TOKEN_ENV_VAR = 'NONPROD_ACCESS_TOKEN_HEADER' + +SKILL_NAME_ARG = { + 'name': 'skill-name', + 'help_text': 'The name of the skill.', + 'action': 'store', + 'cli_type_name': 'string', + 'required': True, +} + +SKILL_VERSION_ARG = { + 'name': 'skill-version', + 'help_text': ( + 'Skill version to retrieve (such as ``v1``). Defaults to latest.' + ), + 'action': 'store', + 'cli_type_name': 'string', + 'required': False, +} + +AGENT_ARG = { + 'name': 'agent', + 'help_text': ( + 'The agentic tool to target. If not set, the operation applies ' + 'to all detected tools. Valid values: ' + f'{", ".join(c.id for c in AGENT_CONFIGS)}.' + ), + 'action': 'store', + 'cli_type_name': 'string', + 'required': False, +} + + +def create_client(session, parsed_globals): + return create_client_from_parsed_globals( + session, + 'agenttoolkit', + parsed_globals, + ) + + +def resolve_latest_version(client, skill_name): + response = client.get_latest_skill_version(name=skill_name) + return response['body'].strip() + + +def get_skill_download(client, skill_name, version=None): + """ + Download a skill zip and its checksum via the modeled API. + + Returns (zip_bytes, checksum_hex, resolved_version). + """ + + # Actually look up the latest version instead of just passing 'latest', + # this lets us write out the new version to the metadata file without + # inspecting the zip contents + if version is None: + version = resolve_latest_version(client, skill_name) + + response = client.get_skill_file( + name=skill_name, skillVersion=version, filePath='download' + ) + zip_bytes = response['body'].read() + + response = client.get_skill_file_checksum( + name=skill_name, skillVersion=version, filePath='download' + ) + checksum = response['body'].strip().lower().split()[0] + return zip_bytes, checksum, version + + +def read_installed_version(skill_dir): + metadata = read_skill_metadata(skill_dir) + if metadata is None: + return None + return metadata.get('version') + + +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) + except FileNotFoundError: + return None + except json.JSONDecodeError as e: + LOG.debug( + 'Could not parse skill metadata at %s: %s. ' + 'The file may be corrupted; remove or re-install the skill ' + 'to recover.', + path, + e, + ) + return None + + +def write_skill_metadata(skill_dir, version): + path = os.path.join(skill_dir, SKILL_METADATA_FILENAME) + with open(path, 'w') as f: + json.dump({'version': version}, f) + f.write('\n') + + +def install_skill( + skill_name, + version, + zip_bytes, + checksum, + agents, + stream=None, + action='Installed', +): + expected = checksum.strip().lower().split()[0] + actual = hashlib.sha256(zip_bytes).hexdigest() + if len(expected) != 64 or actual != expected: + raise AgentToolkitServiceError( + f'Checksum verification failed for {skill_name} ' + f'(expected {expected!r}, got {actual!r}).' + ) + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + total_size = sum(i.file_size for i in zf.infolist()) + if total_size > MAX_UNCOMPRESSED_SIZE: + raise AgentToolkitServiceError( + f'Refusing to extract: uncompressed size ' + f'({total_size} bytes) exceeds limit.' + ) + for member in zf.namelist(): + normalized = os.path.normpath(member) + if normalized.startswith('..') or os.path.isabs(normalized): + raise AgentToolkitServiceError( + f'Refusing to extract {member!r}' + ' (path escapes skill directory).' + ) + + extracted_paths = set() + for agent in universal_first(agents): + skill_dir = os.path.join(agent.skills_path, skill_name) + if skill_dir in extracted_paths: + continue + if os.path.isdir(skill_dir): + shutil.rmtree(skill_dir) + os.makedirs(skill_dir, exist_ok=True) + zf.extractall(skill_dir) + write_skill_metadata(skill_dir, version) + extracted_paths.add(skill_dir) + if stream: + stream.write( + f' {action} {skill_name} ({version})' + f' to {agent.display_label}.\n' + ) + + +class AgentToolkitServiceError(Exception): + pass + + +def resolve_agents(agent_filter, agent_configs=None): + """Resolve agents, optionally filtered by agent id.""" + if agent_configs is None: + agent_configs = AGENT_CONFIGS + if agent_filter: + by_id = {c.id.lower(): c for c in agent_configs} + config = by_id.get(agent_filter.lower()) + if config is None: + raise ParamValidationError( + f'Invalid agent "{agent_filter}". Valid values: ' + f'{", ".join(sorted(c.id for c in agent_configs))}.' + ) + agent = config.detect() + return [agent] if agent is not None else [] + return get_detected_agents(agent_configs=agent_configs) diff --git a/awscli/customizations/argrename.py b/awscli/customizations/argrename.py index aec333db1a29..b6d0a0e84bd5 100644 --- a/awscli/customizations/argrename.py +++ b/awscli/customizations/argrename.py @@ -106,6 +106,8 @@ 'glue.get-unfiltered-partition-metadata.region': 'resource-region', 'glue.get-unfiltered-partitions-metadata.region': 'resource-region', 'glue.get-unfiltered-table-metadata.region': 'resource-region', + 'agent-toolkit.*.name': 'skill-name', + 'agent-toolkit.search-skills.query': 'search-query', } # Same format as ARGUMENT_RENAMES, but instead of renaming the arguments, @@ -124,13 +126,13 @@ def register_arg_renames(cli): for original, new_name in ARGUMENT_RENAMES.items(): event_portion, original_arg_name = original.rsplit('.', 1) cli.register( - 'building-argument-table.%s' % event_portion, + f'building-argument-table.{event_portion}', rename_arg(original_arg_name, new_name), ) for original, new_name in HIDDEN_ALIASES.items(): event_portion, original_arg_name = original.rsplit('.', 1) cli.register( - 'building-argument-table.%s' % event_portion, + f'building-argument-table.{event_portion}', hidden_alias(original_arg_name, new_name), ) diff --git a/awscli/customizations/configure/configure.py b/awscli/customizations/configure/configure.py index f36bcc173709..d378730eb85d 100644 --- a/awscli/customizations/configure/configure.py +++ b/awscli/customizations/configure/configure.py @@ -1,4 +1,4 @@ -# Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. +# 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 @@ -17,6 +17,9 @@ from botocore.exceptions import ProfileNotFound from awscli.compat import compat_input +from awscli.customizations.agenttoolkit.configure import ( + ConfigureAgentToolkitCommand, +) from awscli.customizations.commands import BasicCommand from awscli.customizations.configure.addmodel import AddModelCommand from awscli.customizations.configure.exportcreds import ( @@ -97,6 +100,10 @@ class ConfigureCommand(BasicCommand): 'name': 'export-credentials', 'command_class': ConfigureExportCredentialsCommand, }, + { + 'name': 'agent-toolkit', + 'command_class': ConfigureAgentToolkitCommand, + }, ] # If you want to add new values to prompt, update this list here. diff --git a/awscli/customizations/login/login.py b/awscli/customizations/login/login.py index 171b76c69871..7acf9ecec5d1 100644 --- a/awscli/customizations/login/login.py +++ b/awscli/customizations/login/login.py @@ -22,6 +22,7 @@ LoginType, SameDeviceLoginTokenFetcher, ) +from awscli.customizations.prompts import yes_no_choice from awscli.customizations.sso.utils import ( AuthCodeFetcher, OpenBrowserHandler, @@ -165,19 +166,11 @@ def accept_change_to_existing_profile_if_needed( if existing_session_id == new_session_id: return True - while True: - response = compat_input( - f'\nProfile {profile_name} is already configured to use session ' - f'{existing_session_id}. Do you want to overwrite it to use ' - f'{new_session_id} instead? (y/n): ' - ) - - if response.lower() in ('y', 'yes'): - return True - elif response.lower() in ('n', 'no'): - return False - else: - uni_print('Invalid response. Please enter "y" or "n"') + return yes_no_choice( + f'\nProfile {profile_name} is already configured to use session ' + f'{existing_session_id}. Do you want to overwrite it to use ' + f'{new_session_id} instead? (y/n): ' + ) def ensure_profile_does_not_have_existing_credentials(self, profile_name): """ diff --git a/awscli/customizations/prompts.py b/awscli/customizations/prompts.py new file mode 100644 index 000000000000..339da49df273 --- /dev/null +++ b/awscli/customizations/prompts.py @@ -0,0 +1,168 @@ +# 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 awscli.compat import compat_input +from awscli.customizations.exceptions import ConfigurationError +from awscli.customizations.utils import uni_print +from awscli.utils import is_stdin_a_tty + + +def yes_no_choice(prompt): + """ + Prompts the user to answer a yes/no question. + Continually re-prompts for invalid selections. + + :param prompt: Prompt text. + :returns: True for yes, False for no. + """ + while True: + response = compat_input(prompt) + + if response.lower() in ('y', 'yes'): + return True + elif response.lower() in ('n', 'no'): + return False + else: + uni_print('Invalid response. Please enter "y" or "n"\n') + + +def multiselect_choice( + message, + items, + display_format=None, + preselected=None, + pt_input=None, + pt_output=None, +): + """ + Present a list of items with checkboxes for multi-selection. + + Arrow keys to navigate, space to toggle, enter to confirm. + + :param message: Prompt message displayed above the choices. + :param items: List of items to select from. + :param display_format: Optional callable to format items for display. + :param preselected: Optional set of indices to pre-check. + :param pt_input: Optional prompt_toolkit input (for testing). + :param pt_output: Optional prompt_toolkit output (for testing). + :returns: List of selected items. + """ + if pt_input is None and not is_stdin_a_tty(): + raise ConfigurationError( + "This command requires an interactive terminal (TTY)." + ) + # Imported lazily so that loading this module does not pull in + # prompt_toolkit for commands that only use yes_no_choice. + from prompt_toolkit import Application + from prompt_toolkit.key_binding.key_bindings import KeyBindings + from prompt_toolkit.layout import HSplit, Layout, Window + from prompt_toolkit.layout.controls import UIContent, UIControl + from prompt_toolkit.layout.dimension import Dimension + from prompt_toolkit.layout.screen import Point + from prompt_toolkit.widgets import Label + + class MultiSelectControl(UIControl): + def __init__(self, items, display_format=None, preselected=None): + self._items = items + self._cursor = 0 + self._selected = set( + preselected if preselected is not None else range(len(items)) + ) + self._display_format = display_format + + def is_focusable(self): + return True + + def preferred_width(self, max_width): + return max_width + + def preferred_height( + self, width, max_height, wrap_lines, get_line_prefix + ): + return len(self._items) + + def create_content(self, width, height): + def get_line(i): + check = 'x' if i in self._selected else ' ' + item = self._items[i] + if self._display_format: + item = self._display_format(item) + style = 'bold' if i == self._cursor else '' + return [(style, f' [{check}] {item}')] + + return UIContent( + get_line=get_line, + cursor_position=Point(x=0, y=self._cursor), + line_count=len(self._items), + ) + + def get_key_bindings(self): + kb = KeyBindings() + + @kb.add('up') + def move_up(event): + self._cursor = (self._cursor - 1) % len(self._items) + + @kb.add('down') + def move_down(event): + self._cursor = (self._cursor + 1) % len(self._items) + + @kb.add(' ') + def toggle(event): + if self._cursor in self._selected: + self._selected.discard(self._cursor) + else: + self._selected.add(self._cursor) + + @kb.add('enter') + def confirm(event): + result = [self._items[i] for i in sorted(self._selected)] + event.app.exit(result=result) + + return kb + + control = MultiSelectControl( + items, display_format=display_format, preselected=preselected + ) + body = Window( + control, + always_hide_cursor=False, + height=Dimension(min=len(items), max=len(items)), + ) + + layout = Layout( + HSplit( + [ + Label( + f'{message} (space to toggle, up/down arrows to navigate, enter to confirm):' + ), + body, + ] + ), + focused_element=body, + ) + + app_bindings = KeyBindings() + + @app_bindings.add('c-c') + def exit_app(event): + event.app.exit(exception=KeyboardInterrupt, style='class:aborting') + + app = Application( + layout=layout, + key_bindings=app_bindings, + full_screen=False, + erase_when_done=True, + input=pt_input, + output=pt_output, + ) + return app.run() diff --git a/awscli/examples/agenttoolkit/add-skill.rst b/awscli/examples/agenttoolkit/add-skill.rst new file mode 100644 index 000000000000..f5a277271c61 --- /dev/null +++ b/awscli/examples/agenttoolkit/add-skill.rst @@ -0,0 +1,40 @@ +**Example 1: To install a skill** + +The following ``add-skill`` example downloads and installs the aws-serverless skill to all detected AI coding agents. :: + + aws agent-toolkit add-skill \ + --skill-name aws-serverless + +Output:: + + Installed aws-serverless (v1) to Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To install a skill to a specific agent** + +The following ``add-skill`` example installs the aws-cdk skill only to Kiro. :: + + aws agent-toolkit add-skill \ + --skill-name aws-cdk \ + --agent kiro + +Output:: + + Installed aws-cdk (v1) to Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 3: To install a specific version of a skill** + +The following ``add-skill`` example installs version v1 of the aws-serverless skill. :: + + aws agent-toolkit add-skill \ + --skill-name aws-serverless \ + --skill-version v1 + +Output:: + + Installed aws-serverless (v1) to Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/get-skill-file.rst b/awscli/examples/agenttoolkit/get-skill-file.rst new file mode 100644 index 000000000000..ebf5da19c922 --- /dev/null +++ b/awscli/examples/agenttoolkit/get-skill-file.rst @@ -0,0 +1,37 @@ +**Example 1: To fetch a file from a skill** + +The following ``get-skill-file`` example retrieves the SKILL.md file from the aws-serverless skill. :: + + aws agent-toolkit get-skill-file \ + --skill-name aws-serverless \ + --file-path SKILL.md + +Output:: + + # AWS Serverless + + Build, deploy, and manage serverless applications on AWS using Lambda, + API Gateway, Step Functions, EventBridge, and SAM/CDK. + + ## When to use this skill + ... + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To fetch a specific version of a skill file** + +The following ``get-skill-file`` example retrieves a reference file from a specific version of the aws-serverless skill. :: + + aws agent-toolkit get-skill-file \ + --skill-name aws-serverless \ + --file-path references/architecture.md \ + --skill-version v1 + +Output:: + + # Architecture Reference + + ## Lambda Function Patterns + ... + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/get-skill-metadata.rst b/awscli/examples/agenttoolkit/get-skill-metadata.rst new file mode 100644 index 000000000000..8373d6ee7f18 --- /dev/null +++ b/awscli/examples/agenttoolkit/get-skill-metadata.rst @@ -0,0 +1,49 @@ +**Example 1: To get metadata for a skill** + +The following ``get-skill-metadata`` example retrieves metadata for the aws-serverless skill, including its version and file list. :: + + aws agent-toolkit get-skill-metadata \ + --skill-name aws-serverless + +Output:: + + { + "name": "aws-serverless", + "version": "v1", + "description": "Build, deploy, and manage serverless applications on AWS using Lambda, API Gateway, Step Functions, and SAM/CDK.", + "categories": [ + "aws-core" + ], + "files": [ + "SKILL.md", + "references/architecture.md", + "references/best-practices.md" + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To get metadata for a specific skill version** + +The following ``get-skill-metadata`` example retrieves metadata for version v1 of the aws-serverless skill. :: + + aws agent-toolkit get-skill-metadata \ + --skill-name aws-serverless \ + --skill-version v1 + +Output:: + + { + "name": "aws-serverless", + "version": "v1", + "description": "Build, deploy, and manage serverless applications on AWS using Lambda, API Gateway, Step Functions, and SAM/CDK.", + "categories": [ + "aws-core" + ], + "files": [ + "SKILL.md", + "references/architecture.md" + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/list-available-skills.rst b/awscli/examples/agenttoolkit/list-available-skills.rst new file mode 100644 index 000000000000..053f1accaccd --- /dev/null +++ b/awscli/examples/agenttoolkit/list-available-skills.rst @@ -0,0 +1,68 @@ +**Example 1: To list all available skills** + +The following ``list-available-skills`` example lists all skills available in the remote catalog. :: + + aws agent-toolkit list-available-skills + +Output:: + + { + "skills": [ + { + "name": "aws-serverless", + "description": "Build, deploy, and manage serverless applications on AWS.", + "version": "v1", + "categories": [ + "aws-core" + ] + }, + { + "name": "aws-cdk", + "description": "Author, deploy, and troubleshoot AWS infrastructure using CDK.", + "version": "v1", + "categories": [ + "aws-core" + ] + }, + { + "name": "aws-cleanrooms", + "description": "Troubleshoot AWS Clean Rooms collaboration issues.", + "version": "v1", + "categories": [] + } + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To list available skills filtered by category** + +The following ``list-available-skills`` example lists only skills in the aws-core category. :: + + aws agent-toolkit list-available-skills \ + --category-filter aws-core + +Output:: + + { + "skills": [ + { + "name": "aws-serverless", + "description": "Build, deploy, and manage serverless applications on AWS.", + "version": "v1", + "categories": [ + "aws-core" + ] + }, + { + "name": "aws-cdk", + "description": "Author, deploy, and troubleshoot AWS infrastructure using CDK.", + "version": "v1", + "categories": [ + "aws-core" + ] + } + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/list-installed-skills.rst b/awscli/examples/agenttoolkit/list-installed-skills.rst new file mode 100644 index 000000000000..e64258285102 --- /dev/null +++ b/awscli/examples/agenttoolkit/list-installed-skills.rst @@ -0,0 +1,45 @@ +**Example 1: To list installed skills** + +The following ``list-installed-skills`` example lists all AWS skills installed on detected agents. :: + + aws agent-toolkit list-installed-skills + +Output:: + + { + "skills": [ + { + "agent": "Kiro", + "name": "aws-serverless", + "path": "/Users/username/.kiro/skills/aws-serverless/SKILL.md" + }, + { + "agent": "Cursor", + "name": "aws-serverless", + "path": "/Users/username/.cursor/skills/aws-serverless/SKILL.md" + } + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To list installed skills for a specific agent** + +The following ``list-installed-skills`` example lists skills installed only in Kiro. :: + + aws agent-toolkit list-installed-skills \ + --agent kiro + +Output:: + + { + "skills": [ + { + "agent": "Kiro", + "name": "aws-serverless", + "path": "/Users/username/.kiro/skills/aws-serverless/SKILL.md" + } + ] + } + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/remove-skill.rst b/awscli/examples/agenttoolkit/remove-skill.rst new file mode 100644 index 000000000000..ce24387ebded --- /dev/null +++ b/awscli/examples/agenttoolkit/remove-skill.rst @@ -0,0 +1,26 @@ +**Example 1: To remove an installed skill** + +The following ``remove-skill`` example removes the aws-serverless skill from all detected agents. :: + + aws agent-toolkit remove-skill \ + --skill-name aws-serverless + +Output:: + + Removed aws-serverless from Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To remove a skill from a specific agent** + +The following ``remove-skill`` example removes the aws-cdk skill only from Kiro. :: + + aws agent-toolkit remove-skill \ + --skill-name aws-cdk \ + --agent kiro + +Output:: + + Removed aws-cdk from Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/examples/agenttoolkit/search-skills.rst b/awscli/examples/agenttoolkit/search-skills.rst new file mode 100644 index 000000000000..d1430f62e356 --- /dev/null +++ b/awscli/examples/agenttoolkit/search-skills.rst @@ -0,0 +1,23 @@ +**To search for available skills** + +The following ``search-skills`` example searches for skills related to serverless development. :: + + aws agent-toolkit search-skills \ + --search-query serverless + +Output:: + + { + "skills": [ + { + "name": "aws-serverless", + "description": "Build, deploy, and manage serverless applications on AWS using Lambda, API Gateway, Step Functions, and SAM/CDK.", + "version": "v1", + "categories": [ + "aws-core" + ] + } + ] + } + +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 new file mode 100644 index 000000000000..ec34c18cbb72 --- /dev/null +++ b/awscli/examples/agenttoolkit/update-skill.rst @@ -0,0 +1,39 @@ +**Example 1: To update an installed skill** + +The following ``update-skill`` example updates the aws-serverless skill to the latest version across all agents where it is installed. :: + + aws agent-toolkit update-skill \ + --skill-name aws-serverless + +Output:: + + Updated aws-serverless (v1) to Kiro. + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. + +**Example 2: To update a skill for a specific agent** + +The following ``update-skill`` example updates the aws-serverless skill only for Kiro. :: + + aws agent-toolkit update-skill \ + --skill-name aws-serverless \ + --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 3: When a skill is already up to date** + +The following ``update-skill`` example shows the output when the installed skill is already at the latest version. :: + + aws agent-toolkit update-skill \ + --skill-name aws-serverless + +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*. diff --git a/awscli/examples/configure/agent-toolkit.rst b/awscli/examples/configure/agent-toolkit.rst new file mode 100644 index 000000000000..9edce33a0114 --- /dev/null +++ b/awscli/examples/configure/agent-toolkit.rst @@ -0,0 +1,37 @@ +**To set up AI coding agents with AWS skills and the AWS MCP Server** + +The following ``agent-toolkit`` example runs the interactive setup wizard. It detects installed AI coding agents, installs default AWS skills, and configures the AWS MCP Server connection. :: + + aws configure agent-toolkit + +Output:: + + Detecting installed AI coding agents... + [x] Kiro + [ ] Claude Code (not found) + [ ] Cursor (not found) + + Select agents to configure + > [x] Kiro + + Fetching default AWS skills... + Found: aws-serverless, aws-cdk, aws-iam + + Install 3 default AWS skills? [Y/n]: Y + + Installing 3 default AWS skills... + [1/3] aws-serverless + [2/3] aws-cdk + [3/3] aws-iam + + Skills installed to: + Kiro: /Users/username/.kiro/skills + + Configure AWS MCP server connection? [Y/n]: Y + + AWS MCP server configured for: + [x] Kiro: updated /Users/username/.kiro/settings/mcp.json + + You can discover additional skills with 'aws agent-toolkit search-skills --search-query ' + +For more information, see `Getting started with the AWS Agent Toolkit `__ in the *AWS Agent Toolkit User Guide*. diff --git a/awscli/handlers.py b/awscli/handlers.py index 3f0aa0180078..51df636f11d0 100644 --- a/awscli/handlers.py +++ b/awscli/handlers.py @@ -22,6 +22,9 @@ from awscli.clidriver import no_pager_handler from awscli.customizations import datapipeline from awscli.customizations.addexamples import add_examples +from awscli.customizations.agenttoolkit import ( + register_agent_toolkit_commands, +) from awscli.customizations.argrename import register_arg_renames from awscli.customizations.assumerole import register_assume_role_provider from awscli.customizations.awslambda import register_lambda_create_function @@ -241,3 +244,4 @@ def awscli_initialize(event_handlers): register_quicksight_asset_bundle_customizations(event_handlers) register_ec2_instance_connect_commands(event_handlers) register_login_cmds(event_handlers) + register_agent_toolkit_commands(event_handlers) diff --git a/tests/functional/botocore/endpoint-rules/agenttoolkit/endpoint-tests-1.json b/tests/functional/botocore/endpoint-rules/agenttoolkit/endpoint-tests-1.json new file mode 100644 index 000000000000..59d03c3a7e8b --- /dev/null +++ b/tests/functional/botocore/endpoint-rules/agenttoolkit/endpoint-tests-1.json @@ -0,0 +1,39 @@ +{ + "version": "1", + "testCases": [ + { + "documentation": "Returns regional endpoint for us-east-1", + "expect": { + "endpoint": { + "url": "https://agent-toolkit.us-east-1.api.aws" + } + }, + "params": { + "Region": "us-east-1" + } + }, + { + "documentation": "Returns regional endpoint for us-west-2", + "expect": { + "endpoint": { + "url": "https://agent-toolkit.us-west-2.api.aws" + } + }, + "params": { + "Region": "us-west-2" + } + }, + { + "documentation": "Overrides endpoint", + "expect": { + "endpoint": { + "url": "https://override.agent-toolkit.api.aws" + } + }, + "params": { + "Region": "us-east-1", + "Endpoint": "https://override.agent-toolkit.api.aws" + } + } + ] +} \ No newline at end of file diff --git a/tests/unit/customizations/agenttoolkit/__init__.py b/tests/unit/customizations/agenttoolkit/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/unit/customizations/agenttoolkit/test_add_skill.py b/tests/unit/customizations/agenttoolkit/test_add_skill.py new file mode 100644 index 000000000000..c5aa55ee281b --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_add_skill.py @@ -0,0 +1,238 @@ +# 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 hashlib +from io import StringIO +from unittest.mock import Mock, patch + +import pytest + +from awscli.customizations.agenttoolkit.add_skill import AddSkillCommand +from awscli.customizations.agenttoolkit.utils import AgentToolkitServiceError +from awscli.customizations.exceptions import ParamValidationError +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_skill_zip, +) + + +def _run_add(agent_configs, args, zip_bytes=None, version='v1', checksum=None): + default_bytes, default_checksum = make_skill_zip({'SKILL.md': 'test'}) + if zip_bytes is None: + zip_bytes = default_bytes + if checksum is None: + checksum = hashlib.sha256(zip_bytes).hexdigest() + + def mock_download(client, skill_name, version=None): + v = version if version is not None else fallback_version + return zip_bytes, checksum, v + + fallback_version = version + + stream = StringIO() + session = Mock() + session.user_agent_extra = '' + session.emit_first_non_none_response.return_value = None + + with ( + patch( + 'awscli.customizations.agenttoolkit.add_skill.get_skill_download', + side_effect=mock_download, + ), + patch('awscli.customizations.agenttoolkit.add_skill.create_client'), + ): + cmd = AddSkillCommand( + session, stream=stream, agent_configs=agent_configs + ) + rc = cmd(args=args, parsed_globals=Mock()) + return rc, stream.getvalue() + + +def test_add_skill_success(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + rc, output = _run_add(configs, ['--skill-name', 'aws-s3']) + assert rc == 0 + assert 'Installed aws-s3 (v1) to Test Agent' in output + assert ( + tmp_path / '.test-agent' / 'skills' / 'aws-s3' / 'SKILL.md' + ).exists() + + +def test_add_skill_with_version(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + rc, output = _run_add( + configs, + ['--skill-name', 'aws-s3', '--skill-version', 'v2'], + version='v999', + ) + assert rc == 0 + assert 'v2' in output + assert 'v999' not in output + + +def test_add_skill_checksum_failure(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + with pytest.raises( + AgentToolkitServiceError, match='Checksum verification failed' + ): + _run_add(configs, ['--skill-name', 'aws-s3'], checksum='bad_checksum') + + +def test_add_skill_multiple_agents(tmp_path): + (tmp_path / '.agent-a' / 'skills').mkdir(parents=True) + (tmp_path / '.agent-b' / 'skills').mkdir(parents=True) + configs = [ + make_config( + tmp_path, + id='a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + make_config( + tmp_path, + id='b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + ), + ] + rc, output = _run_add(configs, ['--skill-name', 'aws-s3']) + assert rc == 0 + assert 'Agent A' in output + assert 'Agent B' in output + assert (tmp_path / '.agent-a' / 'skills' / 'aws-s3' / 'SKILL.md').exists() + assert (tmp_path / '.agent-b' / 'skills' / 'aws-s3' / 'SKILL.md').exists() + + +def test_add_skill_dedupes_shared_skills_path(tmp_path): + (tmp_path / '.agent-a').mkdir() + (tmp_path / '.agent-b').mkdir() + shared = str(tmp_path / 'shared' / 'skills') + configs = [ + make_config( + tmp_path, + id='a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + skills_path_override=shared, + ), + make_config( + tmp_path, + id='b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + skills_path_override=shared, + ), + ] + rc, output = _run_add(configs, ['--skill-name', 'aws-s3']) + assert rc == 0 + assert output.count('Installed aws-s3') == 1 + assert (tmp_path / 'shared' / 'skills' / 'aws-s3' / 'SKILL.md').exists() + + +def test_add_skill_with_client_filter(tmp_path): + (tmp_path / '.agent-a' / 'skills').mkdir(parents=True) + (tmp_path / '.agent-b' / 'skills').mkdir(parents=True) + 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_add( + configs, ['--skill-name', 'aws-s3', '--agent', 'agent-a'] + ) + assert rc == 0 + assert 'Agent A' in output + assert 'Agent B' not in output + assert (tmp_path / '.agent-a' / 'skills' / 'aws-s3' / 'SKILL.md').exists() + assert not (tmp_path / '.agent-b' / 'skills' / 'aws-s3').exists() + + +def test_add_skill_agent_filter_is_case_insensitive(tmp_path): + (tmp_path / '.agent-a' / 'skills').mkdir(parents=True) + configs = [ + make_config( + tmp_path, + id='agent-a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + ] + rc, output = _run_add( + configs, ['--skill-name', 'aws-s3', '--agent', 'AGENT-A'] + ) + assert rc == 0 + assert 'Agent A' in output + assert (tmp_path / '.agent-a' / 'skills' / 'aws-s3' / 'SKILL.md').exists() + + +def test_add_skill_invalid_client(tmp_path): + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='Invalid agent'): + _run_add(configs, ['--skill-name', 'aws-s3', '--agent', 'nonexistent']) + + +def test_add_skill_no_agents(tmp_path): + configs = [make_config(tmp_path)] # dir doesn't exist + with pytest.raises( + ParamValidationError, match='No supported AI coding agents' + ): + _run_add(configs, ['--skill-name', 'aws-s3']) + + +def test_add_skill_universal_row_coexists_with_override_agent(tmp_path): + (tmp_path / '.codex').mkdir() + universal_base = tmp_path / '.agents' + shared = universal_base / 'skills' + shared.mkdir(parents=True) + 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), + ), + ] + rc, output = _run_add(configs, ['--skill-name', 'aws-cdk']) + assert rc == 0 + assert 'Universal (Codex)' in output + # Codex shares the skills path; the universal row owns the print line. + assert output.count('Installed aws-cdk') == 1 + assert (shared / 'aws-cdk' / 'SKILL.md').exists() + + +def test_add_skill_zip_slip_rejected(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + zip_bytes = make_skill_zip({'../../../evil.txt': 'pwned'})[0] + with pytest.raises( + AgentToolkitServiceError, match='path escapes skill directory' + ): + _run_add(configs, ['--skill-name', 'aws-s3'], zip_bytes=zip_bytes) diff --git a/tests/unit/customizations/agenttoolkit/test_agents.py b/tests/unit/customizations/agenttoolkit/test_agents.py new file mode 100644 index 000000000000..82925f9b1bc1 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_agents.py @@ -0,0 +1,319 @@ +# 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 +import os +from unittest.mock import patch + +from awscli.customizations.agenttoolkit.agents import ( + AGENT_CONFIGS, + AgentConfig, + DetectedAgent, + McpConfigureAction, + get_detected_agents, +) +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_skill, +) + + +def test_detect_when_installed(tmp_path): + (tmp_path / '.test-agent').mkdir() + config = make_config(tmp_path) + agent = config.detect() + assert isinstance(agent, DetectedAgent) + assert agent.display_name == 'Test Agent' + assert agent.base_dir == str(tmp_path / '.test-agent') + + +def test_detect_when_not_installed(tmp_path): + config = make_config(tmp_path) + assert config.detect() is None + + +def test_detect_env_override(tmp_path, monkeypatch): + (tmp_path / '.test-agent').mkdir() + override_dir = tmp_path / '.custom-location' + override_dir.mkdir() + config = make_config( + tmp_path, + detection_path_env_override='TEST_CONFIG_DIR', + ) + monkeypatch.setenv('TEST_CONFIG_DIR', str(override_dir)) + agent = config.detect() + assert agent.base_dir == str(override_dir) + + +def test_detect_env_override_falls_back(tmp_path, monkeypatch): + (tmp_path / '.test-agent').mkdir() + config = make_config( + tmp_path, + detection_path_env_override='TEST_CONFIG_DIR', + ) + monkeypatch.setenv('TEST_CONFIG_DIR', str(tmp_path / 'nonexistent')) + agent = config.detect() + assert agent.base_dir == str(tmp_path / '.test-agent') + + +def test_get_installed_skills_finds_aws_skills(tmp_path): + make_skill(tmp_path, '.test-agent', 'aws-serverless') + make_skill(tmp_path, '.test-agent', 'aws-databases') + config = make_config(tmp_path) + agent = config.detect() + skills = agent.get_installed_skills() + assert len(skills) == 2 + assert skills[0].name == 'aws-databases' + assert skills[0].agent.display_name == 'Test Agent' + assert skills[1].name == 'aws-serverless' + assert skills[1].path.endswith('aws-serverless/SKILL.md') + + +def test_skills_path_override(tmp_path): + (tmp_path / '.test-agent').mkdir() + override_dir = tmp_path / 'shared' / 'skills' + config = make_config( + tmp_path, + skills_path_override=str(override_dir), + ) + agent = config.detect() + assert agent.skills_path == str(override_dir) + + +def test_universal_row_display_name_lists_consumers(): + from awscli.customizations.agenttoolkit.agents import AGENT_CONFIGS + + universal = next(c for c in AGENT_CONFIGS if c.id == 'universal') + assert universal.detection_path == '~/.agents' + for config in AGENT_CONFIGS: + if config.skills_path_override and config.skills_path_override.rstrip( + '/' + ).endswith('.agents/skills'): + assert config.display_name in universal.display_name + + +def test_get_installed_skills_excludes_unmarked(tmp_path): + make_skill(tmp_path, '.test-agent', 'aws-installed', with_marker=True) + make_skill(tmp_path, '.test-agent', 'hand-rolled', with_marker=False) + config = make_config(tmp_path) + skills = config.detect().get_installed_skills() + names = [s.name for s in skills] + assert names == ['aws-installed'] + + +def test_get_installed_skills_empty(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + config = make_config(tmp_path) + assert config.detect().get_installed_skills() == [] + + +def test_get_detected_agents(tmp_path): + (tmp_path / '.kiro').mkdir() + test_configs = [ + AgentConfig( + id='kiro', + display_name='Kiro', + detection_path=str(tmp_path / '.kiro'), + ), + AgentConfig( + id='cursor', + display_name='Cursor', + detection_path=str(tmp_path / '.cursor'), + ), + ] + detected = get_detected_agents(agent_configs=test_configs) + assert len(detected) == 1 + assert detected[0].display_name == 'Kiro' + + +def test_mcp_config_path_honors_detection_env_override(tmp_path, monkeypatch): + (tmp_path / '.test-agent').mkdir() + override_dir = tmp_path / '.custom-location' + override_dir.mkdir() + config = make_config( + tmp_path, + detection_path_env_override='TEST_CONFIG_DIR', + mcp_config_path='~/.test-agent.json', + ) + monkeypatch.setenv('TEST_CONFIG_DIR', str(override_dir)) + agent = config.detect() + assert agent.mcp_config_file == str(override_dir / '.test-agent.json') + + +def test_mcp_config_path_falls_back_when_env_override_dir_missing( + tmp_path, monkeypatch +): + (tmp_path / '.test-agent').mkdir() + config = make_config( + tmp_path, + detection_path_env_override='TEST_CONFIG_DIR', + mcp_config_path='~/.test-agent.json', + ) + monkeypatch.setenv('TEST_CONFIG_DIR', str(tmp_path / 'nonexistent')) + agent = config.detect() + assert agent.mcp_config_file == os.path.expanduser('~/.test-agent.json') + + +def test_mcp_config_path_falls_back_to_home_without_env_override(tmp_path): + (tmp_path / '.test-agent').mkdir() + config = make_config(tmp_path, mcp_config_path='~/.test-agent.json') + agent = config.detect() + assert agent.mcp_config_file == os.path.expanduser('~/.test-agent.json') + + +def test_configure_mcp_new_file(tmp_path): + (tmp_path / '.test-agent').mkdir() + agent = make_config(tmp_path, mcp_config_path='mcp.json').detect() + action, path = agent.configure_mcp_server() + assert action is McpConfigureAction.CONFIGURED + data = json.loads(open(path).read()) + assert data['mcpServers']['aws-mcp']['command'] == 'uvx' + assert 'mcp-proxy-for-aws@latest' in data['mcpServers']['aws-mcp']['args'] + + +def test_configure_mcp_already_configured(tmp_path): + (tmp_path / '.test-agent').mkdir() + agent = make_config(tmp_path, mcp_config_path='mcp.json').detect() + mcp_path = tmp_path / '.test-agent' / 'mcp.json' + mcp_path.write_text( + json.dumps({'mcpServers': {'aws-mcp': {'command': 'custom'}}}) + ) + action, _ = agent.configure_mcp_server() + assert action is McpConfigureAction.ALREADY_CONFIGURED + data = json.loads(mcp_path.read_text()) + assert data['mcpServers']['aws-mcp']['command'] == 'custom' + + +def test_configure_mcp_preserves_other_servers(tmp_path): + (tmp_path / '.test-agent').mkdir() + agent = make_config(tmp_path, mcp_config_path='mcp.json').detect() + mcp_path = tmp_path / '.test-agent' / 'mcp.json' + mcp_path.write_text( + json.dumps({'mcpServers': {'other': {'command': 'foo'}}}) + ) + agent.configure_mcp_server() + data = json.loads(mcp_path.read_text()) + assert data['mcpServers']['other']['command'] == 'foo' + assert 'aws-mcp' in data['mcpServers'] + + +def test_configure_mcp_extra_config_merged(tmp_path): + (tmp_path / '.test-agent').mkdir() + agent = make_config( + tmp_path, + mcp_config_path='mcp.json', + mcp_extra_config={'timeout': 100000}, + ).detect() + agent.configure_mcp_server() + mcp_path = tmp_path / '.test-agent' / 'mcp.json' + data = json.loads(mcp_path.read_text()) + entry = data['mcpServers']['aws-mcp'] + assert entry['timeout'] == 100000 + assert entry['command'] == 'uvx' + + +def test_configure_mcp_server_entry_overrides_default(tmp_path): + (tmp_path / '.test-agent').mkdir() + custom_entry = { + 'type': 'local', + 'command': ['uvx', 'something@latest'], + } + agent = make_config( + tmp_path, + mcp_config_path='mcp.json', + mcp_servers_key='mcp', + mcp_server_entry=custom_entry, + ).detect() + agent.configure_mcp_server() + mcp_path = tmp_path / '.test-agent' / 'mcp.json' + data = json.loads(mcp_path.read_text()) + entry = data['mcp']['aws-mcp'] + assert entry == custom_entry + assert 'args' not in entry + + +def test_configure_mcp_shell_command_runs_when_executable_present(tmp_path): + (tmp_path / '.test-agent').mkdir() + config = make_config( + tmp_path, + mcp_shell_command=['some-cli', 'mcp', 'add', 'aws-mcp'], + ) + agent = config.detect() + with ( + patch( + 'awscli.customizations.agenttoolkit.agents.shutil.which', + return_value='/usr/local/bin/some-cli', + ), + patch( + 'awscli.customizations.agenttoolkit.agents.subprocess.run' + ) as run_mock, + ): + action, detail = agent.configure_mcp_server() + assert action is McpConfigureAction.CONFIGURED + assert detail == 'some-cli' + run_mock.assert_called_once_with( + ['some-cli', 'mcp', 'add', 'aws-mcp'], check=True + ) + + +def test_configure_mcp_shell_command_skipped_when_executable_missing(tmp_path): + (tmp_path / '.test-agent').mkdir() + config = make_config( + tmp_path, + mcp_shell_command=['missing-cli', 'mcp', 'add', 'aws-mcp'], + ) + agent = config.detect() + with ( + patch( + 'awscli.customizations.agenttoolkit.agents.shutil.which', + return_value=None, + ), + patch( + 'awscli.customizations.agenttoolkit.agents.subprocess.run' + ) as run_mock, + ): + action, detail = agent.configure_mcp_server() + assert action is McpConfigureAction.SKIPPED + assert detail == 'missing-cli' + run_mock.assert_not_called() + + +def test_configure_mcp_creates_parent_directories(tmp_path): + (tmp_path / '.test-agent').mkdir() + agent = make_config( + tmp_path, mcp_config_path='nested/dir/mcp.json' + ).detect() + agent.configure_mcp_server() + assert (tmp_path / '.test-agent' / 'nested' / 'dir' / 'mcp.json').exists() + + +def test_configure_mcp_new_file_gets_0600_permissions(tmp_path): + (tmp_path / '.test-agent').mkdir() + agent = make_config(tmp_path, mcp_config_path='mcp.json').detect() + agent.configure_mcp_server() + mcp_path = tmp_path / '.test-agent' / 'mcp.json' + assert oct(mcp_path.stat().st_mode & 0o777) == oct(0o600) + + +def test_configure_mcp_preserves_existing_permissions(tmp_path): + (tmp_path / '.test-agent').mkdir() + mcp_path = tmp_path / '.test-agent' / 'mcp.json' + mcp_path.write_text(json.dumps({'mcpServers': {}})) + os.chmod(mcp_path, 0o644) + agent = make_config(tmp_path, mcp_config_path='mcp.json').detect() + agent.configure_mcp_server() + assert oct(mcp_path.stat().st_mode & 0o777) == oct(0o644) + + +def test_agent_configs_sorted_alphabetically(): + ids = [c.id for c in AGENT_CONFIGS if c.id != 'universal'] + assert ids == sorted(ids) diff --git a/tests/unit/customizations/agenttoolkit/test_configure.py b/tests/unit/customizations/agenttoolkit/test_configure.py new file mode 100644 index 000000000000..9af50c532c33 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_configure.py @@ -0,0 +1,231 @@ +# 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 MagicMock, patch + +import pytest + +from awscli.customizations.agenttoolkit.agents import AgentConfig +from awscli.customizations.agenttoolkit.configure import ( + ConfigureAgentToolkitCommand, +) +from awscli.customizations.exceptions import ConfigurationError +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_session, + make_skill_zip, +) + + +def _make_agent_configs(tmp_path, count=2): + configs = [] + for i in range(count): + agent_dir = tmp_path / f'.agent-{i}' + agent_dir.mkdir() + configs.append( + make_config( + tmp_path, + id=f'agent-{i}', + display_name=f'Agent {i}', + detection_path=str(agent_dir), + mcp_config_path='mcp.json', + ) + ) + return configs + + +def _make_client(skills=None): + """Create a mock client that returns the given default skills.""" + if skills is None: + skills = [] + client = MagicMock() + paginator = MagicMock() + paginator.paginate.return_value = [{'skills': skills}] + client.get_paginator.return_value = paginator + return client + + +def _run(agent_configs, yes_no_return=True, client=None): + stream = StringIO() + session = make_session() + if client is None: + client = _make_client() + cmd = ConfigureAgentToolkitCommand( + session, stream=stream, agent_configs=agent_configs, client=client + ) + with ( + patch( + 'awscli.customizations.agenttoolkit.configure.multiselect_choice', + side_effect=lambda msg, items, **kw: items, + ), + patch( + 'awscli.customizations.agenttoolkit.configure.yes_no_choice', + return_value=yes_no_return, + ), + ): + rc = cmd._run_main(None, None) + return rc, stream + + +def test_no_agents_detected_raises_error(tmp_path): + configs = [ + AgentConfig( + id='missing', + display_name='Missing', + detection_path=str(tmp_path / 'nonexistent'), + ) + ] + with pytest.raises(ConfigurationError): + _run(configs) + + +def test_detection_output(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + configs.append( + AgentConfig( + id='missing', + display_name='Missing Agent', + detection_path=str(tmp_path / 'nope'), + ) + ) + _, stream = _run(configs) + output = stream.getvalue() + assert '\u2713 Agent 0' in output + assert '\u2717 Missing Agent' in output + assert '(not found)' in output + + +def test_mcp_configured_on_yes(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + _run(configs, yes_no_return=True) + mcp_path = tmp_path / '.agent-0' / 'mcp.json' + assert mcp_path.exists() + data = json.loads(mcp_path.read_text()) + assert 'aws-mcp' in data['mcpServers'] + assert data['mcpServers']['aws-mcp']['command'] == 'uvx' + + +def test_mcp_skipped_on_no(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + _run(configs, yes_no_return=False) + mcp_path = tmp_path / '.agent-0' / 'mcp.json' + assert not mcp_path.exists() + + +def test_mcp_already_configured_not_overwritten(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + mcp_path = tmp_path / '.agent-0' / 'mcp.json' + mcp_path.write_text( + json.dumps( + {'mcpServers': {'aws-mcp': {'command': 'custom', 'args': []}}} + ) + ) + _, stream = _run(configs, yes_no_return=True) + data = json.loads(mcp_path.read_text()) + assert data['mcpServers']['aws-mcp']['command'] == 'custom' + assert 'already configured' in stream.getvalue() + + +def test_default_skills_installed(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + zip_bytes, checksum = make_skill_zip() + client = _make_client(skills=[{'name': 'aws-serverless'}]) + with patch( + 'awscli.customizations.agenttoolkit.configure.get_skill_download', + return_value=(zip_bytes, checksum, 'v1'), + ): + _, stream = _run(configs, yes_no_return=True, client=client) + skill_path = ( + tmp_path / '.agent-0' / 'skills' / 'aws-serverless' / 'SKILL.md' + ) + assert skill_path.exists() + assert '[1/1] aws-serverless' in stream.getvalue() + + +def test_default_skills_skipped_on_no(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + client = _make_client(skills=[{'name': 'aws-serverless'}]) + _run(configs, yes_no_return=False, client=client) + skill_path = tmp_path / '.agent-0' / 'skills' / 'aws-serverless' + assert not skill_path.exists() + + +def test_wizard_hides_universal_row_from_detection(tmp_path): + universal_dir = tmp_path / '.agents' / 'skills' + universal_dir.mkdir(parents=True) + real_dir = tmp_path / '.codex' + real_dir.mkdir() + configs = [ + AgentConfig( + id='codex', + display_name='Codex', + detection_path=str(real_dir), + skills_path_override=str(universal_dir), + mcp_config_path='mcp.json', + ), + AgentConfig( + id='universal', + display_name='Universal (Codex)', + detection_path=str(tmp_path / '.agents'), + ), + ] + _, stream = _run(configs) + output = stream.getvalue() + assert '✓ Codex' in output + assert 'Universal' not in output + + +def test_wizard_credits_universal_row_for_shared_path_install(tmp_path): + universal_dir = tmp_path / '.agents' / 'skills' + universal_dir.mkdir(parents=True) + real_dir = tmp_path / '.codex' + real_dir.mkdir() + configs = [ + AgentConfig( + id='codex', + display_name='Codex', + detection_path=str(real_dir), + skills_path_override=str(universal_dir), + mcp_config_path='mcp.json', + ), + AgentConfig( + id='universal', + display_name='Universal (Codex)', + detection_path=str(tmp_path / '.agents'), + ), + ] + zip_bytes, checksum = make_skill_zip() + client = _make_client(skills=[{'name': 'aws-cdk'}]) + with patch( + 'awscli.customizations.agenttoolkit.configure.get_skill_download', + return_value=(zip_bytes, checksum, 'v1'), + ): + _, stream = _run(configs, yes_no_return=True, client=client) + output = stream.getvalue() + assert 'Universal (Codex)' in output + assert (universal_dir / 'aws-cdk' / 'SKILL.md').exists() + + +def test_skill_checksum_failure_reported(tmp_path): + configs = _make_agent_configs(tmp_path, count=1) + zip_bytes, _ = make_skill_zip() + client = _make_client(skills=[{'name': 'aws-bad'}]) + with patch( + 'awscli.customizations.agenttoolkit.configure.get_skill_download', + return_value=(zip_bytes, 'bad' * 21 + 'x', 'v1'), + ): + _, stream = _run(configs, yes_no_return=True, client=client) + assert '[1/1] aws-bad' in stream.getvalue() + skill_path = tmp_path / '.agent-0' / 'skills' / 'aws-bad' + assert not skill_path.exists() diff --git a/tests/unit/customizations/agenttoolkit/test_list_installed_skills.py b/tests/unit/customizations/agenttoolkit/test_list_installed_skills.py new file mode 100644 index 000000000000..7b82a93f89db --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_list_installed_skills.py @@ -0,0 +1,175 @@ +# 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 + +import pytest + +from awscli.customizations.agenttoolkit.list_installed_skills import ( + ListInstalledSkillsCommand, +) +from awscli.customizations.exceptions import ParamValidationError +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_parsed_globals, + make_session, + make_skill, +) + + +def _run_installed(monkeypatch, agent_configs, args=None): + stream = StringIO() + monkeypatch.setattr('sys.stdout', stream) + cmd = ListInstalledSkillsCommand( + make_session(), agent_configs=agent_configs + ) + cmd(args=args or [], parsed_globals=make_parsed_globals()) + return json.loads(stream.getvalue()) + + +def test_list_installed_no_agents(monkeypatch): + result = _run_installed(monkeypatch, []) + assert result['skills'] == [] + + +def test_list_installed_no_skills(tmp_path, monkeypatch): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + result = _run_installed(monkeypatch, configs) + assert result['skills'] == [] + + +def test_list_installed_shows_skills(tmp_path, monkeypatch): + make_skill(tmp_path, '.test-agent', 'aws-serverless') + make_skill(tmp_path, '.test-agent', 'aws-databases') + configs = [make_config(tmp_path)] + result = _run_installed(monkeypatch, configs) + skills = result['skills'] + assert len(skills) == 2 + assert skills[0]['agent'] == 'Test Agent' + assert skills[0]['name'] == 'aws-databases' + assert skills[1]['name'] == 'aws-serverless' + + +def test_list_installed_multiple_agents(tmp_path, monkeypatch): + make_skill(tmp_path, '.agent-a', 'aws-serverless') + make_skill(tmp_path, '.agent-b', 'aws-serverless') + configs = [ + make_config( + tmp_path, + id='a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + ), + make_config( + tmp_path, + id='b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + ), + ] + result = _run_installed(monkeypatch, configs) + clients = [s['agent'] for s in result['skills']] + assert 'Agent A' in clients + assert 'Agent B' in clients + + +def test_list_installed_with_client_filter(tmp_path, monkeypatch): + make_skill(tmp_path, '.agent-a', 'aws-serverless') + make_skill(tmp_path, '.agent-b', 'aws-serverless') + 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_installed(monkeypatch, configs, args=['--agent', 'agent-a']) + clients = [s['agent'] for s in result['skills']] + assert clients == ['Agent A'] + + +def test_list_installed_invalid_agent(tmp_path, monkeypatch): + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='Invalid agent'): + _run_installed(monkeypatch, configs, args=['--agent', 'nonexistent']) + + +def test_list_installed_universal_row_only_detected(tmp_path, monkeypatch): + 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 / '.aws-skill-metadata').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 = _run_installed(monkeypatch, configs) + skills = result['skills'] + assert len(skills) == 1 + assert skills[0]['agent'] == 'Universal (Codex)' + + +def test_list_installed_universal_row_lists_alongside_override_agent( + tmp_path, monkeypatch +): + (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 / '.aws-skill-metadata').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 = _run_installed(monkeypatch, configs) + skills = result['skills'] + assert len(skills) == 1 + assert skills[0]['agent'] == 'Universal (Codex)' diff --git a/tests/unit/customizations/agenttoolkit/test_remove_skill.py b/tests/unit/customizations/agenttoolkit/test_remove_skill.py new file mode 100644 index 000000000000..bacfd2e4f5e9 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_remove_skill.py @@ -0,0 +1,188 @@ +# 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 + +import pytest + +from awscli.customizations.agenttoolkit.agents import SKILL_METADATA_FILENAME +from awscli.customizations.agenttoolkit.remove_skill import RemoveCommand +from awscli.customizations.exceptions import ParamValidationError +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_skill, +) + + +def _run_remove(agent_configs, args): + stream = StringIO() + session = Mock() + session.user_agent_extra = '' + session.emit_first_non_none_response.return_value = None + cmd = RemoveCommand(session, stream=stream, agent_configs=agent_configs) + rc = cmd(args=args, parsed_globals=Mock()) + return rc, stream.getvalue() + + +def test_remove_skill_not_installed(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='not installed'): + _run_remove(configs, ['--skill-name', 'aws-foo']) + + +def test_remove_skill_success(tmp_path): + make_skill(tmp_path, '.test-agent', 'aws-serverless') + configs = [make_config(tmp_path)] + rc, output = _run_remove(configs, ['--skill-name', 'aws-serverless']) + assert rc == 0 + assert 'Removed aws-serverless from Test Agent' in output + assert not ( + tmp_path / '.test-agent' / 'skills' / 'aws-serverless' + ).exists() + + +def test_remove_from_multiple_agents(tmp_path): + make_skill(tmp_path, '.agent-a', 'aws-serverless') + make_skill(tmp_path, '.agent-b', 'aws-serverless') + 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_remove(configs, ['--skill-name', 'aws-serverless']) + assert rc == 0 + assert 'Agent A' in output + assert 'Agent B' in output + assert not (tmp_path / '.agent-a' / 'skills' / 'aws-serverless').exists() + assert not (tmp_path / '.agent-b' / 'skills' / 'aws-serverless').exists() + + +def test_remove_with_client_filter(tmp_path): + make_skill(tmp_path, '.agent-a', 'aws-serverless') + make_skill(tmp_path, '.agent-b', 'aws-serverless') + 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_remove( + configs, + ['--skill-name', 'aws-serverless', '--agent', 'agent-a'], + ) + assert rc == 0 + assert 'Agent A' in output + assert 'Agent B' not in output + assert not (tmp_path / '.agent-a' / 'skills' / 'aws-serverless').exists() + assert (tmp_path / '.agent-b' / 'skills' / 'aws-serverless').exists() + + +def test_remove_skill_dedupes_shared_skills_path(tmp_path): + (tmp_path / '.agent-a').mkdir() + (tmp_path / '.agent-b').mkdir() + shared = tmp_path / 'shared' / 'skills' + skill_dir = shared / 'aws-s3' + 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='a', + display_name='Agent A', + detection_path=str(tmp_path / '.agent-a'), + skills_path_override=str(shared), + ), + make_config( + tmp_path, + id='b', + display_name='Agent B', + detection_path=str(tmp_path / '.agent-b'), + skills_path_override=str(shared), + ), + ] + rc, output = _run_remove(configs, ['--skill-name', 'aws-s3']) + assert rc == 0 + assert output.count('Removed aws-s3') == 1 + assert not (shared / 'aws-s3').exists() + + +def test_remove_skill_universal_row_coexists_with_override_agent(tmp_path): + (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), + ), + ] + rc, output = _run_remove(configs, ['--skill-name', 'aws-cdk']) + assert rc == 0 + assert 'Removed aws-cdk from Universal (Codex)' in output + # Codex shares the skills path; the universal row owns the print line. + assert output.count('Removed aws-cdk') == 1 + assert not (shared / 'aws-cdk').exists() + + +def test_remove_skill_without_marker_refused(tmp_path): + make_skill(tmp_path, '.test-agent', 'hand-rolled', with_marker=False) + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='not installed'): + _run_remove(configs, ['--skill-name', 'hand-rolled']) + assert (tmp_path / '.test-agent' / 'skills' / 'hand-rolled').exists() + + +def test_remove_invalid_client_rejected(tmp_path): + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='Invalid agent'): + _run_remove( + configs, + ['--skill-name', 'aws-serverless', '--agent', 'nonexistent'], + ) diff --git a/tests/unit/customizations/agenttoolkit/test_update_skill.py b/tests/unit/customizations/agenttoolkit/test_update_skill.py new file mode 100644 index 000000000000..0e3cbd2b9a23 --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/test_update_skill.py @@ -0,0 +1,161 @@ +# 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 hashlib +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.update_skill import UpdateSkillCommand +from awscli.customizations.exceptions import ParamValidationError +from tests.unit.customizations.agenttoolkit.utils import ( + make_config, + make_session, + make_skill_zip, +) + + +def _install_skill_at_version(tmp_path, agent_dir, skill_name, version): + skill_dir = tmp_path / agent_dir / 'skills' / skill_name + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / 'SKILL.md').write_text(f'name: {skill_name}\n') + (skill_dir / SKILL_METADATA_FILENAME).write_text( + json.dumps({'version': version}) + ) + + +def _run_update(agent_configs, args, remote_version='v2', zip_bytes=None): + if zip_bytes is not None: + checksum = hashlib.sha256(zip_bytes).hexdigest() + else: + zip_bytes, checksum = make_skill_zip({'SKILL.md': 'test'}) + + mock_client = Mock() + mock_client.meta.endpoint_url = 'https://example.com' + + stream = StringIO() + session = make_session() + + with ( + patch( + 'awscli.customizations.agenttoolkit.update_skill.resolve_latest_version', + return_value=remote_version, + ), + patch( + 'awscli.customizations.agenttoolkit.update_skill.get_skill_download', + return_value=(zip_bytes, checksum, remote_version), + ), + patch( + 'awscli.customizations.agenttoolkit.update_skill.create_client', + return_value=mock_client, + ), + ): + cmd = UpdateSkillCommand( + session, stream=stream, agent_configs=agent_configs + ) + rc = cmd(args=args, parsed_globals=Mock()) + return rc, stream.getvalue() + + +def test_update_skill_outdated(tmp_path): + _install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1') + configs = [make_config(tmp_path)] + rc, output = _run_update( + configs, ['--skill-name', 'aws-s3'], remote_version='v2' + ) + assert rc == 0 + assert 'Updated aws-s3 (v2)' in output + marker = ( + tmp_path + / '.test-agent' + / 'skills' + / 'aws-s3' + / SKILL_METADATA_FILENAME + ) + assert json.loads(marker.read_text()) == {'version': 'v2'} + + +def test_update_skill_already_up_to_date(tmp_path): + _install_skill_at_version(tmp_path, '.test-agent', 'aws-s3', 'v1') + configs = [make_config(tmp_path)] + rc, output = _run_update( + configs, ['--skill-name', 'aws-s3'], remote_version='v1' + ) + assert rc == 0 + assert 'already up to date' in output + + +def test_update_skill_not_installed(tmp_path): + (tmp_path / '.test-agent' / 'skills').mkdir(parents=True) + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='not installed'): + _run_update(configs, ['--skill-name', 'aws-s3']) + + +def test_update_skill_only_outdated_agents_updated(tmp_path): + _install_skill_at_version(tmp_path, '.agent-a', 'aws-s3', 'v1') + _install_skill_at_version(tmp_path, '.agent-b', 'aws-s3', 'v2') + 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( + configs, ['--skill-name', 'aws-s3'], remote_version='v2' + ) + assert rc == 0 + assert 'Updated aws-s3 (v2) to Agent A' in output + assert 'Agent B' not in output + + +def test_update_skill_missing_marker_skipped(tmp_path): + skill_dir = tmp_path / '.test-agent' / 'skills' / 'aws-s3' + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text('test') + configs = [make_config(tmp_path)] + with pytest.raises(ParamValidationError, match='not installed'): + _run_update(configs, ['--skill-name', 'aws-s3'], remote_version='v2') + + +def test_update_skill_removes_orphaned_files(tmp_path): + skill_dir = tmp_path / '.test-agent' / 'skills' / 'aws-s3' + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text('old') + (skill_dir / 'old.md').write_text('removed in v2') + (skill_dir / SKILL_METADATA_FILENAME).write_text( + json.dumps({'version': 'v1'}) + ) + configs = [make_config(tmp_path)] + new_zip = make_skill_zip({'SKILL.md': 'new'})[0] + rc, _ = _run_update( + configs, + ['--skill-name', 'aws-s3'], + remote_version='v2', + zip_bytes=new_zip, + ) + assert rc == 0 + assert (skill_dir / 'SKILL.md').read_text() == 'new' + assert not (skill_dir / 'old.md').exists() diff --git a/tests/unit/customizations/agenttoolkit/utils.py b/tests/unit/customizations/agenttoolkit/utils.py new file mode 100644 index 000000000000..487484008ffc --- /dev/null +++ b/tests/unit/customizations/agenttoolkit/utils.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. +import hashlib +import io +import json +import zipfile +from unittest.mock import Mock + +from awscli.customizations.agenttoolkit.agents import ( + SKILL_METADATA_FILENAME, + AgentConfig, +) + + +def make_parsed_globals(output='json'): + pg = Mock() + pg.output = output + pg.query = None + pg.color = 'off' + pg.no_paginate = False + return pg + + +def make_session(): + session = Mock() + session.user_agent_extra = '' + session.get_config_variable.return_value = 'json' + session.emit_first_non_none_response.return_value = None + return session + + +def make_skill_zip(files=None): + """Create a skill zip and returns (bytes, checksum).""" + if files is None: + files = {'SKILL.md': '# Test Skill\n'} + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + for name, content in files.items(): + zf.writestr(name, content) + zip_bytes = buf.getvalue() + checksum = hashlib.sha256(zip_bytes).hexdigest() + return zip_bytes, checksum + + +def make_skill(tmp_path, agent_name, skill_name, with_marker=True): + skill_dir = tmp_path / agent_name / 'skills' / skill_name + skill_dir.mkdir(parents=True) + (skill_dir / 'SKILL.md').write_text('test') + if with_marker: + (skill_dir / SKILL_METADATA_FILENAME).write_text( + json.dumps({'version': 'v1'}) + ) + + +def make_config(tmp_path, **overrides): + defaults = { + 'id': 'test-agent', + 'display_name': 'Test Agent', + 'detection_path': str(tmp_path / '.test-agent'), + } + defaults.update(overrides) + return AgentConfig(**defaults) diff --git a/tests/unit/customizations/test_multiselect.py b/tests/unit/customizations/test_multiselect.py new file mode 100644 index 000000000000..3d866d3515c8 --- /dev/null +++ b/tests/unit/customizations/test_multiselect.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 pytest +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.output import DummyOutput + +from awscli.customizations.prompts import multiselect_choice + +# ANSI escape sequences for simulating input +UP = '\x1b[A' +DOWN = '\x1b[B' +SPACE = ' ' +ENTER = '\r' +CTRL_C = '\x03' + + +@pytest.fixture +def pipe_input(): + with create_pipe_input() as inp: + yield inp + + +def _run(pipe_input, items, **kwargs): + return multiselect_choice( + '', items, pt_input=pipe_input, pt_output=DummyOutput(), **kwargs + ) + + +def test_enter_returns_all_preselected_by_default(pipe_input): + pipe_input.send_text(ENTER) + assert _run(pipe_input, ['a', 'b', 'c']) == ['a', 'b', 'c'] + + +def test_space_deselects_current_item(pipe_input): + pipe_input.send_text(SPACE + ENTER) + assert _run(pipe_input, ['a', 'b', 'c']) == ['b', 'c'] + + +def test_navigate_down_and_deselect(pipe_input): + pipe_input.send_text(DOWN + SPACE + ENTER) + assert _run(pipe_input, ['a', 'b', 'c']) == ['a', 'c'] + + +def test_navigate_up_wraps_around(pipe_input): + pipe_input.send_text(UP + SPACE + ENTER) + assert _run(pipe_input, ['a', 'b', 'c']) == ['a', 'b'] + + +def test_toggle_on_and_off(pipe_input): + pipe_input.send_text(SPACE + SPACE + ENTER) + assert _run(pipe_input, ['a', 'b', 'c']) == ['a', 'b', 'c'] + + +def test_deselect_all(pipe_input): + pipe_input.send_text(SPACE + DOWN + SPACE + DOWN + SPACE + ENTER) + assert _run(pipe_input, ['a', 'b', 'c']) == [] + + +def test_preselected_none_starts_all_selected(pipe_input): + pipe_input.send_text(ENTER) + assert _run(pipe_input, ['x', 'y'], preselected=None) == ['x', 'y'] + + +def test_preselected_subset(pipe_input): + pipe_input.send_text(ENTER) + assert _run(pipe_input, ['a', 'b', 'c'], preselected={1}) == ['b'] + + +def test_preselected_empty(pipe_input): + pipe_input.send_text(ENTER) + assert _run(pipe_input, ['a', 'b', 'c'], preselected=set()) == [] + + +def test_display_format_does_not_affect_return_value(pipe_input): + pipe_input.send_text(ENTER) + assert _run( + pipe_input, [1, 2, 3], display_format=lambda x: f'item-{x}' + ) == [1, 2, 3] + + +def test_single_item(pipe_input): + pipe_input.send_text(ENTER) + assert _run(pipe_input, ['only']) == ['only'] + + +def test_ctrl_c_raises_keyboard_interrupt(pipe_input): + pipe_input.send_text(CTRL_C) + with pytest.raises(KeyboardInterrupt): + _run(pipe_input, ['a', 'b'])