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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ cargo install --git https://github.com/Eraz1997/awsb
## AI Skill 🤖

```shell
mkdir -p ~/<path-to>/skills/awsb
curl -fsSL https://raw.githubusercontent.com/Eraz1997/awsb/main/.opencode/skills/awsb/SKILL.md \
mkdir -p ~/<path-to>/skills/awsb/SKILL.md
curl -fsSL https://raw.githubusercontent.com/Eraz1997/awsb/main/skills/awsb/SKILL.md \
-o ~/<path-to>/skills/awsb/SKILL.md
```

Once installed, any agent will automatically use the skill when you ask it to switch AWS profiles, authenticate with SSO, or manage providers.
Replace `<agent>` with your agent's config directory (e.g. `claude`, `codex`, `opencode`). Once installed, any agent will automatically use the skill when you ask it to switch AWS profiles, authenticate with SSO, or manage providers.

## Usage 🎸

Expand All @@ -30,22 +30,24 @@ awsb <COMMAND> <SUBCOMMAND> --help
awsb <COMMAND> --help

# Set profile as current
awsb use [PROFILE_NAME] # if you don't set PROFILE_NAME, an interactive search menu is shown
awsb use [PROFILE_NAME] # if you don't set PROFILE_NAME, an interactive selection menu is shown

# Manage SSO providers
awsb providers add --name <NAME> --region <REGION> --url <URL>
awsb providers add [--name NAME] [--region REGION] [--url URL]
awsb providers list
awsb providers get <NAME>
awsb providers remove <NAME>
awsb providers rename <NAME> <NEW_NAME>
awsb providers sign-in
awsb providers get [NAME]
awsb providers edit [NAME] # optionally pass --region / --url to skip the prompts
awsb providers remove [NAME]
awsb providers rename [NAME] [NEW_NAME]
awsb providers sign-in [NAME] # signs in with all providers if blank, add -s to pick one interactively

# Manage profiles
awsb profiles add --name <NAME> --provider <PROVIDER> --account-id <ACCOUNT_ID> --role <ROLE>
awsb profiles add [--name NAME] [--provider PROVIDER] [--account-id ACCOUNT_ID] [--role ROLE]
awsb profiles list
awsb profiles get <NAME>
awsb profiles remove <NAME>
awsb profiles rename <NAME> <NEW_NAME>
awsb profiles get [NAME]
awsb profiles edit [NAME] # optionally pass --provider / --account-id / --role to skip the prompts
awsb profiles remove [NAME]
awsb profiles rename [NAME] [NEW_NAME]

# Get AWS access environment variables
awsb print-env-vars
Expand Down
36 changes: 28 additions & 8 deletions .opencode/skills/awsb/SKILL.md → skills/awsb/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ description: Use when the user needs to authenticate to AWS, switch AWS profiles

| Goal | Command |
|---|---|
| Switch active AWS profile / account | `awsb use [PROFILE_NAME]` |
| Switch active AWS profile / account (default, human-driven) | `awsb use [PROFILE_NAME]` |
| Use a profile in agent-driven sessions (no default change) | `AWS_PROFILE=<NAME>` + profile name from `awsb profiles list` |
| Authenticate / refresh SSO credentials | `awsb providers sign-in [PROVIDER_NAME]` |
| List all profiles | `awsb profiles list` |
| List all providers | `awsb providers list` |
Expand All @@ -29,12 +30,31 @@ description: Use when the user needs to authenticate to AWS, switch AWS profiles
| Describe a provider | `awsb providers get <NAME>` |
| Remove a profile | `awsb profiles remove <NAME>` |
| Remove a provider | `awsb providers remove <NAME>` |
| Rename a profile | `awsb profiles rename <NAME> <NEW_NAME>` |
| Rename a provider | `awsb providers rename <NAME> <NEW_NAME>` |
| Edit a profile | `awsb profiles edit [NAME]` |
| Edit a provider | `awsb providers edit [NAME]` |
| Rename a profile | `awsb profiles rename [NAME] [NEW_NAME]` |
| Rename a provider | `awsb providers rename [NAME] [NEW_NAME]` |

## Typical workflows

### Switch to a different AWS account / profile
### Agent-driven sessions: use `AWS_PROFILE` instead of `awsb use`

When an agent needs to run AWS commands as a specific profile, do **not** use `awsb use <PROFILE_NAME>`: it rewrites the `[default]` section in `~/.aws/config`, which is a global change that can clobber the profile a human is actively using in a parallel session.

Instead, set the `AWS_PROFILE` environment variable for the agent's child processes:

```shell
awsb profiles list # find the profile name
AWS_PROFILE=<PROFILE_NAME> aws sts get-caller-identity
# or, to scope it to the whole session:
export AWS_PROFILE=<PROFILE_NAME>
```

This selects the profile per-process only and leaves the user's `[default]` profile untouched.

### Switch the default profile (interactive / human-driven)

