Lê Duy Khương (Daniel)

Chuỗi: cli-to-agent-native · Phần 2

Năng suất & công cụ dev

Giải phẫu CLI cho agent: 7 nguyên tắc thiết kế

7 nguyên tắc thiết kế CLI cho AI agent: --json output, exit codes có nghĩa, không interactive prompt, schema introspection. Code ví dụ bằng Python (Typer).

2026-03-177 phút đọcVI

English title: Anatomy of an Agent-Ready CLI


Mở đầu

Jeff Dickey — người tạo oclif, CLI framework mà Heroku và Salesforce dùng — từng viết một bài tựa đề "12 Factor CLI Apps". Lấy cảm hứng từ 12-factor web apps của Heroku, ông đặt ra 12 quy tắc cho CLI: documentation đầy đủ, exit code có nghĩa, output đẹp cho terminal nhưng pipeable cho máy.

Bài đó viết năm 2018. Lúc đó, "máy" nghĩa là shell script hoặc CI pipeline.

Năm 2026, "máy" nghĩa là AI agent — Claude, GPT, Gemini — gọi tool bằng subprocess, parse output bằng JSON, và quyết định bước tiếp theo dựa trên exit code. Và hóa ra, phần lớn CLI hiện tại không sẵn sàng cho điều đó.

Justin Poehnelt từ Google viết thẳng: "You need to rewrite your CLI for AI agents." Không phải rewrite từ đầu — nhưng cần thêm vài thứ mà hầu hết developer bỏ qua.

Bài này đi qua 7 nguyên tắc đó.


1. Đi sâu vào chủ đề: Tại sao CLI hiện tại chưa "agent-ready"

Hầu hết CLI được thiết kế cho mắt người. Output là bảng đẹp, progress bar, emoji, màu sắc ANSI. Tất cả những thứ đó — agent không hiểu.

Agent cần ba thứ:

Thứ nhất, output có cấu trúc. Không phải bảng — mà là JSON, YAML, hoặc CSV mà json.loads() parse được.

Thứ hai, exit code có nghĩa. Không chỉ 0 và 1. Exit code 2 = usage error (argument sai). Exit code 3 = resource not found. Agent dùng exit code để branch logic — giống if/else nhưng ở cấp process.

Thứ ba, không hỏi. Khi CLI hiện "Are you sure? (y/n)" — agent treo. Agent không có tay để gõ "y". Mọi input phải qua flag hoặc stdin.


2. Bảy nguyên tắc

Nguyên tắc 1: Dual output — JSON cho máy, text cho người

Mọi command phải hỗ trợ --json (hoặc --output json). Default: text cho terminal. Khi agent gọi, nó thêm --json.

import typer
import json
 
app = typer.Typer()
 
@app.command()
def list_users(
    output_json: bool = typer.Option(False, "--json", help="Output as JSON")
):
    users = [
        {"name": "Alice", "email": "a@x.com", "role": "admin"},
        {"name": "Bob", "email": "b@x.com", "role": "member"},
    ]
 
    if output_json:
        print(json.dumps(users, indent=2))
    else:
        for u in users:
            print(f"  {u['name']:10s} {u['email']:15s} {u['role']}")

Quan trọng: JSON ra stdout, thông báo phụ ra stderr. Agent chỉ đọc stdout.

Nguyên tắc 2: Exit codes có nghĩa

Không chỉ 0 và 1. Quy ước BSD:

import sys
 
EXIT_SUCCESS = 0        # Thành công
EXIT_GENERAL_ERROR = 1  # Lỗi chung
EXIT_USAGE_ERROR = 2    # Argument sai, thiếu flag
EXIT_NOT_FOUND = 3      # Resource không tồn tại
EXIT_PERMISSION = 4     # Không đủ quyền
EXIT_CONFLICT = 5       # Resource đã tồn tại, conflict
 
