Files
nanoclaw/.claude/skills/claw/SKILL.md
2026-03-21 12:03:00 +02:00

15 KiB

name, description, author
name description author
claw Install the claw CLI tool — run NanoClaw agent containers from the command line without opening a chat app. kenbolton

claw — NanoClaw CLI

claw is a Python CLI script that lets you send prompts directly to a NanoClaw agent container from your terminal. It reads registered groups from the NanoClaw database, picks up your secrets from .env, and pipes a JSON payload into a container run — no chat app required.

What it does

  • Send a prompt to any registered group by name, folder, or JID
  • Default target is the main group (no -g needed for most use)
  • Resume a previous session with -s <session-id>
  • Read prompts from stdin (--pipe) for scripting and piping
  • List all registered groups with --list-groups
  • Auto-detects container or docker runtime (or override with --runtime)
  • Prints the agent's response to stdout; session ID to stderr
  • Verbose mode (-v) shows the command, redacted payload, and exit code

Prerequisites

  • Python 3.10 or later
  • NanoClaw installed at ~/src/nanoclaw with a built and tagged container image (nanoclaw-agent:latest)
  • Either container (Apple Container, macOS 15+) or docker available in PATH

Install

Note: Run this skill from within your NanoClaw directory (cd ~/src/nanoclaw or wherever you installed it). The script auto-detects its location, so the symlink always points to the right place.

1. Write the script

Create the scripts directory if it doesn't exist, then write the script:

mkdir -p scripts

Write the following to scripts/claw:

#!/usr/bin/env python3
"""
claw — NanoClaw CLI
Run a NanoClaw agent container from the command line.

Usage:
  claw "What is 2+2?"
  claw -g <channel_name> "Review this code"
  claw -g "<channel name with spaces>" "What's the latest issue?"
  claw -j "<chatJid>" "Hello"
  claw -g <channel_name> -s <session-id> "Continue"
  claw --list-groups
  echo "prompt text" | claw --pipe -g <channel_name>
  cat prompt.txt | claw --pipe
"""

import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import threading
from pathlib import Path

# ── Globals ─────────────────────────────────────────────────────────────────

VERBOSE = False

def dbg(*args):
    if VERBOSE:
        print("»", *args, file=sys.stderr)

# ── Config ──────────────────────────────────────────────────────────────────

def _find_nanoclaw_dir() -> Path:
    """Locate the NanoClaw installation directory.

    Resolution order:
    1. NANOCLAW_DIR env var
    2. The directory containing this script (if it looks like a NanoClaw install)
    3. ~/src/nanoclaw (legacy default)
    """
    if env := os.environ.get("NANOCLAW_DIR"):
        return Path(env).expanduser()
    # If this script lives inside the NanoClaw tree (e.g. scripts/claw), walk up
    here = Path(__file__).resolve()
    for parent in [here.parent, here.parent.parent]:
        if (parent / "store" / "messages.db").exists() or (parent / ".env").exists():
            return parent
    return Path.home() / "src" / "nanoclaw"

NANOCLAW_DIR = _find_nanoclaw_dir()
DB_PATH      = NANOCLAW_DIR / "store" / "messages.db"
ENV_FILE     = NANOCLAW_DIR / ".env"
IMAGE        = "nanoclaw-agent:latest"

SECRET_KEYS = [
    "CLAUDE_CODE_OAUTH_TOKEN",
    "ANTHROPIC_API_KEY",
    "ANTHROPIC_BASE_URL",
    "ANTHROPIC_AUTH_TOKEN",
    "OLLAMA_HOST",
]

# ── Helpers ──────────────────────────────────────────────────────────────────

def detect_runtime(preference: str | None) -> str:
    if preference:
        dbg(f"runtime: forced to {preference}")
        return preference
    for rt in ("container", "docker"):
        result = subprocess.run(["which", rt], capture_output=True)
        if result.returncode == 0:
            dbg(f"runtime: auto-detected {rt} at {result.stdout.decode().strip()}")
            return rt
    sys.exit("error: neither 'container' nor 'docker' found. Install one or pass --runtime.")


def read_secrets(env_file: Path) -> dict:
    secrets = {}
    if not env_file.exists():
        return secrets
    for line in env_file.read_text().splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if "=" in line:
            key, _, val = line.partition("=")
            key = key.strip()
            if key in SECRET_KEYS:
                secrets[key] = val.strip()
    return secrets