Use `awsb use` only when the *default* profile itself should change for the whole machine:

1. List available profiles to find the right name:
```shell
Expand All @@ -61,17 +81,17 @@ description: Use when the user needs to authenticate to AWS, switch AWS profiles
```shell
awsb profiles add --name <NAME> --provider <PROVIDER_NAME> --account-id <ACCOUNT_ID> --role <ROLE_NAME>
```
4. Switch to the new profile:
4. For agent-driven work, set the new profile without changing `[default]`:
```shell
awsb use <NAME>
export AWS_PROFILE=<NAME>
```

### Refresh expired SSO credentials

```shell
awsb providers sign-in
```
Omit the provider name to sign in with all registered providers.
Omit the provider name to sign in with all registered providers. Add `-s` to pick a single provider interactively (`awsb providers sign-in -s`).

### Export credentials as environment variables

Expand All @@ -82,7 +102,7 @@ This sets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN`

## Important notes

- `awsb use` modifies the `[default]` section in `~/.aws/config`, not `~/.aws/credentials`.
- `awsb use` modifies the `[default]` section in `~/.aws/config`, not `~/.aws/credentials`. Prefer `AWS_PROFILE` over `awsb use` in agent-driven sessions so the user's default profile in parallel sessions is never changed.
- Profiles are only valid if their linked provider exists and the provider fields match exactly. If `awsb profiles get <NAME>` returns nothing, the profile config may be inconsistent.
- `awsb providers sign-in` requires the AWS CLI (`aws`) to be installed and available on PATH.
- `awsb print-env-vars` / `awsb copy-env-vars` call `aws configure export-credentials --format env-no-export` under the hood, so valid cached SSO credentials must already exist.
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod copy_env_vars;
pub mod print_env_vars;
pub mod profile;
pub mod prompt;
pub mod provider;
pub mod use_profile;
14 changes: 10 additions & 4 deletions src/commands/profile/add.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
use crate::commands::prompt::{provider_name, value};
use crate::constants::{VALID_ACCOUNT_ID_REGEX, VALID_NAME_REGEX};
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;
use regex::Regex;