def handle_error(error_type: str, message: str):
    print(json.dumps({"error": error_type, "message": message}), file=sys.stderr)
    raise SystemExit(EXIT_NOT_FOUND)

Agent đọc exit code trước, rồi mới parse error message. Giống HTTP status code — nhưng cho process.

Nguyên tắc 3: Không interactive prompt

Mọi input qua argument hoặc environment variable. Không input(), không getpass(), không click.confirm().

# SAI — agent treo ở đây
if click.confirm("Delete all users?"):
    delete_all()
 
# ĐÚNG — explicit flag
@app.command()
def delete_users(
    force: bool = typer.Option(False, "--force", help="Skip confirmation")
):
    if not force:
        print("Use --force to confirm deletion", file=sys.stderr)
        raise SystemExit(EXIT_USAGE_ERROR)
    delete_all()

Nguyên tắc 4: Schema introspection

Tool tự mô tả input/output schema. Agent đọc schema trước khi gọi — biết cần truyền gì, nhận lại gì.

@app.command()
def schema():
    """Print JSON schema for all commands."""
    schemas = {
        "list_users": {
            "input": {
                "type": "object",
                "properties": {
                    "role": {"type": "string", "enum": ["admin", "member", "viewer"]},
                    "limit": {"type": "integer", "default": 50}
                }
            },
            "output": {
                "type": "array",
                "items": {"$ref": "#/definitions/User"}
            }
        }
    }
    print(json.dumps(schemas, indent=2))

Đây là khác biệt lớn nhất giữa CLI truyền thống và agent-ready CLI. --help cho người, --schema cho agent.

Nguyên tắc 5: Stderr cho side effects, stdout cho data

import sys
 
def create_user(name: str, output_json: bool = False):
    # Side effects → stderr (agent bỏ qua)
    print(f"Creating user {name}...", file=sys.stderr)
 
    user = {"id": 42, "name": name, "created": True}
 
    # Data → stdout (agent parse)
    if output_json:
        print(json.dumps(user))
    else:
        print(f"Created user {name} (id=42)")

Quy tắc đơn giản: nếu agent cần parse nó — stdout. Nếu chỉ cho người đọc — stderr.

Nguyên tắc 6: Idempotency

Gọi cùng command 2 lần liên tiếp — kết quả giống nhau. Agent hay retry khi timeout. Nếu command không idempotent, agent có thể tạo duplicate.

@app.command()
def ensure_user(name: str, email: str, output_json: bool = False):
    """Create user if not exists. Idempotent."""
    existing = find_user_by_email(email)
    if existing:
        result = {"action": "already_exists", "user": existing}
    else:
        user = create_user(name, email)
        result = {"action": "created", "user": user}
 
    if output_json:
        print(json.dumps(result))

action field cho agent biết chuyện gì đã xảy ra — không cần đoán.

Nguyên tắc 7: Documentation-as-interface

Agent không đọc README. Agent đọc --help--schema. Nhưng có một layer nữa — Skills file: static markdown mô tả tool, workflow, và ví dụ.

# SKILL: User Management CLI
 
## Commands
- `myapp list-users --json` — List all users
- `myapp ensure-user --name Alice --email a@x.com --json` — Create or find user
- `myapp delete-users --force --json` — Delete all users
 
## Workflow: Onboard new team member
1. `myapp ensure-user --name "{{name}}" --email "{{email}}" --json`
2. `myapp assign-role --user-id {{id}} --role member --json`
3. `myapp send-welcome --user-id {{id}}`

Skills doc tốn ~200 tokens. So với MCP server load toàn bộ schema (~55,000 tokens), đây là 99.6% reduction. Block Engineering gọi đây là "agent skills" — và nó đang trở thành pattern phổ biến.


3. Code sample: CLI hoàn chỉnh với Typer

Gộp 7 nguyên tắc vào một CLI nhỏ:

#!/usr/bin/env python3
"""Agent-ready CLI example using Typer."""
import json
import sys
import typer
 
