Compare commits

..

24 Commits

Author SHA1 Message Date
gavrielc
deee4b2a96 Merge pull request #1280 from Koshkoshinsk/feature/diagnostics
Some checks failed
Merge-forward skill branches / merge-forward (push) Has been cancelled
feat: add opt-in diagnostics via PostHog
2026-03-22 16:55:07 +02:00
gavrielc
4f60be7803 Merge branch 'main' into feature/diagnostics 2026-03-22 16:54:10 +02:00
gavrielc
02d51afe09 trim diagnostics verbosity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:53:53 +02:00
gavrielc
a4fbc9d615 show full payload to user, not just properties
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:51:15 +02:00
gavrielc
f97394656c cross-skill opt-out and gather system info via shell
- "Never ask again" now removes diagnostics from both skills
- Added shell commands to gather version, platform, arch, node version
- Show only properties object to user, not api_key/distinct_id
- Write full PostHog payload to temp file, send with curl -d @file

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:47:54 +02:00
gavrielc
09d833c310 replace diagnostics script with curl, simplify flow
Remove send-diagnostics.ts entirely. Claude writes the JSON, shows
it to the user, and sends via curl. Opt-out is permanent: Claude
replaces diagnostics.md contents and removes the section from SKILL.md.
No dependencies, no state files, no .nanoclaw/ directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:45:04 +02:00
gavrielc
f33c66b046 simplify setup diagnostics to single event
One setup_complete event at the end, not per-skill events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:37:13 +02:00
gavrielc
e2423171e1 simplify diagnostics instructions
Show example commands with placeholder values. Claude fills in the
actual values from the session in one shot — no multi-step build process.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:36:08 +02:00
gavrielc
e10b136df6 refactor: move diagnostics into each skill's own directory
Replace shared _shared/diagnostics.md with dedicated diagnostics.md
files in setup/ and update-nanoclaw/. Each contains only the event
types relevant to that skill. References updated to local links.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:31:59 +02:00
gavrielc
31ac74f5f2 fix: remove claw skill accidentally added to this branch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:28:36 +02:00
gavrielc
d768a04843 docs: move Docker Sandboxes out of README hero section
Demote Docker Sandboxes from a prominent hero banner to inline
mentions in the features list and FAQ. New users now land on
Quick Start first.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:10:54 +02:00
github-actions[bot]
8c3979556a docs: update token count to 40.9k tokens · 20% of context window 2026-03-21 11:09:04 +00:00
gavrielc
ec1b14504b docs: update contributing guidelines and skill type taxonomy
- Rewrite CONTRIBUTING.md with four skill types (feature, utility,
  operational, container), PR requirements, pre-submission checklist
