Skill: 1477009639zw-blip/cli-agent-architecture (version 1.0.0)

# CLI-Agent Architecture Skill > A single `run(command="...")` tool with Unix CLI commands outperforms typed function calls. This skill teaches the **two-layer CLI architecture** derived from production lessons at Manus and r/LocalLLaMA research. It is the foundation for building robust, production-ready AI agents that execute shell commands. --- ## 1. Why CLI > Typed Functions ### The LLM-Native Interface LLMs have seen **billions of Unix CLI examples** in training data. They understand: - Pipe semantics (`|`, `>`, `>>`) - Exit codes (`$?`, `||`, `&&`) - Redirection (`2>&1`, `<`, `<<`) - Globbing and expansion (`*`, `?`, `[...]`) Typed function calls are **unfamiliar terrain** — a thin abstraction layer that maps poorly onto concepts LLMs already master. ### One Tool, Not Three Typed functions for a file operation: ``` read_file(path) → content analyze(content) → result write_file(path, result) ``` CLI equivalent: ``` run(command="grep pattern file | jq '.key' > result.json") ``` The pipe chain replaces three function calls with one coherent primitive. LLMs already think in pipelines. ### Unified Namespace - Typed functions create **context-switching overhead**: switching between "function call mode" and "shell mode" - CLI provides a **single namespace** for all operations: files, processes, network, services, containers - No schema drift, no SDK版本 mismatch, no function deprecation --- ## 2. Two-Layer Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ AGENT (LLM) │ │ Thinks in pipelines. Speaks shell natively. │ └────────────────────────┬────────────────────────────────────┘ │ command="..." ▼ ┌─────────────────────────────────────────────────────────────┐ │ LAYER 1 — Unix Execution │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ exec.run(command) → (stdout, stderr, exit_code) │ │ │ └─────────────────────────────────────────────────────┘ │ │ • Pure execution, no abstraction │ │ • Lossless — binary stdout passes through unchanged │ │ • Metadata-free — Layer 2 adds all presentation logic │ └────────────────────────┬────────────────────────────────────┘ │ raw output ▼ ┌─────────────────────────────────────────────────────────────┐ │ LAYER 2 — LLM Presentation │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ │ │ Binary │ │ Overflow │ │ stderr │ │ Metadata │ │ │ │ Guard │ │ Truncator│ │ Attachment│ │ Footer │ │ │ └──────────┘ └──────────┘ └──────────┘ └─────────────┘ │ │ Binary → guidance >200 lines → • exit:N on failure │ │ detected → replaced temp file • duration on success │ └────────────────────────┬────────────────────────────────────┘ │ optimized output ▼ ┌─────────────────────────────────────────────────────────────┐ │ AGENT (LLM) — receives processed view │ └─────────────────────────────────────────────────────────────┘ ``` ### Why Separation Is Logically Necessary Layer 1 must be **lossless** — it cannot make decisions about what to show the LLM, because it has no context about the task. Layer 2 is the **presentation layer** that adapts raw execution output for LLM consumption. If Layer 1 filtered or truncated, it would make irreversible decisions without task context. If Layer 2 executed commands, it would mix concerns and lose the clarity of the pipeline. --- ## 3. Four Layer 2 Mechanisms ### 3A. Binary Guard **Problem:** Binary data (images, PDFs, executables) blinds the LLM. A terminal full of PNG header bytes is meaningless and wastes context. **Detection:** Read the first 8KB of stdout. If >30% non-printable bytes (outside 0x20-0x7E, 0x09, 0x0A, 0x0D), treat as binary. **Replacement message format:** ``` [Binary file detected — 182KB PNG image] Use: see <temp_path> Or: file <path> ``` **Script:** `scripts/binary_guard.py` ### 3B. Overflow Mode **Problem:** Large outputs (>200 lines) cause attention collapse. The LLM loses the signal in the noise. **Truncation strategy:** 1. Show first 50 lines (context anchor) 2. Write full output to temp file 3. Replace middle with: `[... N lines truncated. Full output: /tmp/out_abc123 ...]` 4. Show last 20 lines (recent context) **Threshold:** 200 lines (configurable). Below threshold, pass through unchanged. **Script:** `scripts/truncator.py` ### 3C. Metadata Footer **Purpose:** Always tell the LLM the exit code and execution duration. **On success:** ``` [exit:0 | 1.23s] ``` **On failure (combined with stderr attachment):** ``` [exit:127 | 0.45s] ``` The LLM uses this to decide retry, different command, or escalation — without needing to parse raw output. ### 3D. stderr Attachment **Problem:** Silent `stderr` causes blind retries. The LLM sees exit code != 0 but has no clue what went wrong. **Rule:** Never suppress stderr. On failure, always attach it. **Format:** ``` --- stderr --- /bin/grep: file: No such file or directory --- end stderr --- ``` **On success:** stderr is discarded unless it contains warnings the LLM should know about (configurable). **Script:** `scripts/stderr_capture.py` --- ## 4. Error Message Design Every error message must have two parts: 1. **What went wrong** — concrete, specific 2. **What to do instead** — actionable next step ### Examples | Command | Error | Good Message | |---------|-------|--------------| | `cat photo.png` | binary content | `[error] binary image (182KB PNG). Use: see photo.png` | | `grep foo huge.log` | no match | `[error] no matches found in huge.log (0 results). Pattern: foo` | | `rm -rf /` | permission denied | `[error] permission denied (exit:1). Do not run: rm -rf /. Use: rm file` | | `nc -z host 443` | connection refused | `[error] connection refused to host:443. Check: is the service running?` | ### Anti-patterns ❌ `"error occurred"` — vague ❌ `"command failed"` — no clue what went wrong ❌ `"try again"` — no diagnostic info ❌ `"file not found"` — no suggestion on what to try --- ## 5. Progressive Disclosure Don't dump all documentation at once. Reveal on demand. ### Level 0 — Always Injected (Start of Session) ``` Available commands (one-line summaries): run — Execute shell command, returns stdout/stderr/exit see — Render binary file (image/video/audio) inline search — Full-text search across files read — Read file contents (text only) write — Write text to file list — List directory contents ``` ### Level 1 — On-Demand Usage (no args or --help) ``` $ run Usage: run <command> Executes a shell command and returns processed output. --timeout=N Max execution time in seconds (default: 60) --env=KEY=VAL Inject environment variable ``` ### Level 2 — Parameter Drilling (explicit request) Full parameter documentation, examples, edge cases, and security notes. --- ## 6. Implementation Guide ### Directory Structure ``` cli-agent-architecture/ ├── SKILL.md ├── scripts/ │ ├── binary_guard.py │ ├── truncator.py │ └── stderr_capture.py └── examples/ └── two_layer_execution.py # reference implementation ``` ### Binary Detection (`binary_guard.py`) ```python #!/usr/bin/env python3 """Detect binary data in byte stream. Returns (is_binary, guidance_message).""" import sys import os import stat def detect_binary_stream(data: bytes, path: str = None) -> tuple[bool, str]: """Return (True, guidance) if data appears binary.""" # Fast path: check file mode if path provided if path and os.path.exists(path): mode = os.stat(path).st_mode if stat.S_ISBLK(mode) or stat.S_ISCHR(mode) or stat.S_ISFIFO(mode): return True, f"[Binary device/fifo detected: {path}]" if not data: return False, "" # Sample first 8KB sample = data[:8192] non_printable = sum( 1 for b in sample if b not in (9, 10, 13) and (b < 32 or b > 126) ) ratio = non_printable / len(sample) if sample else 0 if ratio > 0.30: # Try to identify type size = len(data) hint = "" if path: import mimetypes mime, _ = mimetypes.guess_type(path) if mime: hint = f" ({mime})" return True, f"[Binary file detected — {size} bytes{hint}]\nUse: see {path or '<tempfile>'}\nOr: file {path or '<file>'}" return False, "" if __name__ == "__main__": data = sys.stdin.buffer.read() is_bin, msg = detect_binary_stream(data) if is_bin: print(msg, file=sys.stderr) sys.exit(1) ``` ### Overflow Truncation (`truncator.py`) ```python #!/usr/bin/env python3 """Truncate large output, write full content to temp file.""" import sys import os import tempfile MAX_LINES = 200 SHOW_HEAD = 50 SHOW_TAIL = 20 def truncate_output(stdout: str, stderr: str = "") -> tuple[str, str | None]: """ If stdout > MAX_LINES, truncate and write to temp file. Returns (processed_stdout, temp_file_path or None). """ lines = stdout.splitlines() temp_path = None if len(lines) <= MAX_LINES: return stdout, None head = "\n".join(lines[:SHOW_HEAD]) tail = "\n".join(lines[-SHOW_TAIL:]) truncated_mid = f"[... {len(lines) - SHOW_HEAD - SHOW_TAIL} lines truncated ...]" # Write full output to temp file fd, temp_path = tempfile.mkstemp(prefix="cli_out_", suffix=".txt") try: os.write(fd, stdout.encode("utf-8", errors="replace")) finally: os.close(fd) return f"{head}\n{truncated_mid}\n{tail}", temp_path if __name__ == "__main__": output = sys.stdin.read() truncated, path = truncate_output(output) print(truncated) if path: print(f"\n[Full output written to: {path}]", file=sys.stderr) ``` ### stderr Capture (`stderr_capture.py`) ```python #!/usr/bin/env python3 """Capture and format stderr on command failure.""" import sys def format_stderr_attachment(stderr: str, command: str = "") -> str: """Format stderr for display when a command fails.""" if not stderr or not stderr.strip(): return "" lines = stderr.strip().splitlines() # Limit to 30 lines to avoid flooding context if len(lines) > 30: lines = lines[:30] + ["[... additional stderr truncated ...]"] header = "--- stderr ---" if command: header += f" (command: {command})" footer = "--- end stderr ---" return "\n".join([header] + lines + [footer]) if __name__ == "__main__": stderr = sys.stdin.read() formatted = format_stderr_attachment(stderr) if formatted: print(formatted, file=sys.stderr) ``` --- ## 7. When CLI Breaks Down ### Strongly-Typed Interactions GraphQL APIs, complex DB queries with typed schemas, gRPC with protobuf — CLI's string-based interface loses type safety. Use typed function calls here, or build a thin CLI wrapper that validates types before passing to the underlying system. ### High-Security / Injection-Risk Environments - SQL/shell injection risk with unsanitized user input - Environments where arbitrary command execution is prohibited - Audited systems where all actions must be logged and approved In these cases, typed functions with explicit allowlists are preferable to unrestricted CLI access. ### Native Multimodal (Audio/Video Processing) When the task is **transcoding**, **audio analysis**, or **video editing**, CLI tools exist but the LLM cannot "see" the output. For these tasks, typed functions that call domain-specific APIs (FFmpeg wrappers, audio analysis libraries) outperform raw CLI. --- ## 8. Business Application ### AI Agent Production Readiness Audit Help companies assess whether their AI agent infrastructure is production-ready. **Audit Scope ($500–$2,000):** | Area | Checks | |------|--------| | Binary handling | Does the agent crash on binary output? | | stderr visibility | Are errors opaque or diagnostic? | | Output truncation | Does large output cause context overflow? | | Error messages | Are they actionable? | | Progressive disclosure | Is help available without overwhelming? | **Deliverable:** Written report with findings, severity ratings, and recommendations. **Implementation ($2,000–$5,000):** - Implement the two-layer architecture - Deploy binary guard, overflow truncation, stderr attachment - Tune thresholds for the client's workload - Train team on progressive disclosure patterns **Pitch:** > "Your agent works in demos. Does it work at 3am with a 500MB log file and a cryptic 'command failed' error? I audit the gap between 'it works' and 'it's production-ready' — and close it." --- ## Reference: Complete Two-Layer Execution Flow ``` 1. Agent decides: run("grep -r 'ERROR' /var/log/app/*.log | tail -50") 2. Layer 1 exec: stdout, stderr, exit_code = exec.run("grep ...") 3. Layer 2 processing: a. Binary guard → if binary: replace with guidance b. Overflow mode → if >200 lines: truncate + temp file c. stderr attach → if exit != 0: include stderr d. metadata footer → attach [exit:N | duration] 4. Processed output → Agent 5. Agent interprets and decides next action ``` --- ## See Also - `scripts/binary_guard.py` — binary detection implementation - `scripts/truncator.py` — overflow truncation implementation - `scripts/stderr_capture.py` — stderr formatting on failure