def get_groups(db: Path) -> list[dict]:
    conn = sqlite3.connect(db)
    rows = conn.execute(
        "SELECT jid, name, folder, is_main FROM registered_groups ORDER BY name"
    ).fetchall()
    conn.close()
    return [{"jid": r[0], "name": r[1], "folder": r[2], "is_main": bool(r[3])} for r in rows]


def find_group(groups: list[dict], query: str) -> dict | None:
    q = query.lower()
    # Exact name match
    for g in groups:
        if g["name"].lower() == q or g["folder"].lower() == q:
            return g
    # Partial match
    matches = [g for g in groups if q in g["name"].lower() or q in g["folder"].lower()]
    if len(matches) == 1:
        return matches[0]
    if len(matches) > 1:
        names = ", ".join(f'"{g["name"]}"' for g in matches)
        sys.exit(f"error: ambiguous group '{query}'. Matches: {names}")
    return None


def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -> None:
    cmd = [runtime, "run", "-i", "--rm", image]
    dbg(f"cmd: {' '.join(cmd)}")

    # Show payload sans secrets
    if VERBOSE:
        safe = {k: v for k, v in payload.items() if k != "secrets"}
        safe["secrets"] = {k: "***" for k in payload.get("secrets", {})}
        dbg(f"payload: {json.dumps(safe, indent=2)}")

    proc = subprocess.Popen(
        cmd,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    dbg(f"container pid: {proc.pid}")

    # Write JSON payload and close stdin
    proc.stdin.write(json.dumps(payload).encode())
    proc.stdin.close()
    dbg("stdin closed, waiting for response...")

    stdout_lines: list[str] = []
    stderr_lines: list[str] = []
    done = threading.Event()

    def stream_stderr():
        for raw in proc.stderr:
            line = raw.decode(errors="replace").rstrip()
            if line.startswith("npm notice"):
                continue
            stderr_lines.append(line)
            print(line, file=sys.stderr)

    def stream_stdout():
        for raw in proc.stdout:
            line = raw.decode(errors="replace").rstrip()
            stdout_lines.append(line)
            dbg(f"stdout: {line}")
            # Kill the container as soon as we see the closing sentinel —
            # the Node.js event loop often keeps the process alive indefinitely.
            if line.strip() == "---NANOCLAW_OUTPUT_END---":
                dbg("output sentinel found, terminating container")
                done.set()
                try:
                    proc.kill()
                except ProcessLookupError:
                    pass
                return

    t_err = threading.Thread(target=stream_stderr, daemon=True)
    t_out = threading.Thread(target=stream_stdout, daemon=True)
    t_err.start()
    t_out.start()

    # Wait for sentinel or timeout
    if not done.wait(timeout=timeout):
        # Also check if process exited naturally
        t_out.join(timeout=2)
        if not done.is_set():
            proc.kill()
            sys.exit(f"error: container timed out after {timeout}s (no output sentinel received)")

    t_err.join(timeout=5)
    t_out.join(timeout=5)
    proc.wait()
    dbg(f"container done (rc={proc.returncode}), {len(stdout_lines)} stdout lines")
    stdout = "\n".join(stdout_lines)

    # Parse output block
    match = re.search(
        r"---NANOCLAW_OUTPUT_START---\n(.*?)\n---NANOCLAW_OUTPUT_END---",
        stdout,
        re.DOTALL,
    )
    if match:
        try:
            data = json.loads(match.group(1))
            status = data.get("status", "unknown")
            if status == "success":
                print(data.get("result", ""))
                session_id = data.get("newSessionId") or data.get("sessionId")
                if session_id:
                    print(f"\n[session: {session_id}]", file=sys.stderr)
            else:
                print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
                sys.exit(1)
        except json.JSONDecodeError:
            print(match.group(1))
    else:
        # No structured output — print raw stdout
        print(stdout)

    if proc.returncode not in (0, None):
        sys.exit(proc.returncode)


# ── Main ─────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        prog="claw",
        description="Run a NanoClaw agent from the command line.",
    )
    parser.add_argument("prompt", nargs="?", help="Prompt to send")
    parser.add_argument("-g", "--group", help="Group name or folder (fuzzy match)")
    parser.add_argument("-j", "--jid", help="Chat JID (exact)")
    parser.add_argument("-s", "--session", help="Session ID to resume")
    parser.add_argument("-p", "--pipe", action="store_true",
                        help="Read prompt from stdin (can be combined with a prompt arg as prefix)")
    parser.add_argument("--runtime", choices=["docker", "container"],
                        help="Container runtime (default: auto-detect)")
    parser.add_argument("--image", default=IMAGE, help=f"Container image (default: {IMAGE})")
    parser.add_argument("--list-groups", action="store_true", help="List registered groups and exit")
    parser.add_argument("--raw", action="store_true", help="Print raw JSON output")
    parser.add_argument("--timeout", type=int, default=300, metavar="SECS",
                        help="Max seconds to wait for a response (default: 300)")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="Show debug info: cmd, payload (secrets redacted), stdout lines, exit code")
    args = parser.parse_args()

    global VERBOSE
    VERBOSE = args.verbose

    groups = get_groups(DB_PATH) if DB_PATH.exists() else []

    if args.list_groups:
        print(f"{'NAME':<35} {'FOLDER':<30} {'JID'}")
        print("-" * 100)
        for g in groups:
            main_tag = " [main]" if g["is_main"] else ""
            print(f"{g['name']:<35} {g['folder']:<30} {g['jid']}{main_tag}")
        return

    # Resolve prompt: --pipe reads stdin, optionally prepended with positional arg
    if args.pipe or (not sys.stdin.isatty() and not args.prompt):
        stdin_text = sys.stdin.read().strip()
        if args.prompt:
            prompt = f"{args.prompt}\n\n{stdin_text}"
        else:
            prompt = stdin_text
    else:
        prompt = args.prompt

    if not prompt:
        parser.print_help()
        sys.exit(1)

    # Resolve group → jid
    jid = args.jid
    group_name = None
    is_main = False

    if args.group:
        g = find_group(groups, args.group)
        if g is None:
            sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
        jid = g["jid"]
        group_name = g["name"]
        is_main = g["is_main"]
    elif not jid:
        # Default: main group
        mains = [g for g in groups if g["is_main"]]
        if mains:
            jid = mains[0]["jid"]
            group_name = mains[0]["name"]
            is_main = True
        else:
            sys.exit("error: no group specified and no main group found. Use -g or -j.")

    runtime = detect_runtime(args.runtime)
    secrets = read_secrets(ENV_FILE)

    if not secrets:
        print("warning: no secrets found in .env — agent may not be authenticated", file=sys.stderr)

    payload: dict = {
        "prompt": prompt,
        "chatJid": jid,
        "isMain": is_main,
        "secrets": secrets,
    }
    if group_name:
        payload["groupFolder"] = group_name
    if args.session:
        payload["sessionId"] = args.session
        payload["resumeAt"] = "latest"

    print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
    run_container(runtime, args.image, payload, timeout=args.timeout)