app = typer.Typer(help="User management CLI — agent-ready")
 
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_USAGE = 2
EXIT_NOT_FOUND = 3
 
 
def output_result(data, as_json: bool):
    if as_json:
        print(json.dumps(data, indent=2, default=str))
    else:
        for key, value in data.items():
            print(f"  {key}: {value}")
 
 
@app.command()
def get_user(
    user_id: int = typer.Argument(..., help="User ID"),
    json_output: bool = typer.Option(False, "--json"),
):
    """Get user by ID."""
    # Simulate lookup
    if user_id == 404:
        print(json.dumps({"error": "not_found", "id": user_id}), file=sys.stderr)
        raise SystemExit(EXIT_NOT_FOUND)
 
    user = {"id": user_id, "name": "Alice", "role": "admin"}
    output_result(user, json_output)
 
 
@app.command()
def schema():
    """Print JSON schema for agent discovery."""
    print(json.dumps({
        "get_user": {
            "input": {"user_id": {"type": "integer", "required": True}},
            "output": {"type": "object", "properties": {
                "id": {"type": "integer"},
                "name": {"type": "string"},
                "role": {"type": "string"}
            }}
        }
    }, indent=2))
 
 
if __name__ == "__main__":
    app()

Agent gọi:

# Discovery
myapp schema | jq '.get_user.input'
 
# Invocation
myapp get-user 42 --json
 
# Error handling
myapp get-user 404 --json; echo "Exit code: $?"
# Exit code: 3

4. Workflow: Checklist khi thiết kế CLI cho agent

Trước khi ship CLI, chạy checklist này:

  1. Mọi command có --json flag không?
  2. Exit codes phân biệt ít nhất 4 trường hợp (success, error, usage, not_found)?
  3. Không có input() hoặc confirm() nào — mọi thứ qua flag?
  4. --schema command trả JSON schema không?
  5. Data ra stdout, log/message ra stderr?
  6. Commands quan trọng idempotent không?
  7. Có Skills doc (markdown) mô tả workflow không?

Nếu 7/7: CLI của bạn agent-ready. Agent có thể gọi nó ngay bằng subprocess.run() — không cần MCP, không cần SDK, không cần gì thêm.


5. Ứng dụng trong AI-centric engineering

Những CLI nổi tiếng đã áp dụng các nguyên tắc này:

GitHub CLI (gh): Mọi command hỗ trợ --json với field selector. gh pr list --json number,title,author — agent chỉ lấy field cần thiết. Exit codes phân biệt rõ ràng. Không interactive prompt khi detect non-TTY (piped output).

Stripe CLI: Structured JSON output. Webhook testing là first-class feature — agent dùng để test payment flow tự động.

kubectl: --output json, --output yaml, --dry-run=client. Idempotent qua kubectl apply. Agent dùng kubectl để manage Kubernetes cluster mà không cần Kubernetes SDK.

Pattern chung: CLI lớn nào cũng phải hỗ trợ agent — vì CI/CD pipeline chính là "agent đời đầu". Bạn đã viết CLI cho GitHub Actions / Jenkins. Bây giờ bạn viết CLI cho Claude.

Bài tiếp, chúng ta sẽ đi vào adapter pattern — tầng trung gian giữa API và CLI, nơi auth, pagination, error mapping, và output formatting được xử lý. Đây là kiến trúc mà GitHub CLI, Stripe CLI, và hầu hết CLI chuyên nghiệp đều dùng.


Bài tiếp

Bài 3: API-First, CLI-Second — The Adapter Pattern — Tầng trung gian giữa API và CLI: auth, pagination, error mapping, output formatting. Tại sao Stripe CLI, GitHub CLI đều dùng adapter. Code pattern bằng Python.

LDK

Le Duy Khuong

AI Transformation & Digital Strategy. Writing about agentic systems, engineering leadership, and building in public.