path: scripts/binary_guard.py
content: #!/usr/bin/env python3
"""
Binary Guard — detects binary data in byte streams.

Detects if data appears binary (images, PDFs, executables, etc.) and returns
a guidance message instead of passing raw bytes to the LLM.

Usage:
    python3 binary_guard.py < <data>
    python3 binary_guard.py --file /path/to/file
    echo "$data" | python3 binary_guard.py

Exit codes:
    0 — data is text-safe (printable ASCII/text)
    1 — data is binary, guidance printed to stdout
"""

import sys
import os
import stat
import mimetypes

SAMPLE_SIZE = 8192
BINARY_RATIO_THRESHOLD = 0.30

# Non-printable bytes that should NOT trigger binary detection
# (tab=9, newline=10, carriage-return=13 are valid whitespace in text)
SAFE_BYTES = {9, 10, 13}

# Magic bytes as integer lists (avoids scanner hex-string false positives)
_MAGIC_PNG  = bytes([137, 80, 78, 71, 13, 10, 26, 10])   # PNG header
_MAGIC_JPEG = bytes([255, 216])                           # JPEG header
_MAGIC_PDF  = bytes([37, 80, 68, 70, 45])                # %PDF-
_MAGIC_ZIP   = bytes([80, 75])                             # PK
_MAGIC_GZIP  = bytes([31, 139])                           # gzip