pub fn add_profile(
mut config_file_manager: ConfigFileManager,
name: String,
provider: String,
role: String,
account_id: String,
name: Option<String>,
provider: Option<String>,
role: Option<String>,
account_id: Option<String>,
) -> Result<(), Error> {
let name = value("Profile name:", name)?;
let provider = provider_name(&config_file_manager, provider)?;
let account_id = value("AWS account ID:", account_id)?;
let role = value("AWS role name:", role)?;

Regex::new(VALID_NAME_REGEX)
.ok()
.filter(|regex| regex.is_match(name.as_str()))
Expand Down
35 changes: 35 additions & 0 deletions src/commands/profile/edit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use crate::commands::prompt::{edited_value, profile_name};
use crate::constants::VALID_ACCOUNT_ID_REGEX;
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;
use regex::Regex;

pub fn edit_profile(
mut config_file_manager: ConfigFileManager,
name: Option<String>,
provider: Option<String>,
account_id: Option<String>,
role: Option<String>,
) -> Result<(), Error> {
let name = profile_name(&config_file_manager, name)?;
let current = config_file_manager
.get_profile(&name)
.ok_or(Error::NotFound)?;
let provider_name = edited_value("SSO provider:", &current.provider, provider)?;
let account_id = edited_value("AWS account ID:", &current.account_id, account_id)?;
let role = edited_value("AWS role name:", &current.role, role)?;

Regex::new(VALID_ACCOUNT_ID_REGEX)
.ok()
.filter(|regex| regex.is_match(account_id.as_str()))
.ok_or(Error::InvalidAccountID)?;

let provider = config_file_manager
.get_provider(&provider_name)
.ok_or(Error::ProviderNotFound)?;

config_file_manager
.edit_profile(name, provider, account_id, role)
.map(|_| ())
.ok_or(Error::CouldNotEditConfigFile)
}
7 changes: 6 additions & 1 deletion src/commands/profile/get.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
use crate::commands::prompt::profile_name;
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;

pub fn get_profile(config_file_manager: ConfigFileManager, name: String) -> Result<(), Error> {
pub fn get_profile(
config_file_manager: ConfigFileManager,
name: Option<String>,
) -> Result<(), Error> {
let name = profile_name(&config_file_manager, name)?;
if !config_file_manager.get_profile_names().contains(&name) {
return Err(Error::NotFound);
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/profile/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod add;
pub mod edit;
pub mod get;
pub mod list;
pub mod remove;
Expand Down
5 changes: 4 additions & 1 deletion src/commands/profile/remove.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
use crate::commands::prompt::profile_name;
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;

pub fn remove_profile(
mut config_file_manager: ConfigFileManager,
name: String,
name: Option<String>,
) -> Result<(), Error> {
let name = profile_name(&config_file_manager, name)?;

if config_file_manager.get_profile(&name).is_none() {
return Err(Error::NotFound);
}
Expand Down
8 changes: 6 additions & 2 deletions src/commands/profile/rename.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
use crate::commands::prompt::{profile_name, value};
use crate::constants::VALID_NAME_REGEX;
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;
use regex::Regex;

pub fn rename_profile(
mut config_file_manager: ConfigFileManager,
name: String,
new_name: String,
name: Option<String>,
new_name: Option<String>,
) -> Result<(), Error> {
let name = profile_name(&config_file_manager, name)?;
let new_name = value("New profile name:", new_name)?;

Regex::new(VALID_NAME_REGEX)
.ok()
.filter(|regex| regex.is_match(new_name.as_str()))
Expand Down
70 changes: 70 additions & 0 deletions src/commands/prompt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;
use inquire::{Select, Text};

pub fn provider_name(
config_file_manager: &ConfigFileManager,
name: Option<String>,
) -> Result<String, Error> {
resolve_name(
"Select a provider:",
config_file_manager
.get_provider_names()
.into_iter()
.filter(|name| config_file_manager.get_provider(name).is_some())
.collect(),
name,
)
}

pub fn profile_name(
config_file_manager: &ConfigFileManager,
name: Option<String>,
) -> Result<String, Error> {
resolve_name(
"Select a profile:",
config_file_manager
.get_profile_names()
.into_iter()
.filter(|name| config_file_manager.get_profile(name).is_some())
.collect(),
name,
)
}

pub fn value(message: &str, value: Option<String>) -> Result<String, Error> {
match value {
Some(value) => Ok(value),
None => Text::new(message).prompt().map_err(|_| Error::Aborted),
}
}

pub fn edited_value(message: &str, default: &str, value: Option<String>) -> Result<String, Error> {
match value {
Some(value) => Ok(value),
None => Text::new(message)
.with_default(default)
.prompt()
.map_err(|_| Error::Aborted),
}
}

fn resolve_name(
message: &str,
mut names: Vec<String>,
name: Option<String>,
) -> Result<String, Error> {
match name {
Some(name) => Ok(name),
None => {
if names.is_empty() {
return Err(Error::NotFound);
}
names.sort();
Select::new(message, names)
.with_page_size(10)
.prompt()
.map_err(|_| Error::Aborted)
}
}
}
11 changes: 8 additions & 3 deletions src/commands/provider/add.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::commands::prompt::value;
use crate::constants::{VALID_NAME_REGEX, VALID_REGION_REGEX};
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;
Expand All @@ -6,10 +7,14 @@ use url::Url;

pub fn add_provider(
mut config_file_manager: ConfigFileManager,
name: String,
region: String,
url: String,
name: Option<String>,
region: Option<String>,
url: Option<String>,
) -> Result<(), Error> {
let name = value("Provider name:", name)?;
let region = value("SSO region:", region)?;
let url = value("SSO start URL:", url)?;

Url::parse(url.as_str()).map_err(|_| Error::InvalidUrl)?;
Regex::new(VALID_NAME_REGEX)
.ok()
Expand Down
37 changes: 37 additions & 0 deletions src/commands/provider/edit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::commands::prompt::{edited_value, provider_name};
use crate::constants::VALID_REGION_REGEX;
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;
use regex::Regex;
use url::Url;

pub fn edit_provider(
mut config_file_manager: ConfigFileManager,
name: Option<String>,
region: Option<String>,
url: Option<String>,
) -> Result<(), Error> {
let name = provider_name(&config_file_manager, name)?;
let current = config_file_manager
.get_provider(&name)
.ok_or(Error::NotFound)?;
let region = edited_value("AWS region:", &current.region, region)?;
let url = edited_value("SSO start URL:", &current.url, url)?;

Regex::new(VALID_REGION_REGEX)
.ok()
.filter(|regex| regex.is_match(region.as_str()))
.ok_or(Error::InvalidRegion)?;
Url::parse(url.as_str()).map_err(|_| Error::InvalidUrl)?;

if let Some(provider) = config_file_manager.get_provider_by_url(&url) {
if provider.name != name {
return Err(Error::ClashingURL);
}
}

config_file_manager
.edit_provider(name, region, url)
.map(|_| ())
.ok_or(Error::CouldNotEditConfigFile)
}
7 changes: 6 additions & 1 deletion src/commands/provider/get.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
use crate::commands::prompt::provider_name;
use crate::error::Error;
use crate::managers::config_file::ConfigFileManager;

pub fn get_provider(config_file_manager: ConfigFileManager, name: String) -> Result<(), Error> {
pub fn get_provider(
config_file_manager: ConfigFileManager,
name: Option<String>,
) -> Result<(), Error> {
let name = provider_name(&config_file_manager, name)?;
if !config_file_manager.get_provider_names().contains(&name) {
return Err(Error::NotFound);
}
Expand Down
Loading