--- name: claw description: Install the claw CLI tool — run NanoClaw agent containers from the command line without opening a chat app. author: 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 ` - 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: ```bash mkdir -p scripts ``` Write the following to `scripts/claw`: ```python #!/usr/bin/env python3 """ claw — NanoClaw CLI Run a NanoClaw agent container from the command line. Usage: claw "What is 2+2?" claw -g "Review this code" claw -g "" "What's the latest issue?" claw -j "" "Hello" claw -g -s "Continue" claw --list-groups echo "prompt text" | claw --pipe -g 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() ``` ### 2. Make executable and symlink ```bash 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: ```bash export PATH="$HOME/bin:$PATH" ``` Then reload your shell: ```bash source ~/.zshrc # or ~/.bashrc ``` ### 3. Verify ```bash 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 ```bash # 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: ```bash export NANOCLAW_DIR=/path/to/your/nanoclaw ``` Or add it permanently to your shell profile.