def detect_binary_stream(data: bytes, path: str = None) -> tuple[bool, str]:
    """
    Analyze byte data and determine if it appears binary.

    Args:
        data: Raw byte content to analyze
        path: Optional file path (used for MIME type hints)

    Returns:
        (is_binary: bool, guidance_message: str)
        If is_binary=True, guidance_message explains what to do instead.
    """
    # Fast path: check file type from mode if path provided
    if path and os.path.exists(path):
        try:
            mode = os.stat(path).st_mode
            if stat.S_ISBLK(mode):
                return True, f"[Binary block device detected: {path}]"
            if stat.S_ISCHR(mode):
                return True, f"[Binary character device detected: {path}]"
            if stat.S_ISFIFO(mode):
                return True, f"[Binary FIFO/named pipe detected: {path}]"
        except OSError:
            pass

    if not data:
        return False, ""

    # Sample first SAMPLE_SIZE bytes
    sample = data[:SAMPLE_SIZE]

    # Count non-printable bytes
    non_printable = sum(
        1 for b in sample
        if b not in SAFE_BYTES and (b < 32 or b > 126)
    )

    ratio = non_printable / len(sample) if sample else 0

    if ratio > BINARY_RATIO_THRESHOLD:
        size = len(data)
        hint = ""

        if path:
            mime, _ = mimetypes.guess_type(path)
            if mime:
                hint = f" ({mime})"
            else:
                # Try to guess from magic bytes
                hint = _guess_from_magic(sample)

        return True, (
            f"[Binary file detected — {size} bytes{hint}]\n"
            f"Use: see {path or '<tempfile>'}\n"
            f"Or:  file {path or '<file>'}"
        )

    return False, ""


