Home Building a CLI Tool That Hides Complexity: Making APIs Accessible to Non-Technical Teams
Post
Cancel

Building a CLI Tool That Hides Complexity: Making APIs Accessible to Non-Technical Teams

Disclaimer: This story is fictionalized and based on common patterns and challenges encountered in API integration and CLI tool development. While inspired by real-world scenarios, specific details, clients, and situations have been altered to protect sensitive information and illustrate general principles.

How I turned a complex enterprise API into a simple command-line tool that anyone on my team could use


The Problem

“My team needs to query the API, but they don’t need to know how.”

That was the challenge I faced. We had an enterprise API that required OAuth2 authentication, token management, and understanding of nested JSON responses. My team members—project managers, support staff, and analysts—needed access to this data, but they weren’t developers. They shouldn’t have to:

  • Understand OAuth2 flows
  • Manage access tokens
  • Parse complex JSON structures
  • Know which endpoints to call
  • Handle authentication errors
  • Remember API credentials

They just needed to get the data.


The First Attempt: “Just Use curl”

My initial response was to share the API documentation and a few curl examples. Here’s what that looked like:

1
2
3
4
5
6
7
8
9
10
11
# Step 1: Get access token
TOKEN=$(curl -X POST https://api.example.com/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "grant_type=client_credentials" | jq -r '.access_token')

# Step 2: Query the API
curl -X GET "https://api.example.com/v1/accounts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json" | jq

The result? Confusion, frustration, and a lot of questions:

  • “What’s a token?”
  • “Why do I need to run two commands?”
  • “What if the token expires?”
  • “Where do I put my credentials?”
  • “What’s jq?”

I realized I was solving the wrong problem. I wasn’t making the API accessible—I was just documenting its complexity.


The Realization: Hide the Complexity

The solution wasn’t better documentation. It was hiding the complexity entirely.

A good CLI tool should:

  1. Hide what users don’t need to know (authentication, tokens, endpoints)
  2. Expose what users do need (the data they’re looking for)
  3. Guide users when they make mistakes (clear error messages)
  4. Work the way users think (not the way the API works)

So I built a CLI wrapper that transformed this:

1
2
3
# Complex, multi-step process
TOKEN=$(get_token)
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/accounts/ACC-123/service-lines

Into this:

1
2
# Simple, intuitive command
python cli.py terminals --account ACC-123

Building the Tool: Design Principles

1. Command-Based Structure

Instead of exposing API endpoints, I created commands that match what users want to do:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import argparse

def main():
    parser = argparse.ArgumentParser(
        description="Query API data easily",
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    
    subparsers = parser.add_subparsers(dest='command', help='Available commands')
    
    # List accounts
    accounts_parser = subparsers.add_parser('accounts', help='List all accounts')
    
    # List terminals
    terminals_parser = subparsers.add_parser('terminals', help='List terminals')
    terminals_parser.add_argument('--account', required=True, help='Account number')
    
    # Get usage data
    usage_parser = subparsers.add_parser('usage', help='Get usage data')
    usage_parser.add_argument('--account', required=True)
    usage_parser.add_argument('--start-date', help='Start date (YYYY-MM-DD)')
    usage_parser.add_argument('--end-date', help='End date (YYYY-MM-DD)')
    
    args = parser.parse_args()
    # ... execute command

This structure makes the tool discoverable. Users can run python cli.py --help and see all available commands.

2. Automatic Credential Management

The biggest win was hiding authentication entirely. Users never see tokens, never manage credentials, and never deal with expiration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import os
from dotenv import load_dotenv

class APIClient:
    def __init__(self):
        load_dotenv()  # Load from .env file
        self.client_id = os.getenv("API_CLIENT_ID")
        self.client_secret = os.getenv("API_CLIENT_SECRET")
        self._token = None
        self._token_expires_at = 0
    
    def _get_access_token(self):
        """Automatically handle token refresh"""
        if self._token and time.time() < self._token_expires_at:
            return self._token
        
        # Fetch new token
        response = requests.post(
            "https://api.example.com/auth/token",
            data={
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "grant_type": "client_credentials"
            }
        )
        response.raise_for_status()
        data = response.json()
        
        self._token = data["access_token"]
        # Refresh 60 seconds before expiration
        self._token_expires_at = time.time() + data["expires_in"] - 60
        
        return self._token
    
    def get(self, endpoint):
        """Make authenticated request"""
        token = self._get_access_token()
        response = requests.get(
            f"https://api.example.com{endpoint}",
            headers={"Authorization": f"Bearer {token}"}
        )
        response.raise_for_status()
        return response.json()

Now users never think about authentication. It just works.

3. User-Friendly Error Messages

API errors are cryptic. A 401 Unauthorized or 403 Forbidden doesn’t help a non-technical user. I transformed these into actionable messages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def handle_api_error(error):
    """Convert API errors into user-friendly messages"""
    if isinstance(error, requests.exceptions.HTTPError):
        status_code = error.response.status_code
        
        if status_code == 401:
            return "❌ Authentication failed. Please check your credentials in .env file."
        elif status_code == 403:
            return "❌ Access denied. Your account may not have permission for this resource."
        elif status_code == 404:
            return "❌ Resource not found. Please check the account or service line ID."
        elif status_code == 429:
            return "⚠️ Rate limit exceeded. Please wait a moment and try again."
        else:
            return f"❌ API error ({status_code}): {error.response.text}"
    
    elif isinstance(error, requests.exceptions.ConnectionError):
        return "❌ Could not connect to API. Please check your internet connection."
    
    elif isinstance(error, KeyError):
        return f"❌ Unexpected API response format. Missing key: {error}"
    
    else:
        return f"❌ Unexpected error: {str(error)}"

4. Data Processing and Formatting

Raw API responses are messy. I processed the data before showing it to users:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
def get_usage_data(account_number, start_date=None, end_date=None):
    """Get and process usage data"""
    # Fetch raw data from API
    raw_data = client.post(
        f"/accounts/{account_number}/billing-cycles/query",
        data={"previousBillingCycles": 6}
    )
    
    # Process into user-friendly format
    processed = {}
    for service_line in raw_data.get("content", {}).get("results", []):
        sl_id = service_line["serviceLineNumber"]
        
        processed[sl_id] = {
            "total_cap_gb": 0,
            "total_consumed_gb": 0,
            "daily_usage": []
        }
        
        # Aggregate data from multiple billing cycles
        for cycle in service_line.get("billingCycles", []):
            # Calculate totals
            for pool in cycle.get("dataPoolUsage", []):
                for block in pool.get("dataBlocks", []):
                    processed[sl_id]["total_cap_gb"] += block.get("totalAmountGB", 0)
                    processed[sl_id]["total_consumed_gb"] += block.get("consumedAmountGB", 0)
            
            # Extract daily usage
            for daily in cycle.get("dailyDataUsage", []):
                date_str = daily["date"].split("T")[0]  # Extract date part
                
                # Handle data deduplication (priority vs opt-in)
                priority_gb = daily.get("priorityGB", 0)
                optin_gb = daily.get("optInPriorityGB", 0)
                standard_gb = daily.get("standardGB", 0)
                
                # Use max to avoid double-counting
                actual_priority = max(priority_gb, optin_gb)
                daily_total = actual_priority + standard_gb
                
                processed[sl_id]["daily_usage"].append({
                    "date": date_str,
                    "usage_gb": round(daily_total, 2)
                })
        
        # Filter by date range if provided
        if start_date and end_date:
            processed[sl_id]["daily_usage"] = [
                day for day in processed[sl_id]["daily_usage"]
                if start_date <= day["date"] <= end_date
            ]
    
    return processed

Now users get clean, structured data instead of nested JSON.

5. Helpful Output Formatting

I made the output both human-readable and machine-parseable:

1
2
3
4
5
6
7
8
9
10
11
12
import json

def print_results(data, format='json'):
    """Print results in requested format"""
    if format == 'json':
        print(json.dumps(data, indent=2, default=str))
    elif format == 'table':
        # Convert to table format for terminal viewing
        print_table(data)
    elif format == 'csv':
        # Export as CSV
        print_csv(data)

Users can choose what works best for them:

  • --format json for scripts and automation
  • --format table for quick viewing
  • --format csv for Excel analysis

The Evolution: Adding Safety Features

As the tool gained users, I added features based on real feedback:

Dry-Run Mode

For operations that could be destructive (like sending emails or updating data), I added a --dry-run flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def send_report(client_name, dry_run=False):
    """Send usage report to client"""
    report_data = generate_report(client_name)
    email_content = format_email(report_data)
    
    if dry_run:
        # Save to file instead of sending
        preview_file = f"preview_{client_name}.html"
        with open(preview_file, 'w') as f:
            f.write(email_content)
        print(f"✅ Preview saved to {preview_file}")
        print(f"📧 Would send to: {get_recipients(client_name)}")
        return
    
    # Actually send the email
    send_email(email_content, get_recipients(client_name))
    print(f"✅ Report sent to {client_name}")

This gives users confidence before executing potentially risky operations.

Validation and Early Error Detection

I validate inputs before making API calls:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def validate_date(date_string):
    """Validate date format"""
    try:
        datetime.strptime(date_string, '%Y-%m-%d')
        return True
    except ValueError:
        print(f"❌ Invalid date format: {date_string}")
        print("   Expected format: YYYY-MM-DD (e.g., 2025-10-13)")
        return False

def validate_account(account_number):
    """Check if account exists before processing"""
    try:
        accounts = client.accounts.list_accounts()
        account_numbers = [acc["accountNumber"] for acc in accounts]
        
        if account_number not in account_numbers:
            print(f"❌ Account not found: {account_number}")
            print(f"   Available accounts: {', '.join(account_numbers)}")
            return False
        return True
    except Exception as e:
        print(f"❌ Could not validate account: {e}")
        return False

Catching errors early prevents wasted API calls and gives users immediate feedback.

Progress Indicators

For long-running operations, I added progress feedback:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def process_multiple_clients(clients):
    """Process multiple clients with progress indication"""
    total = len(clients)
    
    for i, client in enumerate(clients, 1):
        print(f"\n[{i}/{total}] Processing {client['name']}...")
        
        try:
            data = fetch_client_data(client)
            print(f"   ✅ Fetched {len(data)} records")
        except Exception as e:
            print(f"   ❌ Error: {e}")
            continue
    
    print(f"\n✅ Completed {total} clients")

Users know the tool is working, even when it takes time.


The Result: Team Independence

After building this CLI tool, my team could:

  • Query the API independently without asking me for help
  • Get data on demand without waiting for scheduled reports
  • Explore data without understanding API internals
  • Feel confident using the tool because of clear error messages

Here’s what changed:

Before:

1
2
3
4
5
6
7
Team Member: "Can you get me the usage data for Client X?"

Me: "Sure, let me check... [5 minutes later] Here's the CSV."

Team Member: "Actually, can you also get it for Client Y?"

Me: [sigh] "Okay, one sec..."

After:

1
2
3
Team Member: [runs command]

Team Member: "Got it, thanks!"

The tool became a force multiplier. One person (me) built it, but the entire team could use it.


Key Lessons Learned

1. Hide Complexity, Not Features

A good CLI tool doesn’t remove functionality—it hides the complexity. Users can still access all the data they need, but they don’t have to understand OAuth2, token management, or API endpoint structures.

2. Error Messages Are User Experience

A cryptic error message breaks the user’s flow. A helpful error message teaches them how to fix the problem. Invest time in making errors actionable.

3. Design for the Least Technical User

If your least technical team member can use the tool, everyone can. Design for them, and you’ll build something that’s intuitive for everyone.

4. Iterate Based on Real Usage

I added features like --dry-run, date validation, and progress indicators based on actual user feedback. The tool evolved to match how people actually used it.

5. Documentation Lives in the Tool

Good CLI tools are self-documenting. The --help command should show everything users need. External documentation is supplementary, not primary.

6. Safety Features Build Confidence

Dry-run modes, validation, and previews make users feel safe. When people feel safe, they use the tool more often and more confidently.


The Pattern: A Reusable Approach

Here’s a template you can use for building your own CLI tools:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#!/usr/bin/env python3
"""
CLI Tool Template
Hides API complexity behind simple commands
"""

import argparse
import os
import sys
from dotenv import load_dotenv

# 1. Load credentials automatically
load_dotenv()

# 2. Initialize API client (handles auth internally)
class APIClient:
    def __init__(self):
        self.client_id = os.getenv("API_CLIENT_ID")
        self.client_secret = os.getenv("API_CLIENT_SECRET")
        # ... handle authentication
    
    def get(self, endpoint):
        # ... make authenticated request
        pass

# 3. Process API data into user-friendly format
def process_api_response(raw_data):
    """Transform complex API response into simple structure"""
    # ... processing logic
    return processed_data

# 4. User-friendly error handling
def handle_error(error):
    """Convert technical errors into actionable messages"""
    # ... error handling
    pass

# 5. Command-based CLI structure
def main():
    parser = argparse.ArgumentParser(description="Simple API CLI")
    subparsers = parser.add_subparsers(dest='command')
    
    # Add commands
    list_parser = subparsers.add_parser('list', help='List resources')
    list_parser.add_argument('--type', required=True)
    
    get_parser = subparsers.add_parser('get', help='Get resource')
    get_parser.add_argument('--id', required=True)
    
    args = parser.parse_args()
    
    # Execute command
    try:
        client = APIClient()
        
        if args.command == 'list':
            data = client.get(f"/{args.type}")
            processed = process_api_response(data)
            print(json.dumps(processed, indent=2))
        
        elif args.command == 'get':
            data = client.get(f"/{args.type}/{args.id}")
            processed = process_api_response(data)
            print(json.dumps(processed, indent=2))
    
    except Exception as e:
        print(handle_error(e))
        sys.exit(1)

if __name__ == "__main__":
    main()

Conclusion

Building a CLI tool that hides complexity isn’t about dumbing down the API—it’s about elevating the user experience.

When you hide authentication, process data automatically, and provide clear error messages, you’re not removing functionality. You’re making it accessible.

The best tools are the ones that feel simple to use but are powerful underneath. They let users focus on what they want to do, not how the system works.

My team can now query the API independently, explore data on demand, and feel confident using the tool. That’s the real win—not just a working tool, but an empowered team.


Takeaways

  1. Hide complexity, not features - Users should access everything they need without understanding internals
  2. Error messages are UX - Make errors actionable, not cryptic
  3. Design for the least technical user - If they can use it, everyone can
  4. Iterate based on real usage - Add features based on actual feedback
  5. Self-documenting tools - --help should show everything
  6. Safety builds confidence - Dry-run modes and validation make users feel safe

The goal isn’t to build a tool that does everything—it’s to build a tool that lets your team do everything they need, simply and confidently.

This post is licensed under CC BY 4.0 by the author.