if __name__ == "__main__":
    main()
chmod +x scripts/claw
mkdir -p ~/bin
ln -sf "$(pwd)/scripts/claw" ~/bin/claw

Make sure ~/bin is in your PATH. Add this to ~/.zshrc or ~/.bashrc if needed:

export PATH="$HOME/bin:$PATH"

Then reload your shell:

source ~/.zshrc   # or ~/.bashrc

3. Verify

claw --list-groups

You should see your registered groups. If NanoClaw isn't running or the database doesn't exist yet, the list will be empty — that's fine.

Usage Examples

# Send a prompt to the main group
claw "What's on my calendar today?"

# Send to a specific group by name (fuzzy match)
claw -g "family" "Remind everyone about dinner at 7"

# Send to a group by exact JID
claw -j "120363336345536173@g.us" "Hello"

# Resume a previous session
claw -s abc123 "Continue where we left off"

# Read prompt from stdin
echo "Summarize this" | claw --pipe -g dev

# Pipe a file
cat report.txt | claw --pipe "Summarize this report"

# List all registered groups
claw --list-groups

# Force a specific runtime
claw --runtime docker "Hello"

# Verbose mode (debug info, secrets redacted)
claw -v "Hello"

# Custom timeout for long-running tasks
claw --timeout 600 "Run the full analysis"

Troubleshooting

"neither 'container' nor 'docker' found"

Install Docker Desktop or Apple Container (macOS 15+), or pass --runtime explicitly.

"no secrets found in .env"

The script auto-detects your NanoClaw directory and reads .env from it. Check that the file exists and contains at least one of: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN.

Container times out

The default timeout is 300 seconds. For longer tasks, pass --timeout 600 (or higher). If the container consistently hangs, check that your nanoclaw-agent:latest image is up to date by running ./container/build.sh in your NanoClaw directory.

"group not found"

Run claw --list-groups to see what's registered. Group lookup does a fuzzy partial match on name and folder — if your query matches multiple groups, you'll get an error listing the ambiguous matches.

Override the NanoClaw directory

If claw can't find your database or .env, set the NANOCLAW_DIR environment variable:

export NANOCLAW_DIR=/path/to/your/nanoclaw

Or add it permanently to your shell profile.