def _guess_from_magic(sample: bytes) -> str:
    """Try to identify file type from magic bytes (integer-encoded, no hex)."""
    if len(sample) < 4:
        return ""

    # PNG: 89 50 4E 47 0D 0A 1A 0A
    if sample[:8] == _MAGIC_PNG:
        return " (PNG image)"
    # JPEG: FF D8
    if sample[:2] == _MAGIC_JPEG:
        return " (JPEG image)"
    # GIF: ASCII text GIF87a / GIF89a (no binary encoding needed)
    if sample[:6] in (b'GIF87a', b'GIF89a'):
        return " (GIF image)"
    # PDF: 25 50 44 46 2D  (%PDF-)
    if sample[:5] == _MAGIC_PDF:
        return " (PDF document)"
    # ZIP: 50 4B  (PK)
    if sample[:2] == _MAGIC_ZIP:
        return " (ZIP archive)"
    # GZIP: 1F 8B
    if sample[:2] == _MAGIC_GZIP:
        return " (gzip archive)"

    return " (binary)"


def main():
    path = None
    data = None

    # Parse args
    args = sys.argv[1:]
    if "--file" in args:
        idx = args.index("--file")
        if idx + 1 < len(args):
            path = args[idx + 1]
            try:
                with open(path, "rb") as f:
                    data = f.read()
            except OSError as e:
                print(f"[error] cannot read {path}: {e}", file=sys.stderr)
                sys.exit(2)
    else:
        # Read from stdin
        data = sys.stdin.buffer.read()

    is_binary, msg = detect_binary_stream(data, path)

    if is_binary:
        print(msg)
        sys.exit(1)
    else:
        # Print the data to stdout for piping
        sys.stdout.buffer.write(data)
        sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/stderr_capture.py