- Update PR template with skill type checkboxes and docs option
- Add label-pr workflow to auto-label PRs from template checkboxes
- Add hidden template version marker (v1) for follows-guidelines label
- Update CLAUDE.md with skill types overview and contributing instruction
- Update skills-as-branches.md to reference full taxonomy
- Remove /clear from README RFS (already exists as /add-compact)
- Delete obsolete docs (nanorepo-architecture.md, nanoclaw-architecture-final.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:08:42 +02:00
gavrielc
3cc30501dd Merge pull request #1296 from kenbolton/skill/claw-cli
skill: claw — run NanoClaw agents from the command line
2026-03-21 12:21:11 +02:00
gavrielc
bf1e2a3819 refactor: extract claw script from SKILL.md into separate file
Move the Python CLI script from inline markdown into scripts/claw,
aligning with the Claude Code skills standard (code in files, not md).
Remove non-standard `author` frontmatter field. SKILL.md now uses
${CLAUDE_SKILL_DIR} substitution to copy the script during install.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:16:57 +02:00
Ken Bolton
b2377bb390 Fix Python 3.8 compat, document --image flag and --rm behavior 2026-03-21 12:03:00 +02:00
Ken Bolton
18469294ce Add claw CLI skill 2026-03-21 12:03:00 +02:00
github-actions[bot]
c3b19876eb chore: bump version to 1.2.21 2026-03-21 09:57:39 +00:00
gavrielc
f1150ac624 Merge pull request #1297 from trycua/claude/add-eslint-preserve-error-YnKYj
Add ESLint configuration and fix linting issues
2026-03-21 11:57:30 +02:00
Claude
b30b5a6a8f style: apply prettier formatting to modified files
https://claude.ai/code/session_01JPjzhBp9PR5LtfLWVDrYrH
2026-03-21 11:57:22 +02:00
Claude
30ebcaa61e feat: add ESLint with error-handling rules
Add ESLint v9.35+ with typescript-eslint recommended config and
error-handling rules: preserve-caught-error (enforces { cause } when
re-throwing), no-unused-vars with caughtErrors:all, and
eslint-plugin-no-catch-all (warns on catch blocks that don't rethrow).

Fix existing violations: add error cause to container-runtime rethrow,
prefix unused vars with underscore, remove unused imports.

https://claude.ai/code/session_01JPjzhBp9PR5LtfLWVDrYrH
2026-03-21 11:57:22 +02:00
github-actions[bot]
b7420c6562 chore: bump version to 1.2.20 2026-03-21 09:54:51 +00:00
gavrielc
656d7f7bff Merge pull request #1300 from trycua/claude/slack-add-slack-formatting-skill-kKnnL
Add Slack formatting skill and update message formatting guides
2026-03-21 11:54:39 +02:00
Claude
0ce11f6f4d feat: add Slack formatting skill for NanoClaw agents
Add a new skill that teaches agents how to format messages using Slack's
mrkdwn syntax. Updates agent CLAUDE.md files to detect channel type from
folder name prefix and use appropriate formatting.

- container/skills/slack-formatting/SKILL.md: comprehensive mrkdwn reference
- groups/global/CLAUDE.md: channel-aware formatting instructions
- groups/main/CLAUDE.md: same, plus emoji shortcode examples

https://claude.ai/code/session_01W44WtL2gRETr9YBB6h62YM
2026-03-21 06:55:51 +00:00
31 changed files with 2202 additions and 1732 deletions

View File

@@ -1,106 +0,0 @@
# Diagnostics (end-of-skill instructions)
Follow these steps **after all other skill steps are complete**.
## 1. Collect all events
Every skill sends its own diagnostics event — even when invoked from a parent skill. A `/setup` that invokes `/add-telegram` produces **two** events: `skill_applied` (for add-telegram) and `setup_complete` (for setup). A `/setup` with no channels produces just `setup_complete`.
Build the list of events to send:
1. If sub-skills were invoked during this run (e.g., `/add-telegram` during `/setup`), add a `skill_applied` event for each.
2. Add the event for the current (outermost) skill.
**If running a sub-skill standalone** (not called from a parent), there is just one event.
## 2. Determine event type (for each event)
- `/setup``setup_complete`
- `/update-nanoclaw``update_complete`
- Any other skill → `skill_applied`
## 3. Build event-specific data
Estimate `error_count` from the conversation (how many errors/retries occurred during the run).
**For `setup_complete`:**
```json
{
"channels_selected": ["whatsapp", "telegram"],
"error_count": 0,
"failed_step": null,
"exit_code": null
}
```
- `channels_selected`: which channels the user chose to install
- `failed_step`: if setup failed, which step (environment / container / groups / register / mounts / service / verify)
- `exit_code`: if a step failed, its exit code
**For `skill_applied`:**
```json
{
"skill_name": "add-telegram",
"is_upstream_skill": true,
"conflict_files": ["package.json", "src/index.ts"],
"error_count": 0
}
```
- `skill_name`: the skill that was run. Use the upstream skill name if it's an upstream skill, otherwise use `"custom"`
- `is_upstream_skill`: true if this is a known upstream skill
- `conflict_files`: filenames that had merge conflicts (the script will gate these against upstream — pass them all, the script filters)
**For `update_complete`:**
```json
{
"version_age_days": 45,
"update_method": "merge",
"conflict_files": ["package.json"],
"breaking_changes_found": false,
"breaking_changes_skills_run": [],
"error_count": 0
}
```
- `version_age_days`: estimate from the backup tag or commit date how many days old the previous version was
- `update_method`: "merge" or "rebase"
- `breaking_changes_found`: whether breaking changes were detected during the update
- `breaking_changes_skills_run`: which skills had to be re-run to fix breaking changes
## 4. Dry run all events
For **each** event, run with `--dry-run` to get the payload:
```bash
npx tsx scripts/send-diagnostics.ts --event <event_type> --success --data '<json>' --dry-run
```
Use `--failure` instead of `--success` if that particular skill/step failed.
If **any** dry-run produces no output, the user has opted out permanently — skip the rest for all events.
## 5. Show the user and ask once
Show **all** payloads together and ask **once** (not per-event):
> "Would you like to send anonymous diagnostics to help improve NanoClaw? Here's exactly what would be sent:"
>
> (show all JSON payloads)
>
> **Yes** / **No** / **Never ask again**
Use AskUserQuestion.
## 6. Handle response
- **Yes**: Send **all** events (run each command without `--dry-run`):
```bash
npx tsx scripts/send-diagnostics.ts --event <event_type> --success --data '<json>'
```
Confirm: "Diagnostics sent (N events)." or "Diagnostics sent." if only one.
- **No**: Do nothing. User will be asked again next time.
- **Never ask again**: Run:
```bash
npx tsx -e "import { setNeverAsk } from './scripts/send-diagnostics.ts'; setNeverAsk();"
```
Confirm: "Got it — you won't be asked again."

View File

@@ -0,0 +1,318 @@
#!/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
"""
from __future__ import annotations
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()

View File

@@ -218,6 +218,6 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
## Diagnostics (Optional)
## Diagnostics
After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`.
Read and follow [diagnostics.md](diagnostics.md).

View File

@@ -0,0 +1,46 @@
# Diagnostics
Gather system info:
```bash
node -p "require('./package.json').version"
uname -s
uname -m
node -p "process.versions.node.split('.')[0]"
```
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
```json
{
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
"event": "setup_complete",
"distinct_id": "<uuid>",
"properties": {
"success": true,
"nanoclaw_version": "1.2.21",
"os_platform": "darwin",
"arch": "arm64",
"node_major_version": 22,
"channels_selected": ["telegram", "whatsapp"],
"error_count": 0,
"failed_step": null
}
}
```
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
**Yes**:
```bash
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
rm /tmp/nanoclaw-diagnostics.json
```
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the "Diagnostics" section from both `.claude/skills/setup/SKILL.md` and `.claude/skills/update-nanoclaw/SKILL.md`
4. `rm /tmp/nanoclaw-diagnostics.json`

View File

@@ -235,6 +235,6 @@ Tell the user:
- If running manually: restart `npm run dev`
## Diagnostics (Optional)
## Diagnostics
After completing all steps above, read and follow `.claude/skills/_shared/diagnostics.md`.
Read and follow [diagnostics.md](diagnostics.md).

View File

@@ -0,0 +1,49 @@
# Diagnostics
Gather system info:
```bash
node -p "require('./package.json').version"
uname -s
uname -m
node -p "process.versions.node.split('.')[0]"
git log -1 --format=%ci HEAD@{1} 2>/dev/null || echo "unknown"
```
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
```json
{
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
"event": "update_complete",
"distinct_id": "<uuid>",
"properties": {
"success": true,
"nanoclaw_version": "1.2.21",
"os_platform": "darwin",
"arch": "arm64",
"node_major_version": 22,
"version_age_days": 45,
"update_method": "merge",
"conflict_count": 0,
"breaking_changes_found": false,
"error_count": 0
}
}
```
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
**Yes**:
```bash
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
rm /tmp/nanoclaw-diagnostics.json
```
**No**: `rm /tmp/nanoclaw-diagnostics.json`
**Never ask again**:
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
3. Remove the "Diagnostics" section from both `.claude/skills/setup/SKILL.md` and `.claude/skills/update-nanoclaw/SKILL.md`
4. `rm /tmp/nanoclaw-diagnostics.json`

View File

@@ -1,14 +1,18 @@
<!-- contributing-guide: v1 -->
## Type of Change
- [ ] **Skill** - adds a new skill in `.claude/skills/`
- [ ] **Feature skill** - adds a channel or integration (source code changes + SKILL.md)
- [ ] **Utility skill** - adds a standalone tool (code files in `.claude/skills/<name>/`, no source changes)
- [ ] **Operational/container skill** - adds a workflow or agent skill (SKILL.md only, no source changes)
- [ ] **Fix** - bug fix or security fix to source code
- [ ] **Simplification** - reduces or simplifies source code
- [ ] **Documentation** - docs, README, or CONTRIBUTING changes only
## Description
## For Skills
- [ ] I have not made any changes to source code
- [ ] My skill contains instructions for Claude to follow (not pre-built code)
- [ ] SKILL.md contains instructions, not inline code (code goes in separate files)
- [ ] SKILL.md is under 500 lines
- [ ] I tested this skill on a fresh clone

35
.github/workflows/label-pr.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Label PR
on:
pull_request:
types: [opened, edited]
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/github-script@v7
with:
script: |
const body = context.payload.pull_request.body || '';
const labels = [];
if (body.includes('[x] **Feature skill**')) { labels.push('PR: Skill'); labels.push('PR: Feature'); }
else if (body.includes('[x] **Utility skill**')) labels.push('PR: Skill');
else if (body.includes('[x] **Operational/container skill**')) labels.push('PR: Skill');
else if (body.includes('[x] **Fix**')) labels.push('PR: Fix');
else if (body.includes('[x] **Simplification**')) labels.push('PR: Refactor');
else if (body.includes('[x] **Documentation**')) labels.push('PR: Docs');
if (body.includes('contributing-guide: v1')) labels.push('follows-guidelines');
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels,
});
}

View File

@@ -19,10 +19,17 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
| `src/task-scheduler.ts` | Runs scheduled tasks |
| `src/db.ts` | SQLite operations |
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
| `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) |
| `container/skills/` | Skills loaded inside agent containers (browser, status, formatting) |
## Skills
Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy and guidelines.
- **Feature skills** — merge a `skill/*` branch to add capabilities (e.g. `/add-telegram`, `/add-slack`)
- **Utility skills** — ship code files alongside SKILL.md (e.g. `/claw`)
- **Operational skills** — instruction-only workflows, always on `main` (e.g. `/setup`, `/debug`)
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`)
| Skill | When to Use |
|-------|-------------|
| `/setup` | First-time installation, authentication, service configuration |
@@ -32,6 +39,10 @@ Single Node.js process with skill-based channel system. Channels (WhatsApp, Tele
| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch |
| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks |
## Contributing
Before creating a PR, adding a skill, or preparing any contribution, you MUST read [CONTRIBUTING.md](CONTRIBUTING.md). It covers accepted change types, the four skill types and their guidelines, SKILL.md format rules, PR requirements, and the pre-submission checklist (searching for existing PRs/issues, testing, description format).
## Development
Run commands directly—don't tell the user to run them.
@@ -57,7 +68,7 @@ systemctl --user restart nanoclaw
## Troubleshooting
**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && (git merge whatsapp/main || { git checkout --theirs package-lock.json && git add package-lock.json && git merge --continue; }) && npm run build`) to install it. Existing auth credentials and groups are preserved.
**WhatsApp not connecting after upgrade:** WhatsApp is now a separate skill, not bundled in core. Run `/add-whatsapp` (or `npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp && npm run build`) to install it. Existing auth credentials and groups are preserved.
## Container Build Cache

View File

@@ -1,5 +1,18 @@
# Contributing
## Before You Start
1. **Check for existing work.** Search open PRs and issues before starting:
```bash
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
```
If a related PR or issue exists, build on it rather than duplicating effort.
2. **Check alignment.** Read the [Philosophy section in README.md](README.md#philosophy). Source code changes should only be things 90%+ of users need. Skills can be more niche, but should still be useful beyond a single person's setup.
3. **One thing per PR.** Each PR should do one thing — one bug fix, one skill, one simplification. Don't mix unrelated changes in a single PR.
## Source Code Changes
**Accepted:** Bug fixes, security fixes, simplifications, reducing code.
@@ -8,16 +21,127 @@
## Skills
A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform a NanoClaw installation.
NanoClaw uses [Claude Code skills](https://code.claude.com/docs/en/skills) markdown files with optional supporting files that teach Claude how to do something. There are four types of skills in NanoClaw, each serving a different purpose.
A PR that contributes a skill should not modify any source files.
Your skill should contain the **instructions** Claude follows to add the feature—not pre-built code. See `/add-telegram` for a good example.
### Why?
### Why skills?
Every user should have clean and minimal code that does exactly what they need. Skills let users selectively add features to their fork without inheriting code for features they don't want.
### Testing
### Skill types
Test your skill by running it on a fresh clone before submitting.
#### 1. Feature skills (branch-based)
Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setup instructions; the actual code lives on a `skill/*` branch.
**Location:** `.claude/skills/` on `main` (instructions only), code on `skill/*` branch
**Examples:** `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`
**How they work:**
1. User runs `/add-telegram`
2. Claude follows the SKILL.md: fetches and merges the `skill/telegram` branch
3. Claude walks through interactive setup (env vars, bot creation, etc.)
**Contributing a feature skill:**
1. Fork `qwibitai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
See `/add-telegram` for a good example. See [docs/skills-as-branches.md](docs/skills-as-branches.md) for the full system design.
#### 2. Utility skills (with code files)
Standalone tools that ship code files alongside the SKILL.md. The SKILL.md tells Claude how to install the tool; the code lives in the skill directory itself (e.g. in a `scripts/` subfolder).
**Location:** `.claude/skills/<name>/` with supporting files
**Examples:** `/claw` (Python CLI in `scripts/claw`)
**Key difference from feature skills:** No branch merge needed. The code is self-contained in the skill directory and gets copied into place during installation.
**Guidelines:**
- Put code in separate files, not inline in the SKILL.md
- Use `${CLAUDE_SKILL_DIR}` to reference files in the skill directory
- SKILL.md contains installation instructions, usage docs, and troubleshooting
#### 3. Operational skills (instruction-only)
Workflows and guides with no code changes. The SKILL.md is the entire skill — Claude follows the instructions to perform a task.
**Location:** `.claude/skills/` on `main`
**Examples:** `/setup`, `/debug`, `/customize`, `/update-nanoclaw`, `/update-skills`
**Guidelines:**
- Pure instructions — no code files, no branch merges
- Use `AskUserQuestion` for interactive prompts
- These stay on `main` and are always available to every user
#### 4. Container skills (agent runtime)
Skills that run inside the agent container, not on the host. These teach the container agent how to use tools, format output, or perform tasks. They are synced into each group's `.claude/skills/` directory when a container starts.
**Location:** `container/skills/<name>/`
**Examples:** `agent-browser` (web browsing), `capabilities` (/capabilities command), `status` (/status command), `slack-formatting` (Slack mrkdwn syntax)
**Key difference:** These are NOT invoked by the user on the host. They're loaded by Claude Code inside the container and influence how the agent behaves.
**Guidelines:**
- Follow the same SKILL.md + frontmatter format
- Use `allowed-tools` frontmatter to scope tool permissions
- Keep them focused — the agent's context window is shared across all container skills
### SKILL.md format
All skills use the [Claude Code skills standard](https://code.claude.com/docs/en/skills):
```markdown
---
name: my-skill
description: What this skill does and when to use it.
---
Instructions here...
```
**Rules:**
- Keep SKILL.md **under 500 lines** — move detail to separate reference files
- `name`: lowercase, alphanumeric + hyphens, max 64 chars
- `description`: required — Claude uses this to decide when to invoke the skill
- Put code in separate files, not inline in the markdown
- See the [skills standard](https://code.claude.com/docs/en/skills) for all available frontmatter fields
## Testing
Test your contribution on a fresh clone before submitting. For skills, run the skill end-to-end and verify it works.
## Pull Requests
### Before opening
1. **Link related issues.** If your PR resolves an open issue, include `Closes #123` in the description so it's auto-closed on merge.
2. **Test thoroughly.** Run the feature yourself. For skills, test on a fresh clone.
3. **Check the right box** in the PR template. Labels are auto-applied based on your selection:
| Checkbox | Label |
|----------|-------|
| Feature skill | `PR: Skill` + `PR: Feature` |
| Utility skill | `PR: Skill` |
| Operational/container skill | `PR: Skill` |
| Fix | `PR: Fix` |
| Simplification | `PR: Refactor` |
| Documentation | `PR: Docs` |
### PR description
Keep it concise. Remove any template sections that don't apply. The description should cover:
- **What** — what the PR adds or changes
- **Why** — the motivation
- **How it works** — brief explanation of the approach
- **How it was tested** — what you did to verify it works
- **Usage** — how the user invokes it (for skills)
Don't pad the description. A few clear sentences are better than lengthy paragraphs.

View File

@@ -16,25 +16,6 @@
---
<h2 align="center">🐳 Now Runs in Docker Sandboxes</h2>
<p align="center">Every agent gets its own isolated container inside a micro VM.<br>Hypervisor-level isolation. Millisecond startup. No complex setup.</p>
**macOS (Apple Silicon)**
```bash
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash
```
**Windows (WSL)**
```bash
curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
```
> Currently supported on macOS (Apple Silicon) and Windows (x86). Linux support coming soon.
<p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">Read the announcement →</a>&nbsp; · &nbsp;<a href="docs/docker-sandboxes.md">Manual setup guide →</a></p>
---
## Why I Built NanoClaw
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
@@ -89,7 +70,7 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication,
- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
- **Web access** - Search and fetch content from the Web
- **Container isolation** - Agents are sandboxed in [Docker Sandboxes](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes) (micro VM isolation), Apple Container (macOS), or Docker (macOS/Linux)
- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS)
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
@@ -138,9 +119,6 @@ Skills we'd like to see:
**Communication Channels**
- `/add-signal` - Add Signal as a channel
**Session Management**
- `/clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK.
## Requirements
- macOS or Linux
@@ -173,7 +151,7 @@ Key files:
**Why Docker?**
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime.
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
**Can I run this on Linux?**

View File

@@ -0,0 +1,94 @@
---
name: slack-formatting
description: Format messages for Slack using mrkdwn syntax. Use when responding to Slack channels (folder starts with "slack_" or JID contains slack identifiers).
---
# Slack Message Formatting (mrkdwn)
When responding to Slack channels, use Slack's mrkdwn syntax instead of standard Markdown.
## How to detect Slack context
Check your group folder name or workspace path:
- Folder starts with `slack_` (e.g., `slack_engineering`, `slack_general`)
- Or check `/workspace/group/` path for `slack_` prefix
## Formatting reference
### Text styles
| Style | Syntax | Example |
|-------|--------|---------|
| Bold | `*text*` | *bold text* |
| Italic | `_text_` | _italic text_ |
| Strikethrough | `~text~` | ~strikethrough~ |
| Code (inline) | `` `code` `` | `inline code` |
| Code block | ` ```code``` ` | Multi-line code |
### Links and mentions
```
<https://example.com|Link text> # Named link
<https://example.com> # Auto-linked URL
<@U1234567890> # Mention user by ID
<#C1234567890> # Mention channel by ID
<!here> # @here
<!channel> # @channel
```
### Lists
Slack supports simple bullet lists but NOT numbered lists:
```
• First item
• Second item
• Third item
```
Use `•` (bullet character) or `- ` or `* ` for bullets.
### Block quotes
```
> This is a block quote
> It can span multiple lines
```
### Emoji
Use standard emoji shortcodes: `:white_check_mark:`, `:x:`, `:rocket:`, `:tada:`
## What NOT to use
- **NO** `##` headings (use `*Bold text*` for headers instead)
- **NO** `**double asterisks**` for bold (use `*single asterisks*`)
- **NO** `[text](url)` links (use `<url|text>` instead)
- **NO** `1.` numbered lists (use bullets with numbers: `• 1. First`)
- **NO** tables (use code blocks or plain text alignment)
- **NO** `---` horizontal rules
## Example message
```
*Daily Standup Summary*
_March 21, 2026_
• *Completed:* Fixed authentication bug in login flow
• *In Progress:* Building new dashboard widgets
• *Blocked:* Waiting on API access from DevOps
> Next sync: Monday 10am
:white_check_mark: All tests passing | <https://ci.example.com/builds/123|View Build>
```
## Quick rules
1. Use `*bold*` not `**bold**`
2. Use `<url|text>` not `[text](url)`
3. Use `•` bullets, avoid numbered lists
4. Use `:emoji:` shortcodes
5. Quote blocks with `>`
6. Skip headings — use bold text instead

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +0,0 @@
# NanoClaw Skills Architecture
## What Skills Are For
NanoClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks.
## Why This Architecture
The problem: users need to combine multiple modifications to a shared codebase, keep those modifications working across core updates, and do all of this without becoming git experts or losing their custom changes. A plugin system would be simpler but constrains what skills can do. Giving skills full codebase access means they can change anything, but that creates merge conflicts, update breakage, and state tracking challenges.
This architecture solves that by making skill application fully programmatic using standard git mechanics, with AI as a fallback for conflicts git can't resolve, and a shared resolution cache so most users never hit those conflicts at all. The result: users compose exactly the features they want, customizations survive core updates automatically, and the system is always recoverable.
## Core Principle
Skills are self-contained, auditable packages applied via standard git merge mechanics. Claude Code orchestrates the process — running git commands, reading skill manifests, and stepping in only when git can't resolve a conflict. The system uses existing git features (`merge-file`, `rerere`, `apply`) rather than custom merge infrastructure.
## Three-Level Resolution Model
Every operation follows this escalation:
1. **Git** — deterministic. `git merge-file` merges, `git rerere` replays cached resolutions, structured operations apply without merging. No AI. Handles the vast majority of cases.
2. **Claude Code** — reads `SKILL.md`, `.intent.md`, and `state.yaml` to resolve conflicts git can't handle. Caches resolutions via `git rerere` so the same conflict never needs resolving twice.
3. **Claude Code + user input** — when Claude Code lacks sufficient context to determine intent (e.g., two features genuinely conflict at an application level), it asks the user for a decision, then uses that input to perform the resolution. Claude Code still does the work — the user provides direction, not code.
**Important**: A clean merge doesn't guarantee working code. Semantic conflicts can produce clean text merges that break at runtime. **Tests run after every operation.**
## Backup/Restore Safety
Before any operation, all affected files are copied to `.nanoclaw/backup/`. On success, backup is deleted. On failure, backup is restored. Works safely for users who don't use git.
## The Shared Base
`.nanoclaw/base/` holds a clean copy of the core codebase. This is the single common ancestor for all three-way merges, only updated during core updates.
## Two Types of Changes
### Code Files (Three-Way Merge)
Source code where skills weave in logic. Merged via `git merge-file` against the shared base. Skills carry full modified files.
### Structured Data (Deterministic Operations)
Files like `package.json`, `docker-compose.yml`, `.env.example`. Skills declare requirements in the manifest; the system applies them programmatically. Multiple skills' declarations are batched — dependencies merged, `package.json` written once, `npm install` run once.
```yaml
structured:
npm_dependencies:
whatsapp-web.js: "^2.1.0"
env_additions:
- WHATSAPP_TOKEN
docker_compose_services:
whatsapp-redis:
image: redis:alpine
ports: ["6380:6379"]
```
Structured conflicts (version incompatibilities, port collisions) follow the same three-level resolution model.
## Skill Package Structure
A skill contains only the files it adds or modifies. Modified code files carry the **full file** (clean core + skill's changes), making `git merge-file` straightforward and auditable.
```
skills/add-whatsapp/
SKILL.md # What this skill does and why
manifest.yaml # Metadata, dependencies, structured ops
tests/whatsapp.test.ts # Integration tests
add/src/channels/whatsapp.ts # New files
modify/src/server.ts # Full modified file for merge
modify/src/server.ts.intent.md # Structured intent for conflict resolution
```
### Intent Files
Each modified file has a `.intent.md` with structured headings: **What this skill adds**, **Key sections**, **Invariants**, and **Must-keep sections**. These give Claude Code specific guidance during conflict resolution.
### Manifest
Declares: skill metadata, core version compatibility, files added/modified, file operations, structured operations, skill relationships (conflicts, depends, tested_with), post-apply commands, and test command.
## Customization and Layering
**One skill, one happy path** — a skill implements the reasonable default for 80% of users.
**Customization is more patching.** Apply the skill, then modify via tracked patches, direct editing, or additional layered skills. Custom modifications are recorded in `state.yaml` and replayable.
**Skills layer via `depends`.** Extension skills build on base skills (e.g., `telegram-reactions` depends on `add-telegram`).
## File Operations
Renames, deletes, and moves are declared in the manifest and run **before** code merges. When core renames a file, a **path remap** resolves skill references at apply time — skill packages are never mutated.
## The Apply Flow
1. Pre-flight checks (compatibility, dependencies, untracked changes)
2. Backup
3. File operations + path remapping
4. Copy new files
5. Merge modified code files (`git merge-file`)
6. Conflict resolution (shared cache → `git rerere` → Claude Code → Claude Code + user input)
7. Apply structured operations (batched)
8. Post-apply commands, update `state.yaml`
9. **Run tests** (mandatory, even if all merges were clean)
10. Clean up (delete backup on success, restore on failure)
## Shared Resolution Cache
`.nanoclaw/resolutions/` ships pre-computed, verified conflict resolutions with **hash enforcement** — a cached resolution only applies if base, current, and skill input hashes match exactly. This means most users never encounter unresolved conflicts for common skill combinations.
### rerere Adapter
`git rerere` requires unmerged index entries that `git merge-file` doesn't create. An adapter sets up the required index state after `merge-file` produces a conflict, enabling rerere caching. This requires the project to be a git repository; users without `.git/` lose caching but not functionality.
## State Tracking
`.nanoclaw/state.yaml` records: core version, all applied skills (with per-file hashes for base/skill/merged), structured operation outcomes, custom patches, and path remaps. This makes drift detection instant and replay deterministic.
## Untracked Changes
Direct edits are detected via hash comparison before any operation. Users can record them as tracked patches, continue untracked, or abort. The three-level model can always recover coherent state from any starting point.
## Core Updates
Most changes propagate automatically through three-way merge. **Breaking changes** require a **migration skill** — a regular skill that preserves the old behavior, authored against the new core. Migrations are declared in `migrations.yaml` and applied automatically during updates.
### Update Flow
1. Preview changes (git-only, no files modified)
2. Backup → file operations → three-way merge → conflict resolution
3. Re-apply custom patches (`git apply --3way`)
4. **Update base** to new core
5. Apply migration skills (preserves user's setup automatically)
6. Re-apply updated skills (version-changed skills only)
7. Re-run structured operations → run all tests → clean up
The user sees no prompts during updates. To accept a new default later, they remove the migration skill.
## Skill Removal
Uninstall is **replay without the skill**: read `state.yaml`, remove the target skill, replay all remaining skills from clean base using the resolution cache. Backup for safety.
## Rebase
Flatten accumulated layers into a clean starting point. Updates base, regenerates diffs, clears old patches and stale cache entries. Trades individual skill history for simpler future merges.
## Replay
Given `state.yaml`, reproduce the exact installation on a fresh machine with no AI (assuming cached resolutions). Apply skills in order, merge, apply custom patches, batch structured operations, run tests.
## Skill Tests
Each skill includes integration tests. Tests run **always** — after apply, after update, after uninstall, during replay, in CI. CI tests all official skills individually and pairwise combinations for skills sharing modified files or structured operations.
## Design Principles
1. **Use git, don't reinvent it.**
2. **Three-level resolution: git → Claude Code → Claude Code + user input.**
3. **Clean merges aren't enough.** Tests run after every operation.
4. **All operations are safe.** Backup/restore, no half-applied state.
5. **One shared base**, only updated on core updates.
6. **Code merges vs. structured operations.** Source code is merged; configs are aggregated.
7. **Resolutions are learned and shared** with hash enforcement.
8. **One skill, one happy path.** Customization is more patching.
9. **Skills layer and compose.**
10. **Intent is first-class and structured.**
11. **State is explicit and complete.** Replay is deterministic.
12. **Always recoverable.**
13. **Uninstall is replay.**
14. **Core updates are the maintainers' responsibility.** Breaking changes require migration skills.
15. **File operations and path remapping are first-class.**
16. **Skills are tested.** CI tests pairwise by overlap.
17. **Deterministic serialization.** No noisy diffs.
18. **Rebase when needed.**
19. **Progressive core slimming** via migration skills.

View File

@@ -2,7 +2,20 @@
## Overview
NanoClaw skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git.
This document covers **feature skills** — skills that add capabilities via git branch merges. This is the most complex skill type and the primary way NanoClaw is extended.
NanoClaw has four types of skills overall. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full taxonomy:
| Type | Location | How it works |
|------|----------|-------------|
| **Feature** (this doc) | `.claude/skills/` + `skill/*` branch | SKILL.md has instructions; code lives on a branch, applied via `git merge` |
| **Utility** | `.claude/skills/<name>/` with code files | Self-contained tools; code in skill directory, copied into place on install |
| **Operational** | `.claude/skills/` on `main` | Instruction-only workflows (setup, debug, update) |
| **Container** | `container/skills/` | Loaded inside agent containers at runtime |
---
Feature skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git.
This replaces the previous `skills-engine/` system (three-way file merging, `.nanoclaw/` state, manifest files, replay, backup/restore) with plain git operations and Claude for conflict resolution.
@@ -310,7 +323,9 @@ Standard fork contribution workflow. Their custom changes stay on their main and
## Contributing a Skill
### Contributor flow
The flow below is for **feature skills** (branch-based). For utility skills (self-contained tools) and container skills, the contributor opens a PR that adds files directly to `.claude/skills/<name>/` or `container/skills/<name>/` — no branch extraction needed. See [CONTRIBUTING.md](../CONTRIBUTING.md) for all skill types.
### Contributor flow (feature skills)
1. Fork `qwibitai/nanoclaw`
2. Branch from `main`

32
eslint.config.js Normal file
View File

@@ -0,0 +1,32 @@
import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import noCatchAll from 'eslint-plugin-no-catch-all'
export default [
{ ignores: ['node_modules/', 'dist/', 'container/', 'groups/'] },
{ files: ['src/**/*.{js,ts}'] },
{ languageOptions: { globals: globals.node } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
plugins: { 'no-catch-all': noCatchAll },
rules: {
'preserve-caught-error': ['error', { requireCatchParameter: true }],
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'no-catch-all/no-catch-all': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
},
},
]

View File

@@ -49,10 +49,28 @@ When you learn something important:
## Message Formatting
NEVER use markdown. Only use WhatsApp/Telegram formatting:
- *single asterisks* for bold (NEVER **double asterisks**)
- _underscores_ for italic
- • bullet points
- ```triple backticks``` for code
Format messages based on the channel you're responding to. Check your group folder name:
No ## headings. No [links](url). No **double stars**.
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord channels (folder starts with `discord_`)
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.

View File

@@ -43,15 +43,33 @@ When you learn something important:
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## WhatsApp Formatting (and other messaging apps)
## Message Formatting
Do NOT use markdown headings (##) in WhatsApp messages. Only use:
- *Bold* (single asterisks) (NEVER **double asterisks**)
- _Italic_ (underscores)
- • Bullets (bullet points)
- ```Code blocks``` (triple backticks)
Format messages based on the channel. Check the group folder name prefix:
Keep messages clean and readable for WhatsApp.
### Slack channels (folder starts with `slack_`)
Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules:
- `*bold*` (single asterisks)
- `_italic_` (underscores)
- `<https://url|link text>` for links (NOT `[text](url)`)
- `•` bullets (no numbered lists)
- `:emoji:` shortcodes like `:white_check_mark:`, `:rocket:`
- `>` for block quotes
- No `##` headings — use `*Bold text*` instead
### WhatsApp/Telegram (folder starts with `whatsapp_` or `telegram_`)
- `*bold*` (single asterisks, NEVER **double**)
- `_italic_` (underscores)
- `•` bullet points
- ` ``` ` code blocks
No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord (folder starts with `discord_`)
Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---

1380
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "1.2.19",
"version": "1.2.21",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
@@ -15,6 +15,8 @@
"prepare": "husky",
"setup": "tsx setup/index.ts",
"auth": "tsx src/whatsapp-auth.ts",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"test": "vitest run",
"test:watch": "vitest"
},
@@ -27,13 +29,18 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.35.0",
"eslint-plugin-no-catch-all": "^1.1.0",
"globals": "^15.12.0",
"husky": "^9.1.7",
"prettier": "^3.8.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.35.0",
"vitest": "^4.0.18"
},
"engines": {

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="40.7k tokens, 20% of context window">
<title>40.7k tokens, 20% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="40.9k tokens, 20% of context window">
<title>40.9k tokens, 20% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">40.7k</text>
<text x="74" y="14">40.7k</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">40.9k</text>
<text x="74" y="14">40.9k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,315 +0,0 @@
/**
* send-diagnostics.ts — opt-in, privacy-first diagnostics for NanoClaw.
*
* Collects system info, accepts event-specific data via --data JSON arg,
* gates conflict filenames against upstream, and sends to PostHog.
*
* Usage:
* npx tsx scripts/send-diagnostics.ts \
* --event <setup_complete|skill_applied|update_complete> \
* [--success|--failure] \
* [--data '<json>'] \
* [--dry-run]
*
* Never exits non-zero on telemetry failures.
*/
import { execSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
const POSTHOG_ENDPOINT = 'https://us.i.posthog.com/capture/';
const POSTHOG_TOKEN = 'phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP';
const SEND_TIMEOUT_MS = 5000;
const PROJECT_ROOT = path.resolve(import.meta.dirname, '..');
const STATE_YAML_PATH = path.join(PROJECT_ROOT, '.nanoclaw', 'state.yaml');
// --- Args ---
function parseArgs(): {
event: string;
success?: boolean;
data: Record<string, unknown>;
dryRun: boolean;
} {
const args = process.argv.slice(2);
let event = '';
let success: boolean | undefined;
let data: Record<string, unknown> = {};
let dryRun = false;
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--event':
event = args[++i] || '';
break;
case '--success':
success = true;
break;
case '--failure':
success = false;
break;
case '--data':
try {
data = JSON.parse(args[++i] || '{}');
} catch {
console.error('Warning: --data JSON parse failed, ignoring');
}
break;
case '--dry-run':
dryRun = true;
break;
}
}
if (!event) {
console.error('Error: --event is required');
process.exit(0); // exit 0 — never fail on diagnostics
}
return { event, success, data, dryRun };
}
// --- State (neverAsk) ---
function readState(): Record<string, unknown> {
try {
const raw = fs.readFileSync(STATE_YAML_PATH, 'utf-8');
return parseYaml(raw) || {};
} catch {
return {};
}
}
function isNeverAsk(): boolean {
const state = readState();
return state.neverAsk === true;
}
export function setNeverAsk(): void {
const state = readState();
state.neverAsk = true;
const dir = path.dirname(STATE_YAML_PATH);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(STATE_YAML_PATH, stringifyYaml(state));
}
// --- Git helpers ---
/** Resolve the upstream remote ref (could be 'upstream/main' or 'origin/main'). */
function resolveUpstreamRef(): string | null {
for (const ref of ['upstream/main', 'origin/main']) {
try {
execSync(`git rev-parse --verify ${ref}`, {
cwd: PROJECT_ROOT,
stdio: 'ignore',
});
return ref;
} catch {
continue;
}
}
return null;
}
// --- System info ---
function getNanoclawVersion(): string {
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf-8'),
);
return pkg.version || 'unknown';
} catch {
return 'unknown';
}
}
function getNodeMajorVersion(): number | null {
const match = process.version.match(/^v(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
function getContainerRuntime(): string {
try {
const src = fs.readFileSync(
path.join(PROJECT_ROOT, 'src', 'container-runtime.ts'),
'utf-8',
);
const match = src.match(/CONTAINER_RUNTIME_BIN\s*=\s*['"]([^'"]+)['"]/);
return match ? match[1] : 'unknown';
} catch {
return 'unknown';
}
}
function isUpstreamCommit(): boolean {
const ref = resolveUpstreamRef();
if (!ref) return false;
try {
const head = execSync('git rev-parse HEAD', {
encoding: 'utf-8',
cwd: PROJECT_ROOT,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
execSync(`git merge-base --is-ancestor ${head} ${ref}`, {
cwd: PROJECT_ROOT,
stdio: 'ignore',
});
return true;
} catch {
return false;
}
}
function collectSystemInfo(): Record<string, unknown> {
return {
nanoclaw_version: getNanoclawVersion(),
os_platform: process.platform,
arch: process.arch,
node_major_version: getNodeMajorVersion(),
container_runtime: getContainerRuntime(),
is_upstream_commit: isUpstreamCommit(),
};
}
// --- Conflict filename gating ---
function getUpstreamFiles(): Set<string> | null {
const ref = resolveUpstreamRef();
if (!ref) return null;
try {
const output = execSync(`git ls-tree -r --name-only ${ref}`, {
encoding: 'utf-8',
cwd: PROJECT_ROOT,
stdio: ['pipe', 'pipe', 'ignore'],
});
return new Set(output.trim().split('\n').filter(Boolean));
} catch {
return null;
}
}
function gateConflictFiles(data: Record<string, unknown>): void {
if (!Array.isArray(data.conflict_files)) return;
const rawFiles: string[] = data.conflict_files;
const upstreamFiles = getUpstreamFiles();
const totalCount = rawFiles.length;
if (!upstreamFiles) {
// Can't verify — fail-closed
data.conflict_files = [];
data.conflict_count = totalCount;
data.has_non_upstream_conflicts = totalCount > 0;
return;
}
const safe: string[] = [];
let hasNonUpstream = false;
for (const file of rawFiles) {
if (upstreamFiles.has(file)) {
safe.push(file);
} else {
hasNonUpstream = true;
}
}
data.conflict_files = safe;
data.conflict_count = totalCount;
data.has_non_upstream_conflicts = hasNonUpstream;
}
// --- Build & send ---
function buildPayload(
event: string,
systemInfo: Record<string, unknown>,
eventData: Record<string, unknown>,
success?: boolean,
): Record<string, unknown> {
const properties: Record<string, unknown> = {
$process_person_profile: false,
$lib: 'nanoclaw-diagnostics',
...systemInfo,
...eventData,
};
if (success !== undefined) {
properties.success = success;
}
return {
api_key: POSTHOG_TOKEN,
event,
distinct_id: crypto.randomUUID(),
properties,
};
}
async function sendToPostHog(
payload: Record<string, unknown>,
): Promise<void> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
try {
const response = await fetch(POSTHOG_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (response.ok) {
console.log('Diagnostics sent successfully.');
} else {
console.error(
`Diagnostics send failed (HTTP ${response.status}). This is fine.`,
);
}
} catch (err) {
console.error('Diagnostics send failed (network error). This is fine.');
} finally {
clearTimeout(timeout);
}
}
// --- Main ---
async function main(): Promise<void> {
try {
if (isNeverAsk()) {
// User opted out permanently — exit silently
return;
}
const { event, success, data, dryRun } = parseArgs();
// Gate conflict filenames before building payload
gateConflictFiles(data);
const systemInfo = collectSystemInfo();
const payload = buildPayload(event, systemInfo, data, success);
if (dryRun) {
// Strip internal fields before showing to user
const { api_key, distinct_id, ...visible } = payload;
const props = visible.properties as Record<string, unknown>;
delete props.$process_person_profile;
delete props.$lib;
console.log(JSON.stringify(visible, null, 2));
return;
}
await sendToPostHog(payload);
} catch (err) {
// Never fail on diagnostics
console.error('Diagnostics error (this is fine):', (err as Error).message);
}
}
main();

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
registerChannel,

View File

@@ -507,11 +507,7 @@ export async function runContainerAgent(
// Full input is only included at verbose level to avoid
// persisting user conversation content on every non-zero exit.
if (isVerbose) {
logLines.push(
`=== Input ===`,
JSON.stringify(input, null, 2),
``,
);
logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``);
} else {
logLines.push(
`=== Input Summary ===`,
@@ -698,7 +694,7 @@ export function writeGroupsSnapshot(
groupFolder: string,
isMain: boolean,
groups: AvailableGroup[],
registeredJids: Set<string>,
_registeredJids: Set<string>,
): void {
const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true });

View File

@@ -96,7 +96,9 @@ export function ensureContainerRuntimeRunning(): void {
console.error(
'╚════════════════════════════════════════════════════════════════╝\n',
);
throw new Error('Container runtime is required but failed to start');
throw new Error('Container runtime is required but failed to start', {
cause: err,
});
}
}

View File

@@ -40,7 +40,7 @@ describe('GroupQueue', () => {
let concurrentCount = 0;
let maxConcurrent = 0;
const processMessages = vi.fn(async (groupJid: string) => {
const processMessages = vi.fn(async (_groupJid: string) => {
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate async work
@@ -69,7 +69,7 @@ describe('GroupQueue', () => {
let maxActive = 0;
const completionCallbacks: Array<() => void> = [];
const processMessages = vi.fn(async (groupJid: string) => {
const processMessages = vi.fn(async (_groupJid: string) => {
activeCount++;
maxActive = Math.max(maxActive, activeCount);
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
@@ -104,7 +104,7 @@ describe('GroupQueue', () => {
const executionOrder: string[] = [];
let resolveFirst: () => void;
const processMessages = vi.fn(async (groupJid: string) => {
const processMessages = vi.fn(async (_groupJid: string) => {
if (executionOrder.length === 0) {
// First call: block until we release it
await new Promise<void>((resolve) => {

View File

@@ -351,7 +351,7 @@ export class GroupQueue {
// via idle timeout or container timeout. The --rm flag cleans them up on exit.
// This prevents WhatsApp reconnection restarts from killing working agents.
const activeContainers: string[] = [];
for (const [jid, state] of this.groups) {
for (const [_jid, state] of this.groups) {
if (state.process && !state.process.killed && state.containerName) {
activeContainers.push(state.containerName);
}

View File

@@ -33,7 +33,6 @@ import {
getAllTasks,
getMessagesSince,
getNewMessages,
getRegisteredGroup,
getRouterState,
initDatabase,
setRegisteredGroup,

View File

@@ -37,7 +37,7 @@ describe('remote-control', () => {
let readFileSyncSpy: ReturnType<typeof vi.spyOn>;
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
let mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
let _mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
let openSyncSpy: ReturnType<typeof vi.spyOn>;
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
@@ -50,7 +50,7 @@ describe('remote-control', () => {
stdoutFileContent = '';
// Default fs mocks
mkdirSyncSpy = vi
_mkdirSyncSpy = vi
.spyOn(fs, 'mkdirSync')
.mockImplementation(() => undefined as any);
writeFileSyncSpy = vi

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js';
import { _initTestDatabase, storeChatMetadata } from './db.js';
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
beforeEach(() => {

View File

@@ -1,7 +1,7 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
isSenderAllowed,