OpenClaw: A Practical Guide to Running a Local Autonomous AI Agent — Part 5: Building a Custom Skill — mac-setup-master
Nine people can’t make a baby in a month, (regarding the addition of more programmers to get a project completed faster)– Fred Brooks
mac-setup-master
Step 1. Directory Layout.
mac-setup-master/
├── SKILL.md
├── requirements.txt # required for `uv pip install -r`
├── scripts/
│ └── mysystem.py
├── references/ # loaded on demand (large / situational)
│ ├── troubleshooting.md # fallbacks when files are missing
│ └── glossary.md # Zsh / AeroSpace / Espanso / Stow terms
└── assets/ # loaded on demand
├── zsh-reference.md
[...]
Step 2. SKILL.md
---
name: mac-setup-master
description: Explains this Mac's setup, shell shortcuts and functions, AeroSpace keybindings, Espanso text expansions, Raycast commands, and Spotlight behaviour using the user's local configuration files (with GNU Stow symlink awareness).
Trigger: User asks about their macOS setup, shortcuts, keybindings, or configuration. Typical triggers are "How is my Mac configured?", "Show me my AeroSpace keybindings", "What shortcuts do I have?", "Check my Espanso setup", "Review my Zsh aliases and functions", "Run system overview".
compatibility: macOS 13+ (Ventura), Python 3.11+, zsh 5.8+
Last modified: 2026-11-12
---
Mac Setup Guide & Diagnostics
Help the user understand **their own** macOS setup by running read-only
diagnostic scripts and inspecting local dotfiles. Prefer exact data extracted
via `scripts/mysystem.py` or a terminal command over assumptions or generic
macOS defaults.
> ⚠️ **Read-only contract.** This skill explains configuration. It **never**
> executes shortcuts, never runs structural scripts (`aerospace.toml`,
> `.zshrc`), and never modifies dotfiles. If a user asks "run my AeroSpace
> reload", explain the command instead.
## 0. Quick start — choosing the right path
| User asks… | Do this | Output |
|---|---|---|
| "System overview" / "How is my Mac set up?" | `python scripts/mysystem.py info` | Table of OS, arch, RAM, disk, versions |
| "Show my AeroSpace keybindings" | `python scripts/mysystem.py aerospace` | Grouped by mode |
| "What Espanso triggers do I have?" | `python scripts/mysystem.py espanso` | Trigger → replacement list |
| "List my OpenClaw skills" | `python scripts/mysystem.py skills` | Name + description + path |
| "What aliases do I have?" | `zsh -ic "alias"` | Live alias dump |
| "Show me my `my_func` function" | `zsh -ic "which my_func"` | Function source |
| "What does Cmd+Enter do?" | Read `~/macOS/scripts/help.sh`, `sec_keyboard` | Shortcut → action, with `file#line` |
| "Where is `aerospace.toml`?" | Resolve via `readlink -f` (Stow-aware) | Real path under `~/macOS` |
If the user's question is ambiguous, **prefer `mysystem.py all`** and then
summarize only the relevant section.
## 1. Prerequisites & Installation
`scripts/mysystem.py` needs **Python 3.11+** (for `tomllib`) and `PyYAML`.
```bash
cd ~/macOS/claude/skills/mac-setup-master
python3 -m venv .venv && source .venv/bin/activate
uv pip install -r requirements.txt
```
`requirements.txt` contains:
```text
PyYAML>=6.0
```
**Fallback if the venv is unavailable:** `python3 -m pip install --user pyyaml`
and confirm Python ≥ 3.11 via `python3 -V`. If the machine only has 3.9/3.10,
`mysystem.py` will exit with a clear message — surface that to the user
verbatim instead of guessing.
## 2. System diagnostics — `scripts/mysystem.py`
A single-file Python helper. Run it via the code interpreter or as a
subprocess. It is read-only.
### Available functions
| Function | Returns | Notes |
|---|---|---|
| `get_sys_info()` | `dict` (and prints) | OS, arch, RAM, free disk, Zsh & AeroSpace versions. Uses `subprocess` with `check=False` — a missing tool becomes `"not installed"`, not a crash. |
| `parse_aerospace()` | `dict` of mode → bindings | Reads `~/.config/aerospace/aerospace.toml` via `tomllib`. |
| `parse_espanso()` | `list[dict]` | Recursively scans `$ESPANSO_MATCH_DIR` (default `~/Library/Application Support/espanso/match`) for `*.yml`/`*.yaml`. Handles `form`, `vars`, `image_path` (non-text) matches. |
| `parse_skills(root=...)` | `list[dict]` | Lists OpenClaw skills by parsing YAML frontmatter in every `SKILL.md` under `~/macOS/claude/skills`. |
| `parse_help_sections()` | `dict` of section → text | Extracts `sec_spotlight`, `sec_raycast`, `sec_keyboard` from `~/macOS/scripts/help.sh`. |
| `get_zsh_aliases()` | `dict` | Parses `zsh -ic "alias"` output. |
| `get_zsh_functions()` | `list[str]` | Lists non-underscore function names from `~/.zsh_functions`. |
### CLI
```bash
python scripts/mysystem.py # = all
python scripts/mysystem.py info
python scripts/mysystem.py aerospace
python scripts/mysystem.py espanso
python scripts/mysystem.py skills
python scripts/mysystem.py help
python scripts/mysystem.py zsh
python scripts/mysystem.py all --json # machine-readable
```
**Agent action:** summarize the output logically — group AeroSpace bindings by
mode (e.g. *Window Management*, *Focus*, *Workspaces*), translate AeroSpace
commands into plain English ("moves the focused window to the left half of
the screen") without executing them.
## 3. Configuration sources & GNU Stow awareness
The user manages dotfiles with **GNU Stow**. Files in standard paths are
often symlinks into `~/macOS`. Always resolve the real path before citing
`file#line` — otherwise the tag points at a symlink, not the source of truth.
| What | Standard path | Stow target (real) |
|---|---|---|
| Shell info | `~/macOS/scripts/help.sh` | (already in repo) |
| AeroSpace | `~/.config/aerospace/aerospace.toml` | `~/macOS/aerospace/aerospace.toml` |
| Zsh | `~/.zshrc`, `~/.zsh_aliases`, `~/.zsh_functions` | `~/macOS/zsh/...` |
| Espanso | `$ESPANSO_MATCH_DIR` (default `~/Library/Application Support/espanso/match`) | `~/macOS/Espanso/` |
| Raycast / Spotlight / Keyboard | `~/macOS/scripts/help.sh` (`sec_raycast`, `sec_spotlight`, `sec_keyboard`) | — |
**Resolve with:** `readlink -f ~/.config/aerospace/aerospace.toml` (macOS 13+
ships BSD `readlink`; use `python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' `
for portability).
## 4. Zsh aliases and functions
Do **not** manually parse `.zshrc` — aliases and functions are often loaded
through conditionals, `source` chains, and plugin managers, so static parsing
lies. Dump the live environment instead.
```bash
# All active aliases (interactive, non-login shell)
zsh -ic "alias"
# Function names defined in ~/.zsh_functions (excludes private _* helpers)
zsh -f -c 'source ~/.zsh_functions; print -l ${(k)functions:#_*}'
# Source + preceding comment for a specific function
grep -n -B 6 'my_function' ~/.zsh_functions
# Full source of any function (works for plugin-defined ones too)
zsh -ic "which my_function"
```
**Agent action:** describe the alias/function in plain language. If the user
asks for the source, quote `zsh -ic "which <name>"` output verbatim.
## 5. Spotlight, Raycast, and keyboard shortcuts
Read `~/macOS/scripts/help.sh` directly and extract the relevant section:
| Section | Contains |
|---|---|
| `sec_spotlight` | Spotlight shortcut, enabled categories |
| `sec_raycast` | Raycast hotkeys, hotkey conflicts, extensions |
| `sec_keyboard` | macOS key remaps, custom shortcuts, text replacements |
**Rules:**
- Read the file — do not `bash` it.
- Note invocation shortcuts explicitly (`Cmd+Space` for Spotlight,
`Option+Space` for Raycast, etc.).
- Clearly attribute a capability to **Raycast** vs. **Spotlight** so the user
knows which app to open.
## 6. Response style & formatting
### Tag every claim with its source
- **[Configured]** — statically defined in a dotfile (e.g. `aerospace.toml`).
- **[Detected]** — live data from a script or shell check (RAM, Zsh version).
- **[Unknown]** — data that could not be resolved (file missing, command
absent, parse error).
### Traceability
Append the **real** (Stow-resolved) path and line number as a subdued tag:
> `Cmd+Enter` → opens Terminal — **[Configured]** (`~/macOS/aerospace/aerospace.toml#L45`)
### Format
- Short, distinct lists.
- Group related items (AeroSpace by mode, Espanso by file, aliases by
prefix).
- Bold the user-facing shortcut; plain-text the action.
### Execution rule
Explain commands, shortcuts, and scripts. **NEVER** execute a shortcut or a
structural script (AeroSpace reload, Espanso restart, `zsh` re-source) during
a configuration review.
## 7. When to use reference files
| File | Open when… |
| references/glossary.md | User uses unfamiliar terms ("what's a Stow package?") |
| references/troubleshooting.md | A command failed, a file is missing, or output is empty |
> 📖 **Official documentation:** every tool's official docs are collected in
> `references/glossary.md` — the "Official documentation" table at the end,
> plus a link under each term. When the user needs depth beyond this skill's
> scope, send them there instead of expanding the answer inline.
## 8. Examples
**Q: "Show me my AeroSpace keybindings."**
```bash
python scripts/mysystem.py aerospace
```
> ### AeroSpace keybindings
>
> **Mode `main`** — *Window Management* [Configured]
> - `alt-h` → focus left (`~/macOS/aerospace/aerospace.toml#L12`)
> - `alt-shift-h` → move window to left half (`…#L18`)
> - `alt-1` → workspace 1 (`…#L30`)
>
> **Mode `service`** — *System* [Configured]
> - `esc` → back to `main` (`…#L60`)
>
> ℹ️ AeroSpace v0.15.1 [Detected]
**Q: "What does `Cmd+Space` do on my Mac?"**
> `Cmd+Space` opens **Spotlight** (not Raycast) — [Configured]
> (`~/macOS/scripts/help.sh`, `sec_spotlight`, line 42).
> Raycast is bound to `Option+Space` instead.
## 9. Troubleshooting (short list — full list in `references/troubleshooting.md`)
| Symptom | Likely cause | Next step |
|---|---|---|
| `mysystem.py` exits with "Python 3.11+" | System Python is 3.9/3.10 | Ask user to `brew install python@3.12` or activate `.venv` |
| `AeroSpace config not found` | Symlink broken or file absent | `readlink -f ~/.config/aerospace/aerospace.toml` |
| Empty AeroSpace output | TOML syntax error | Surface the `tomllib.TOMLDecodeError` line number |
| Espanso dir not found | `$ESPANSO_MATCH_DIR` unset and default path missing | Check `~/macOS/Espanso` exists; ask user |
| `zsh: command not found: alias` | Non-interactive shell | Re-run with `-i` flag |
| `skills` list empty | `~/macOS/claude/skills` missing | Ask user to confirm the OpenClaw skills root |
## 10. Security & privacy
- All scripts in `scripts/` are **read-only** by design. If you need to add
one, document the "no writes, no network, no execution of user config"
contract at the top of the file.
- Never print full file contents unless the user asked for source. Prefer
excerpts + line references.
- Never send dotfile contents to external services.
- Do not echo `zsh -ic` output containing secrets (tokens, API keys in
env vars). Redact with `***` and note the redaction.
```
# 3. `requirements.txt`
```text
# mac-setup-master skill dependencies
PyYAML>=6.0,<7.0
```
# 4. Improved `scripts/mysystem.py`
```python
#!/usr/bin/env python3
"""mac-setup-master diagnostic helpers.
READ-ONLY. Never writes to disk, never executes the user's shell config,
never runs shortcuts. Safe to import from the Claude code interpreter.
Requires Python 3.11+ (stdlib ``tomllib``) and PyYAML.
CLI:
python scripts/mysystem.py [info|aerospace|espanso|skills|help|zsh|all]
[--json] [--help]
Import:
from mysystem import (
get_sys_info, parse_aerospace, parse_espanso, parse_skills,
parse_help_sections, get_zsh_aliases, get_zsh_functions,
)
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
try:
import tomllib # type: ignore[import-not-found]
except ModuleNotFoundError: # pragma: no cover
sys.exit("mysystem.py requires Python 3.11+ (stdlib tomllib). Run `python3 -V`.")
import yaml # PyYAML
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _safe_version(cmd: str) -> str:
"""Return --version output, or 'not installed' if the binary is missing."""
if shutil.which(cmd.split()[0]) is None:
return "not installed"
try:
out = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=5, check=False
).stdout.strip()
except subprocess.TimeoutExpired:
return "timed out"
return out or "unknown"
def _emit(payload: Any, as_json: bool, pretty: bool = True) -> None:
if as_json:
print(json.dumps(payload, indent=2, ensure_ascii=False))
elif pretty:
# Human-readable handled by callers; this is just a fallback.
print(payload)
# --------------------------------------------------------------------------- #
# System info
# --------------------------------------------------------------------------- #
def get_sys_info(as_json: bool = False) -> dict[str, str]:
"""Return read-only system metrics. Also prints unless as_json."""
try:
ram_bytes = int(subprocess.getoutput("sysctl -n hw.memsize"))
ram = f"{ram_bytes / (1024 ** 3):.2f} GB"
except ValueError:
ram = "unknown"
info: dict[str, str] = {
"OS_Version": _safe_version("sw_vers -productVersion"),
"Architecture": _safe_version("uname -m"),
"RAM": ram,
"Disk_Free": f"{shutil.disk_usage('/').free / (1024 ** 3):.2f} GB",
"Zsh_Version": _safe_version("zsh --version"),
"AeroSpace": _safe_version("aerospace --version"),
}
if as_json:
return info
for key, value in info.items():
print(f"{key}: {value}")
return info
# --------------------------------------------------------------------------- #
# AeroSpace
# --------------------------------------------------------------------------- #
def parse_aerospace(as_json: bool = False) -> dict[str, dict[str, str]]:
"""Parse ~/.config/aerospace/aerospace.toml -> {mode: {key: action}}."""
path = Path("~/.config/aerospace/aerospace.toml").expanduser()
if not path.exists():
msg = f"AeroSpace config not found at {path}"
print(msg, file=sys.stderr)
return {}
try:
with path.open("rb") as fh:
config = tomllib.load(fh)
except tomllib.TOMLDecodeError as exc:
print(f"Invalid TOML in {path}: {exc}", file=sys.stderr)
return {}
bindings: dict[str, dict[str, str]] = {}
for mode, body in config.get("mode", {}).items():
bindings[mode] = dict(body.get("binding", {}))
if as_json:
return bindings
print("--- AeroSpace Keybindings ---")
for mode, keys in bindings.items():
print(f"\nMode: [{mode}]")
for key, action in keys.items():
print(f" {key.ljust(18)} -> {action}")
if not bindings:
print("(no bindings found)")
return bindings
# --------------------------------------------------------------------------- #
# Espanso
# --------------------------------------------------------------------------- #
def parse_espanso(as_json: bool = False) -> list[dict[str, str]]:
"""Parse $ESPANSO_MATCH_DIR (default Espanso match dir) for triggers."""
match_dir = Path(
os.environ.get(
"ESPANSO_MATCH_DIR",
os.path.expanduser("~/Library/Application Support/espanso/match"),
)
).expanduser()
if not match_dir.exists():
print(f"Espanso match directory not found: {match_dir}", file=sys.stderr)
return []
results: list[dict[str, str]] = []
for path in sorted(match_dir.rglob("*")):
if path.suffix.lower() not in {".yml", ".yaml"}:
continue
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError) as exc:
print(f"Could not read {path}: {exc}", file=sys.stderr)
continue
for match in data.get("matches", []):
if not isinstance(match, dict):
continue
triggers = match.get("triggers", match.get("trigger", []))
if isinstance(triggers, str):
triggers = [triggers]
replacement = match.get("replace")
if replacement is None:
kind = (
"form" if "form" in match
else "vars" if "vars" in match
else "image" if "image_path" in match
else "dynamic"
)
replacement = f"<{kind} match>"
for trigger in triggers:
results.append({
"file": path.name,
"trigger": str(trigger),
"replacement": str(replacement),
})
if as_json:
return results
for row in results:
print(f"{row['file']}: {row['trigger']!r} -> {row['replacement']!r}")
return results
# --------------------------------------------------------------------------- #
# OpenClaw skills
# --------------------------------------------------------------------------- #
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---(?:\s|\Z)", re.S)
def search_skills(
query: str | None = None,
root: str = "~/macOS/claude/skills",
) -> list[dict[str, str]]:
"""Return OpenClaw skills whose name/description match *query* (case-insensitive)."""
root_path = Path(root).expanduser()
if not root_path.exists():
print(f"Skills root not found: {root_path}", file=sys.stderr)
return []
q = query.casefold() if query else None
results: list[dict[str, str]] = []
for skill_file in root_path.rglob("SKILL.md"):
text = skill_file.read_text(encoding="utf-8")
match = _FRONTMATTER_RE.match(text)
if not match:
continue
try:
meta = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError:
continue
name = meta.get("name", skill_file.parent.name)
description = (meta.get("description") or "").strip()
if q and q not in name.casefold() and q not in description.casefold():
continue
results.append({
"name": name,
"description": description,
"path": str(skill_file.parent),
})
return results
def parse_skills(as_json: bool = False) -> list[dict[str, str]]:
skills = search_skills()
if as_json:
return skills
if not skills:
print("(no skills found under ~/macOS/claude/skills)")
return skills
for skill in skills:
print(f"{skill['name']}: {skill['description']}\n {skill['path']}")
return skills
# --------------------------------------------------------------------------- #
# help.sh sections (Spotlight / Raycast / Keyboard)
# --------------------------------------------------------------------------- #
_HELP_SECTION_RE = re.compile(
r"^sec_(?Pspotlight|raycast|keyboard)\s*\(\)\s*\{(?P.*?)^\}",
re.S | re.M,
)
def parse_help_sections(as_json: bool = False) -> dict[str, str]:
"""Extract sec_spotlight / sec_raycast / sec_keyboard bodies from help.sh."""
path = Path("~/macOS/scripts/help.sh").expanduser()
if not path.exists():
print(f"help.sh not found at {path}", file=sys.stderr)
return {}
text = path.read_text(encoding="utf-8")
sections = {
m.group("name"): m.group("body").strip()
for m in _HELP_SECTION_RE.finditer(text)
}
if as_json:
return sections
for name, body in sections.items():
print(f"\n=== sec_{name} ===\n{body}")
return sections
# --------------------------------------------------------------------------- #
# Zsh helpers
# --------------------------------------------------------------------------- #
def get_zsh_aliases(as_json: bool = False) -> dict[str, str]:
"""Dump active aliases via an interactive, non-login shell."""
out = subprocess.run(
["zsh", "-ic", "alias"], capture_output=True, text=True, check=False
).stdout
aliases: dict[str, str] = {}
for line in out.splitlines():
# Format: name='value' or name=value
match = re.match(r"^(\S+)=(?:'([^']*)'|\"([^\"]*)\"|(.*))$", line.strip())
if not match:
continue
name = match.group(1)
value = next(g for g in match.groups()[1:] if g is not None)
aliases[name] = value
if as_json:
return aliases
for name, value in aliases.items():
print(f"{name}={value}")
return aliases
def get_zsh_functions(as_json: bool = False) -> list[str]:
"""List public (non-underscore) function names from ~/.zsh_functions."""
out = subprocess.run(
["zsh", "-f", "-c",
"source ~/.zsh_functions; print -l ${(k)functions:#_*}"],
capture_output=True, text=True, check=False,
).stdout
names = sorted({line.strip() for line in out.splitlines() if line.strip()})
if as_json:
return names
for name in names:
print(name)
return names
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
_COMMANDS = {
"info": get_sys_info,
"aerospace": parse_aerospace,
"espanso": parse_espanso,
"skills": parse_skills,
"help": parse_help_sections,
"zsh": lambda as_json=False: {
"aliases": get_zsh_aliases(as_json=True) if as_json else None,
"functions": get_zsh_functions(as_json=True) if as_json else None,
},
"all": None, # special-cased
}
def _run_all(as_json: bool) -> None:
if as_json:
print(json.dumps({
"info": get_sys_info(as_json=True),
"aerospace": parse_aerospace(as_json=True),
"espanso": parse_espanso(as_json=True),
"skills": parse_skills(as_json=True),
"help_sections": parse_help_sections(as_json=True),
"zsh_aliases": get_zsh_aliases(as_json=True),
"zsh_functions": get_zsh_functions(as_json=True),
}, indent=2, ensure_ascii=False))
return
get_sys_info()
parse_aerospace()
parse_espanso()
parse_skills()
parse_help_sections()
print("\n--- Zsh aliases ---")
get_zsh_aliases()
print("\n--- Zsh functions ---")
get_zsh_functions()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="mysystem.py",
description="Read-only diagnostics for the mac-setup-master skill.",
)
parser.add_argument(
"command",
nargs="?",
default="all",
choices=sorted(_COMMANDS),
help="section to run (default: all)",
)
parser.add_argument("--json", action="store_true", help="machine-readable output")
args = parser.parse_args(argv)
if args.command == "all":
_run_all(args.json)
return 0
fn = _COMMANDS[args.command]
if args.command == "zsh":
if args.json:
print(json.dumps({
"aliases": get_zsh_aliases(as_json=True),
"functions": get_zsh_functions(as_json=True),
}, indent=2, ensure_ascii=False))
else:
print("--- Zsh aliases ---"); get_zsh_aliases()
print("\n--- Zsh functions ---"); get_zsh_functions()
return 0
fn(as_json=args.json)
return 0
if __name__ == "__main__":
raise SystemExit(main())
6. Suggested references/ files
references/troubleshooting.md
# Troubleshooting — mac-setup-master
Indexed by symptom. Each entry follows **Symptom → Cause → Fix → Verify**.
Official documentation links are collected at the bottom of this file.
---
## 1. `mysystem.py` won't start
### 1.1 `No module named 'tomllib'`
| | |
|---|---|
| **Symptom** | Running `python scripts/mysystem.py` raises `ModuleNotFoundError: No module named 'tomllib'` |
| **Cause** | macOS ships `/usr/bin/python3` at **3.9**. `tomllib` only entered the standard library in **3.11**, so even if `python3 -V` reports 3.9, `tomllib` is absent. |
| **Fix** | Activate the skill's `.venv` (recommended), or install 3.11+: `brew install python@3.12` |
| **Verify** | `python3 -c "import tomllib; print('ok')"` prints `ok` |
[...]
references/glossary.md
# Glossary — mac-setup-master
Grouped by category. Every entry gives a **one-line definition** + **where/how it's used in this skill** + **official docs link**.
---
## Shell / Zsh
**Zsh**
: The UNIX command interpreter; the default interactive shell on macOS since 10.15. This skill reads aliases and functions via `zsh -ic` and `zsh -f -c` rather than statically parsing `.zshrc`.
: 📖 [zsh.sourceforge.io/Doc](https://zsh.sourceforge.io/Doc/)
[...]