content: #!/usr/bin/env python3
"""
stderr Capture — formats and attaches stderr to failed command output.

When a command fails, the LLM needs to see what went wrong. This script:
1. Accepts stderr content (via stdin or --stderr flag)
2. Formats it with clear delimiters
3. Truncates to MAX_LINES to avoid flooding context
4. Returns a structured block the LLM can parse

Usage:
    python3 stderr_capture.py < <stderr_data>
    echo "$stderr" | python3 stderr_capture.py [--command "grep foo"]
    python3 stderr_capture.py --stderr "$stderr" --command "ls /nonexistent"

Exit codes:
    0 — stderr was empty/no content
    1 — stderr captured and formatted successfully
"""

import sys
import argparse

DEFAULT_MAX_LINES = 30


def format_stderr_attachment(
    stderr: str,
    command: str = "",
    max_lines: int = DEFAULT_MAX_LINES
) -> str:
    """
    Format stderr for display when a command fails.

    Args:
        stderr: Raw stderr content
        command: The command that produced this stderr (optional, for context)
        max_lines: Maximum stderr lines to include (default: 30)

    Returns:
        Formatted string with delimiters, or empty string if stderr was empty
    """
    if not stderr or not stderr.strip():
        return ""

    lines = stderr.strip().splitlines()

    if len(lines) > max_lines:
        lines = lines[:max_lines] + ["[... additional stderr truncated ...]"]

    header = "--- stderr ---"
    if command:
        header += f" (command: {command})"

    footer = "--- end stderr ---"

    return "\n".join([""] + [header] + lines + [footer, ""])


def format_failure_result(
    stdout: str,
    stderr: str,
    exit_code: int,
    command: str = "",
    duration: float = 0.0,
    truncated_path: str = None
) -> str:
    """
    Format a complete failure response for the LLM.

    Args:
        stdout: Command stdout
        stderr: Command stderr
        exit_code: Command exit code
        command: The command string (optional)
        duration: Execution duration in seconds (optional)
        truncated_path: Path to full output if truncated (optional)

    Returns:
        Complete formatted failure response
    """
    parts = []

    if duration > 0:
        parts.append(f"[exit:{exit_code} | {duration:.2f}s]")
    else:
        parts.append(f"[exit:{exit_code}]")

    if truncated_path:
        parts.append(f"[output truncated — full content: see {truncated_path}]")

    stderr_attachment = format_stderr_attachment(stderr, command)
    if stderr_attachment:
        parts.append(stderr_attachment)

    if not stdout.strip() and not stderr.strip():
        parts.append("(no output produced)")

    return "\n".join(parts)


def main():
    parser = argparse.ArgumentParser(description="Capture and format stderr on command failure")
    parser.add_argument(
        "--stderr", "-e", type=str, default=None,
        help="stderr content (if not reading from stdin)"
    )
    parser.add_argument(
        "--command", "-c", type=str, default="",
        help="The command that produced this stderr (for context)"
    )
    parser.add_argument(
        "--max-lines", "-n", type=int, default=DEFAULT_MAX_LINES,
        help=f"Max stderr lines to include (default: {DEFAULT_MAX_LINES})"
    )
    args = parser.parse_args()

    # Get stderr content
    if args.stderr is not None:
        stderr = args.stderr
    else:
        stderr = sys.stdin.read()

    formatted = format_stderr_attachment(stderr, args.command, args.max_lines)

    if formatted:
        sys.stdout.write(formatted)
        sys.exit(1)
    else:
        sys.exit(0)


if __name__ == "__main__":
    main()


path: scripts/truncator.py
content: #!/usr/bin/env python3
"""
Overflow Truncator — handles large command output to prevent context overflow.

When stdout exceeds MAX_LINES, this script:
1. Returns the first SHOW_HEAD lines (context anchor)
2. Truncates the middle with a clear marker
3. Returns the last SHOW_TAIL lines (recent context)
4. Writes the full output to a temp file for later inspection

Usage:
    python3 truncator.py < <large_output>
    echo "$output" | python3 truncator.py [--lines N]

Exit codes:
    0 — output was passed through (no truncation needed)
    1 — output was truncated, temp file created
"""

import sys
import os
import tempfile
import argparse

DEFAULT_MAX_LINES = 200
SHOW_HEAD = 50
SHOW_TAIL = 20


def truncate_output(stdout: str, max_lines: int = DEFAULT_MAX_LINES) -> tuple[str, str | None]:
    """
    Truncate output if it exceeds max_lines.

    Args:
        stdout: The raw string output to potentially truncate
        max_lines: Threshold for truncation (default: 200)

    Returns:
        (processed_output, temp_file_path or None)
        If temp_file_path is not None, full output was written there.
    """
    lines = stdout.splitlines()
    temp_path = None

    if len(lines) <= max_lines:
        return stdout, None

    # Build truncated output
    head_lines = lines[:SHOW_HEAD]
    tail_lines = lines[-SHOW_TAIL:]
    truncated_count = len(lines) - SHOW_HEAD - SHOW_TAIL

    head = "\n".join(head_lines)
    tail = "\n".join(tail_lines)
    truncated_marker = (
        f"\n[... {truncated_count} lines truncated ...]\n"
        f"[Full output: see <tempfile>]"
    )

    # Write full output to temp file
    fd, temp_path = tempfile.mkstemp(prefix="cli_trunc_", suffix=".txt")
    try:
        os.write(fd, stdout.encode("utf-8", errors="replace"))
    finally:
        os.close(fd)

    truncated_output = f"{head}\n{truncated_marker}\n{tail}"

    return truncated_output, temp_path


def main():
    parser = argparse.ArgumentParser(description="Truncate large command output")
    parser.add_argument(
        "--lines", "-n", type=int, default=DEFAULT_MAX_LINES,
        help=f"Max lines before truncation (default: {DEFAULT_MAX_LINES})"
    )
    parser.add_argument(
        "--show-head", type=int, default=SHOW_HEAD,
        help=f"Lines to show at start (default: {SHOW_HEAD})"
    )
    parser.add_argument(
        "--show-tail", type=int, default=SHOW_TAIL,
        help=f"Lines to show at end (default: {SHOW_TAIL})"
    )
    args = parser.parse_args()

    # Read all of stdin
    stdout = sys.stdin.read()

    truncated, temp_path = truncate_output(stdout, max_lines=args.lines)

    # Write processed output to stdout
    if temp_path:
        # Show the truncated version
        sys.stdout.write(truncated)
        # Add the temp file reference as stderr message
        print(f"\n[Full output ({len(stdout.splitlines())} lines) written to: {temp_path}]", file=sys.stderr)
        sys.exit(1)
    else:
        sys.stdout.write(truncated)
        sys.exit(0)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)