diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..011bf48 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.py] +indent_size = 4 + +[*.ps1] +end_of_line = crlf + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..00292e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Normalize line endings so the cross-platform scripts stay runnable everywhere. +* text=auto eol=lf + +# Shell scripts MUST stay LF even on a Windows checkout — a CRLF in the shebang +# line breaks them under Git Bash ("/usr/bin/env bash^M: bad interpreter"). +*.sh text eol=lf +*.py text eol=lf +*.zsh text eol=lf + +# PowerShell is happiest with CRLF on Windows. +*.ps1 text eol=crlf + +# Binary assets — never normalize or diff. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.pdf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc5a2c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + test: + name: ${{ matrix.os }} / py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.9', '3.12'] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install test deps + run: python -m pip install --upgrade pip pytest + + - name: Run tests (hooks + installer, sandboxed) + run: python -m pytest -q diff --git a/.gitignore b/.gitignore index 7288e8d..bed9b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store __pycache__/ *.pyc +.pytest_cache/ # OMC local agent state .omc/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0e2347a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to +follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed +- `fable-trigger.py` read the playbook from a hardcoded `/Users/ak/...` path, so + on-demand injection silently failed for everyone but the original author. It now + resolves `~/.claude/FABLE_PLAYBOOK.md`. +- `test-after-edit.py` was a silent no-op on Windows — the `npm`/`pnpm`/`yarn`/ + `make` shims raised `FileNotFoundError`. It now resolves the runner via + `shutil.which` and runs through `cmd.exe` on Windows. Also dropped a duplicate + `.lockb` skip entry. + +### Added +- **One-command, cross-platform installer** (`install.py`) for Windows, macOS, and + Linux. `install.sh` / `install.ps1` are thin wrappers that exec it. +- PowerShell launcher (`shell/fable.ps1`) alongside the zsh one. +- `uninstall.py` (+ `.sh` / `.ps1` wrappers) — surgical reversal of the install: + removes bundled files, strips the launcher line, drops only the Fable hooks from + `settings.json`; leaves user skills, unrelated hooks, and `~/.claude` intact. +- pytest suite for both hooks and the installer; GitHub Actions CI across + ubuntu/macos/windows × Python 3.9 and 3.12. +- `.gitattributes` (LF for shell/Python, CRLF for PowerShell), `.editorconfig`, + `SECURITY.md`, `CONTRIBUTING.md`, and this changelog. + +### Changed +- `merge_settings.py` writes the absolute interpreter (`sys.executable`) and + absolute hook paths into `settings.json`, so the hooks fire without `$HOME` or + `python3` resolution at hook-run time. +- Unified the project owner to **HalalifyMusic** in `LICENSE` and `README`. +- Removed a dead `CONNECTORS.md` link in the `explore-data` skill. + +## [0.1.0] + +### Added +- Initial fable-mode bundle: the Fable 5 system prompt (`fable-system.md`), the + `FABLE_PLAYBOOK.md` execution playbook, the `fable-trigger` / `test-after-edit` + hooks, the `/ground` skill and `grounding-verifier` agent, bundled + design/testing/MCP skills, and the `fable` zsh launcher. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab2516c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to fable-mode + +Thanks for helping! fable-mode is deliberately small and cross-platform. A few +conventions keep it that way. + +## Development setup + +```sh +git clone https://github.com/HalalifyMusic/fable-mode +cd fable-mode +python -m pip install --upgrade pytest +python -m pytest -q +``` + +Requires Python ≥ 3.9. The hooks and the installer are **stdlib-only** — keep them +dependency-free so they run in any Claude Code environment without a pip install. + +## Layout + +- `install.py` — the real installer; `install.sh` / `install.ps1` just locate + Python and exec it. `uninstall.py` mirrors it (with the same wrappers). +- `scripts/merge_settings.py` — shared `settings.json` merge (importable + CLI). +- `hooks/` — `fable-trigger.py` (playbook injection) and `test-after-edit.py` (run + tests after an edit). Both must exit 0 and never block a prompt or an edit. +- `shell/` — `fable.zsh` (Unix) and `fable.ps1` (PowerShell) launchers. +- `skills/`, `agents/` — bundled skills and the grounding-verifier agent. +- `tests/` — pytest for the hooks and the installer. + +## Rules of the road + +- **Cross-platform first.** Anything touching paths, shells, or subprocesses must + work on Windows, macOS, and Linux. CI runs the suite on all three. +- **Add tests.** Changes to a hook or the installer need matching tests in + `tests/`. Keep them hermetic — use `tmp_path` and the host interpreter, no + Node / make / network. +- **Don't fight line endings.** `.gitattributes` enforces LF for shell/Python and + CRLF for PowerShell. Let it; `.editorconfig` matches. +- **Leave vendored content alone.** The Anthropic skills under `skills/` and + `fable-system.md` are upstream copies — fix those upstream, not here. +- Match the surrounding style and keep diffs focused. + +## Pull requests + +1. Branch off `main`. +2. `python -m pytest -q` green locally. +3. Describe what changed and why; note any platform-specific behavior. +4. CI must pass on ubuntu/macos/windows before merge. + +See [SECURITY.md](SECURITY.md) for the security model and how to report issues, and +[CHANGELOG.md](CHANGELOG.md) for the running history. diff --git a/LICENSE b/LICENSE index 686b1b9..75aad6a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 ak +Copyright (c) 2026 HalalifyMusic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 8874cec..0fb9e16 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ **Run Claude Fable 5 on Opus 4.8.** The Mythos-class model the U.S. government pulled after three days — brought back as a system prompt. +![CI](https://github.com/HalalifyMusic/fable-mode/actions/workflows/ci.yml/badge.svg)   ![Stars](https://img.shields.io/github/stars/HalalifyMusic/fable-mode?style=social)   ![License](https://img.shields.io/badge/license-MIT-blue)   ![Claude Code](https://img.shields.io/badge/Claude%20Code-Opus%204.8-d97757)   @@ -27,14 +28,35 @@ But when its system prompt leaked, people noticed: a lot of what made Fable *fee ## Quickstart +One installer, every OS — it's Python (already required by the hooks), so the same command works on Windows, macOS, and Linux: + ```sh git clone https://github.com/HalalifyMusic/fable-mode -cd fable-mode && ./install.sh -source ~/.zshrc +cd fable-mode +python install.py # Windows (use python3 on macOS / Linux) +``` + +Then reload your shell and launch: + +```sh fable # Opus 4.8 + Fable prompt + ultracode ``` -`install.sh` copies everything into `~/.claude`, adds the `fable` launcher, and merges your settings (with a backup). No model switch, no API key — it runs on the Opus 4.8 you already have. +Prefer a native one-liner? `./install.sh` (macOS / Linux) and `.\install.ps1` (Windows) just locate Python and run `install.py` for you. + +**Reload after install:** `source ~/.zshrc` (or `~/.bashrc`) on Unix; `. $PROFILE` in PowerShell on Windows. + +> **Requirements:** Python on PATH (`python --version`) for the hooks, and Claude Code installed for the `fable` launcher. On Windows, if `.\install.ps1` is blocked by execution policy, run `python install.py` directly (no policy needed). + +The installer copies everything into `~/.claude`, adds the `fable` launcher (to your shell rc on Unix, to your PowerShell `$PROFILE` on Windows), and merges your settings (with a backup) — writing the absolute interpreter and hook paths so the hooks fire on every platform. Idempotent: safe to re-run. Needs Python ≥ 3.9 (the hooks are stdlib-only — no pip installs). No model switch, no API key — it runs on the Opus 4.8 you already have. + +## Uninstall + +```sh +python uninstall.py # Windows (python3 on macOS / Linux; or ./uninstall.sh, .\uninstall.ps1) +``` + +Removes the bundled files from `~/.claude`, strips the `fable` launcher line, and drops the two Fable hooks from `settings.json` (writing a fresh backup). It's surgical — your own skills, unrelated hooks, and `alwaysThinkingEnabled` are left untouched. ## What's in the bundle @@ -43,7 +65,7 @@ fable # Opus 4.8 + Fable prompt + ultracode - **Hooks** — `fable-trigger.py` injects the playbook at `xhigh`/`max`/`ultracode`; `test-after-edit.py` runs your project's tests after each edit and reports the result back — the one habit no model keeps on willpower. - **`/ground` skill + `grounding-verifier` agent** — a self-terminating grounding loop and a cold verifier that assumes every claim is wrong until the live code proves it. - **Skills** — `claude-design-patterns` (web-UI engineering), `webapp-testing`, `mcp-builder`, `skill-creator`, `explore-data`. -- **`fable()` launcher** — Opus 4.8 + the prompt + `ultracode` effort. +- **`fable` launcher** — Opus 4.8 + the prompt + `ultracode` effort (`fable.zsh` for Unix shells, `fable.ps1` for PowerShell). ## The honest ceiling @@ -56,7 +78,11 @@ This gives you Fable's *disposition*, not its raw capability. Reasoning depth, v ## Credits -Made by me — compiled from community sources (leaked prompts, public Anthropic skills) and original measurement and tooling work. +Made by HalalifyMusic — compiled from community sources (leaked prompts, public Anthropic skills) and original measurement and tooling work. + +## Contributing + +Issues and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup and conventions, [SECURITY.md](SECURITY.md) for the security model (the hooks run code on your machine), and [CHANGELOG.md](CHANGELOG.md) for the running history. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9f828da --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security Policy + +## Supported versions + +fable-mode is a small tooling + prompt bundle, not a versioned library. Security +fixes land on the `main` branch — please run the latest `main`. + +## What fable-mode runs on your machine + +Installing fable-mode wires two hooks into Claude Code that run **on your own +machine**, with your permissions: + +- **`fable-trigger.py`** (UserPromptSubmit) — reads `~/.claude/FABLE_PLAYBOOK.md` + and injects it into the prompt context. It does not execute project code and + makes no network calls. +- **`test-after-edit.py`** (PostToolUse on Edit/Write/MultiEdit) — **runs your + project's own test command** (`npm test`, `pytest`, `cargo test`, `go test`, + `make test`) automatically after a code edit, to report pass/fail. Editing a + file can therefore trigger execution of that project's test suite. + +Neither hook sends anything over the network. They read/write only under +`~/.claude` and the system temp dir (debounce/marker files), and run the detected +test command in the edited project's directory. + +Because the test hook runs a project's test command, **only enable fable-mode in +repositories you trust** — the same caution you would apply to running their +tests yourself. + +### Turning the test hook off + +- Set `FABLE_NO_TEST_HOOK=1` to disable it entirely. +- Tune `FABLE_TEST_HOOK_DEBOUNCE` / `FABLE_TEST_HOOK_TIMEOUT` (seconds). +- Or remove the `PostToolUse` entry from `~/.claude/settings.json` — `uninstall.py` + does this for you. + +## Bundled third-party content + +`fable-system.md` is Anthropic's Claude Fable 5 system prompt, included only so +setup is a single step. It is third-party content, not authored or audited here, +and is removable on request. The skills under `skills/` (`webapp-testing`, +`mcp-builder`, `skill-creator`, `explore-data`) are vendored from upstream +Apache-2.0 repos. Treat all of it as untrusted-origin text. + +## Reporting a vulnerability + +Please report security issues **privately**, not in a public issue: + +- Use GitHub's **"Report a vulnerability"** (the repo's *Security → Advisories* + tab), or +- if private advisories are disabled, open a minimal public issue asking for a + private contact channel — do not include details there. + +Include what you found, how to reproduce it, and the impact. We'll acknowledge the +report and work on a fix; please allow a reasonable window before public +disclosure. diff --git a/hooks/fable-trigger.py b/hooks/fable-trigger.py index 31f9d31..8452e56 100644 --- a/hooks/fable-trigger.py +++ b/hooks/fable-trigger.py @@ -18,7 +18,7 @@ import os import tempfile -PLAYBOOK = "/Users/ak/FABLE_PLAYBOOK.md" +PLAYBOOK = os.path.expanduser(os.path.join("~", ".claude", "FABLE_PLAYBOOK.md")) TRIGGER = re.compile(r"\b(use fable|fable mode|load fable)\b", re.I) HEAVY_EFFORT = {"xhigh", "max", "ultracode"} diff --git a/hooks/test-after-edit.py b/hooks/test-after-edit.py index c48cab2..bae668a 100644 --- a/hooks/test-after-edit.py +++ b/hooks/test-after-edit.py @@ -18,18 +18,20 @@ import os import json import time +import shutil import hashlib import tempfile import subprocess DEBOUNCE = int(os.environ.get("FABLE_TEST_HOOK_DEBOUNCE", "45")) TIMEOUT = int(os.environ.get("FABLE_TEST_HOOK_TIMEOUT", "90")) +IS_WINDOWS = os.name == "nt" # File types that should never trigger a test run (docs, data, config, assets). SKIP_EXT = { ".md", ".markdown", ".txt", ".rst", ".json", ".jsonc", ".lock", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".csv", ".tsv", ".svg", ".png", ".jpg", - ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".lockb", + ".jpeg", ".gif", ".webp", ".ico", ".pdf", } @@ -132,10 +134,25 @@ def main(): if debounced(root): return + # On Windows the interpreter path may contain spaces and several runners + # (npm/pnpm/yarn/make) are .cmd shims that can't be exec'd directly — use the + # bare interpreter name and let cmd.exe resolve it via PATHEXT under shell=True. + prog = cmd[0] + if IS_WINDOWS and prog == sys.executable: + prog = "python" + if not shutil.which(prog): + return # runner not installed — silent + run_args = [prog] + cmd[1:] + t0 = time.time() try: - p = subprocess.run(cmd, cwd=root, capture_output=True, text=True, - timeout=TIMEOUT) + if IS_WINDOWS: + # tokens are bare (no spaces), so a plain join is unambiguous for cmd.exe + p = subprocess.run(" ".join(run_args), cwd=root, capture_output=True, + text=True, timeout=TIMEOUT, shell=True) + else: + p = subprocess.run(run_args, cwd=root, capture_output=True, text=True, + timeout=TIMEOUT) except subprocess.TimeoutExpired: emit(f"test-after-edit ⏱ — `{label}` exceeded {TIMEOUT}s in {root}; " "result inconclusive, run it manually before claiming done.") diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..b9a266a --- /dev/null +++ b/install.ps1 @@ -0,0 +1,16 @@ +#!/usr/bin/env pwsh +# Native entry point for Windows. Locates Python and runs the real, +# cross-platform installer (install.py). macOS / Linux: use ./install.sh +# (or run `python install.py` directly on any OS). +# Compatible with Windows PowerShell 5.1 and PowerShell 7+. +$ErrorActionPreference = "Stop" + +$Repo = Split-Path -Parent $MyInvocation.MyCommand.Path +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { $python = Get-Command python3 -ErrorAction SilentlyContinue } +if (-not $python) { + Write-Error "python (or python3) not found on PATH — required for the hooks." + exit 1 +} +& $python.Source (Join-Path $Repo "install.py") @args +exit $LASTEXITCODE diff --git a/install.py b/install.py new file mode 100755 index 0000000..ab8eb2c --- /dev/null +++ b/install.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""One-command, cross-platform installer for Fable mode (Windows / macOS / Linux). + + python install.py # Windows + python3 install.py # macOS / Linux + +Python is already a hard dependency (the hooks are Python), so the installer is +Python too: one source of truth, identical behavior on every OS. install.sh and +install.ps1 are thin native wrappers that just locate Python and exec this file. + +Idempotent. Backs up an existing settings.json. The interpreter written into the +hook commands is sys.executable — the exact, absolute Python that ran this script. +""" +import os +import sys +import shutil +import subprocess + +REPO = os.path.dirname(os.path.abspath(__file__)) +HOME = os.path.expanduser("~") +CLAUDE = os.path.join(HOME, ".claude") +IS_WINDOWS = os.name == "nt" + +sys.path.insert(0, os.path.join(REPO, "scripts")) +from merge_settings import merge # noqa: E402 + + +def copy_into(rel_file, dst_dir): + shutil.copy(os.path.join(REPO, rel_file), os.path.join(dst_dir, os.path.basename(rel_file))) + + +def copytree_idempotent(src, dst): + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst) + + +def append_once(path, marker, block): + """Append `block` to `path` unless `marker` already appears in it.""" + existing = "" + if os.path.exists(path): + with open(path, encoding="utf-8") as f: + existing = f.read() + if marker in existing: + print(" launcher already present in {} - skipped".format(path)) + return + parent = os.path.dirname(path) + if parent: + os.makedirs(parent, exist_ok=True) + with open(path, "a", encoding="utf-8") as f: + if existing and not existing.endswith("\n"): + f.write("\n") + f.write(block) + print(" added launcher to {}".format(path)) + + +def powershell_profile_path(): + """Ask the available PowerShell for its $PROFILE; fall back to the PS7 default.""" + for exe in ("pwsh", "powershell"): + if shutil.which(exe): + try: + out = subprocess.run([exe, "-NoProfile", "-Command", "$PROFILE"], + capture_output=True, text=True, timeout=20) + p = out.stdout.strip() + if p: + return p + except Exception: + pass + return os.path.join(HOME, "Documents", "PowerShell", + "Microsoft.PowerShell_profile.ps1") + + +def install_launcher(): + if IS_WINDOWS: + profile = powershell_profile_path() + src = os.path.join(REPO, "shell", "fable.ps1") + block = ('\n# Fable mode (added by fable-mode/install.py)\n. "{}"\n'.format(src)) + append_once(profile, "fable.ps1", block) + else: + shell = os.environ.get("SHELL", "") + rc = os.path.join(HOME, ".bashrc" if "bash" in shell else ".zshrc") + src = os.path.join(REPO, "shell", "fable.zsh") + block = ('\n# Fable mode (added by fable-mode/install.py)\nsource "{}"\n'.format(src)) + append_once(rc, "fable.zsh", block) + + +def main(): + for sub in ("hooks", "skills", "agents"): + os.makedirs(os.path.join(CLAUDE, sub), exist_ok=True) + + if not shutil.which("claude"): + print("warning: 'claude' CLI not found on PATH - install Claude Code before running 'fable'.") + + print("-> hooks") + copy_into("hooks/fable-trigger.py", os.path.join(CLAUDE, "hooks")) + copy_into("hooks/test-after-edit.py", os.path.join(CLAUDE, "hooks")) + + print("-> playbook") + copy_into("FABLE_PLAYBOOK.md", CLAUDE) + + print("-> fable system prompt") + copy_into("fable-system.md", CLAUDE) + + print("-> skills (all bundled) + agent") + skills_dir = os.path.join(REPO, "skills") + for name in sorted(os.listdir(skills_dir)): + src = os.path.join(skills_dir, name) + if os.path.isdir(src): + copytree_idempotent(src, os.path.join(CLAUDE, "skills", name)) + copy_into("agents/grounding-verifier.md", os.path.join(CLAUDE, "agents")) + + print("-> launcher") + install_launcher() + + print("-> settings.json (alwaysThinkingEnabled + hooks; backup written)") + merge(os.path.join(CLAUDE, "settings.json"), sys.executable, os.path.join(CLAUDE, "hooks")) + + print() + print("Done. Now:") + if IS_WINDOWS: + print(" 1. . $PROFILE (reload your PowerShell profile)") + else: + print(" 1. source your shell rc (e.g. source ~/.zshrc)") + print(" 2. run: fable") + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh index c466ba2..279675e 100755 --- a/install.sh +++ b/install.sh @@ -1,58 +1,13 @@ #!/usr/bin/env bash -# Install Fable mode into ~/.claude and ~/.zshrc. Idempotent; backs up settings.json. +# Native entry point for macOS / Linux. Locates Python and runs the real, +# cross-platform installer (install.py). Windows: use install.ps1 (or run +# `python install.py` directly on any OS). set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CLAUDE="$HOME/.claude" -mkdir -p "$CLAUDE/hooks" "$CLAUDE/skills" "$CLAUDE/agents" - -echo "→ hooks" -cp "$REPO/hooks/fable-trigger.py" "$CLAUDE/hooks/" -cp "$REPO/hooks/test-after-edit.py" "$CLAUDE/hooks/" - -echo "→ playbook" -cp "$REPO/FABLE_PLAYBOOK.md" "$CLAUDE/FABLE_PLAYBOOK.md" - -echo "→ fable system prompt" -cp "$REPO/fable-system.md" "$CLAUDE/fable-system.md" - -echo "→ skills (all bundled) + agent" -for d in "$REPO"/skills/*/; do cp -R "$d" "$CLAUDE/skills/"; done -cp "$REPO/agents/grounding-verifier.md" "$CLAUDE/agents/" - -echo "→ launcher (~/.zshrc)" -if ! grep -q 'fable()' "$HOME/.zshrc" 2>/dev/null; then - printf '\n# Fable mode (added by fable-mode/install.sh)\nsource "%s/shell/fable.zsh"\n' "$REPO" >> "$HOME/.zshrc" - echo " added source line" -else - echo " fable() already present — skipped" +PYTHON="$(command -v python3 || command -v python || true)" +if [ -z "$PYTHON" ]; then + echo "error: python3 (or python) not found on PATH — required for the hooks." >&2 + exit 1 fi - -echo "→ settings.json (alwaysThinkingEnabled + hooks; backup written)" -python3 - "$CLAUDE/settings.json" <<'PY' -import json, os, sys, shutil -p = sys.argv[1] -d = json.load(open(p)) if os.path.exists(p) else {} -if os.path.exists(p): - shutil.copy(p, p + ".bak") -d["alwaysThinkingEnabled"] = True -hooks = d.setdefault("hooks", {}) -def ensure(event, entry, needle): - arr = hooks.setdefault(event, []) - if not any(needle in h.get("command", "") for e in arr for h in e.get("hooks", [])): - arr.append(entry) -ensure("UserPromptSubmit", - {"hooks": [{"type": "command", "command": "python3 $HOME/.claude/hooks/fable-trigger.py"}]}, - "fable-trigger.py") -ensure("PostToolUse", - {"matcher": "Edit|Write|MultiEdit", - "hooks": [{"type": "command", "command": "python3 $HOME/.claude/hooks/test-after-edit.py"}]}, - "test-after-edit.py") -json.dump(d, open(p, "w"), indent=2) -print(" settings.json updated") -PY - -echo -echo "Done. Now:" -echo " 1. source ~/.zshrc" -echo " 2. run: fable" +exec "$PYTHON" "$REPO/install.py" "$@" diff --git a/scripts/merge_settings.py b/scripts/merge_settings.py new file mode 100644 index 0000000..793d976 --- /dev/null +++ b/scripts/merge_settings.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Merge Fable-mode settings into an existing Claude Code settings.json. + +Importable (`from merge_settings import merge`) and runnable as a script. Writes +ABSOLUTE paths for both the Python interpreter and the hook scripts, so the +resulting hook commands work the same on macOS, Linux, and Windows — no reliance +on `$HOME` expansion or a `python3` alias being present at hook-run time. + +Usage (script): + merge_settings.py +""" +import json +import os +import sys +import shutil + + +def merge(settings_path, py, hooks_dir): + d = json.load(open(settings_path)) if os.path.exists(settings_path) else {} + if os.path.exists(settings_path): + shutil.copy(settings_path, settings_path + ".bak") + + d["alwaysThinkingEnabled"] = True + hooks = d.setdefault("hooks", {}) + + def cmd(name): + return '"{}" "{}"'.format(py, os.path.join(hooks_dir, name)) + + def ensure(event, entry, needle): + arr = hooks.setdefault(event, []) + if not any(needle in h.get("command", "") + for e in arr for h in e.get("hooks", [])): + arr.append(entry) + + ensure("UserPromptSubmit", + {"hooks": [{"type": "command", "command": cmd("fable-trigger.py")}]}, + "fable-trigger.py") + ensure("PostToolUse", + {"matcher": "Edit|Write|MultiEdit", + "hooks": [{"type": "command", "command": cmd("test-after-edit.py")}]}, + "test-after-edit.py") + + json.dump(d, open(settings_path, "w"), indent=2) + print(" settings.json updated") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit("usage: merge_settings.py ") + merge(sys.argv[1], sys.argv[2], sys.argv[3]) diff --git a/settings.fragment.json b/settings.fragment.json index 31dfe8a..d9b88ea 100644 --- a/settings.fragment.json +++ b/settings.fragment.json @@ -1,5 +1,5 @@ { - "_comment": "Merge these into ~/.claude/settings.json (install.sh does this for you, with a backup). $HOME expands at hook-run time.", + "_comment": "Reference only. install.sh / install.ps1 merge these into ~/.claude/settings.json (with a backup) and write ABSOLUTE interpreter + script paths so the hooks work without $HOME/python3 resolution. If editing by hand, replace 'python3' with your interpreter (e.g. 'python' on Windows) and $HOME with the full path to your home dir.", "alwaysThinkingEnabled": true, "hooks": { "UserPromptSubmit": [ diff --git a/shell/fable.ps1 b/shell/fable.ps1 new file mode 100644 index 0000000..e61a546 --- /dev/null +++ b/shell/fable.ps1 @@ -0,0 +1,14 @@ +# Fable mode launcher (PowerShell). Dot-source from your profile, or: +# . C:\path\to\fable-mode\shell\fable.ps1 +# +# Launches Claude Code (Opus 4.8) with the Fable 5 system prompt appended and +# ultracode effort (sends xhigh to the model AND auto-orchestrates multi-agent +# workflows for substantive tasks — the heaviest mode). ultracode is session-only, +# so it's set via --settings, not --effort. It also trips fable-trigger.py, which +# layers FABLE_PLAYBOOK execution discipline on top. +# +# install.ps1 copies fable-system.md into ~\.claude for you. +# If ultracode's auto-workflows burn too many tokens, swap --settings for --effort xhigh. +function fable { + claude --append-system-prompt-file "$HOME\.claude\fable-system.md" --settings '{"ultracode": true}' @args +} diff --git a/shell/fable.zsh b/shell/fable.zsh index cdcd254..7598d98 100644 --- a/shell/fable.zsh +++ b/shell/fable.zsh @@ -6,7 +6,7 @@ # so it's set via --settings, not --effort. It also trips fable-trigger.py, which # layers FABLE_PLAYBOOK execution discipline on top. # -# Supply your own ~/.claude/fable-system.md first (see fable-system.md.example). +# install.sh copies fable-system.md into ~/.claude for you. # If ultracode's auto-workflows burn too many tokens, swap --settings for --effort xhigh. fable() { claude --append-system-prompt-file "$HOME/.claude/fable-system.md" --settings '{"ultracode": true}' "$@" diff --git a/skills/explore-data/SKILL.md b/skills/explore-data/SKILL.md index f1e7032..fa55e69 100644 --- a/skills/explore-data/SKILL.md +++ b/skills/explore-data/SKILL.md @@ -6,7 +6,6 @@ argument-hint: "" # /explore-data - Profile and Explore a Dataset -> If you see unfamiliar placeholders or need to check which tools are connected, see [CONNECTORS.md](../../CONNECTORS.md). Generate a comprehensive data profile for a table or uploaded file. Understand its shape, quality, and patterns before diving into analysis. diff --git a/tests/test_fable_trigger.py b/tests/test_fable_trigger.py new file mode 100644 index 0000000..b07531b --- /dev/null +++ b/tests/test_fable_trigger.py @@ -0,0 +1,66 @@ +"""Tests for hooks/fable-trigger.py — the on-demand playbook injector.""" +import json +import os +import subprocess +import sys +import uuid +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +HOOK = REPO / "hooks" / "fable-trigger.py" + + +def run(stdin_obj, home): + env = dict(os.environ) + env["HOME"] = str(home) # expanduser on POSIX + env["USERPROFILE"] = str(home) # expanduser on Windows + env.pop("CLAUDE_EFFORT", None) # effort is driven by the payload, not the dev's session + p = subprocess.run([sys.executable, str(HOOK)], + input=json.dumps(stdin_obj), text=True, + capture_output=True, env=env) + return p.stdout.strip() + + +def make_home(tmp_path, with_playbook=True): + claude = tmp_path / ".claude" + claude.mkdir(parents=True, exist_ok=True) + if with_playbook: + (claude / "FABLE_PLAYBOOK.md").write_text("PLAYBOOK_MARKER_42", encoding="utf-8") + return tmp_path + + +def test_phrase_injects_playbook(tmp_path): + home = make_home(tmp_path) + out = run({"prompt": "please use fable here", "session_id": str(uuid.uuid4())}, home) + assert out, "phrase trigger should produce output" + ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"] + assert "PLAYBOOK_MARKER_42" in ctx + + +def test_no_phrase_low_effort_is_silent(tmp_path): + home = make_home(tmp_path) + assert run({"prompt": "hello world", "session_id": str(uuid.uuid4())}, home) == "" + + +def test_effort_injects_once_per_session(tmp_path): + home = make_home(tmp_path) + sid = str(uuid.uuid4()) + first = run({"prompt": "hi", "effort": "ultracode", "session_id": sid}, home) + second = run({"prompt": "hi", "effort": "ultracode", "session_id": sid}, home) + assert first, "first effort-only trigger should inject" + assert second == "", "same session should be debounced on the second prompt" + + +def test_missing_playbook_is_silent(tmp_path): + home = make_home(tmp_path, with_playbook=False) + assert run({"prompt": "use fable", "session_id": str(uuid.uuid4())}, home) == "" + + +def test_malformed_input_never_crashes(tmp_path): + env = dict(os.environ) + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + p = subprocess.run([sys.executable, str(HOOK)], input="not json", + text=True, capture_output=True, env=env) + assert p.returncode == 0 + assert p.stdout.strip() == "" diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..a5b4c7b --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,73 @@ +"""Tests for install.py — the cross-platform installer. + +Loads install.py as a module and redirects HOME / CLAUDE / the PowerShell profile +into a temp sandbox, so the real ~/.claude is never touched. Both launcher +branches (PowerShell + Unix shell rc) are exercised regardless of the host OS. +""" +import importlib.util +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def load_install(): + spec = importlib.util.spec_from_file_location("fable_install", REPO / "install.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def sandbox(tmp_path, windows): + inst = load_install() + inst.HOME = str(tmp_path) + inst.CLAUDE = str(tmp_path / ".claude") + inst.IS_WINDOWS = windows + inst._profile = tmp_path / "profile.ps1" + inst.powershell_profile_path = lambda: str(inst._profile) + return inst + + +def test_install_copies_everything(tmp_path): + inst = sandbox(tmp_path, windows=True) + inst.main() + claude = tmp_path / ".claude" + + assert (claude / "FABLE_PLAYBOOK.md").is_file() + assert (claude / "fable-system.md").is_file() + assert (claude / "hooks" / "fable-trigger.py").is_file() + assert (claude / "hooks" / "test-after-edit.py").is_file() + assert (claude / "agents" / "grounding-verifier.md").is_file() + assert (claude / "skills" / "ground" / "SKILL.md").is_file() + # nested skill content must survive the copy + assert (claude / "skills" / "mcp-builder" / "reference" / "evaluation.md").is_file() + # launcher written to the (sandboxed) PowerShell profile + assert "fable.ps1" in (tmp_path / "profile.ps1").read_text(encoding="utf-8") + + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + assert s["alwaysThinkingEnabled"] is True + cmd = s["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"] + assert sys.executable in cmd # absolute interpreter, not a bare "python3" + + +def test_install_is_idempotent(tmp_path): + inst = sandbox(tmp_path, windows=True) + inst.main() + inst.main() + claude = tmp_path / ".claude" + + s = json.loads((claude / "settings.json").read_text(encoding="utf-8")) + assert len(s["hooks"]["UserPromptSubmit"]) == 1 + assert len(s["hooks"]["PostToolUse"]) == 1 + assert (tmp_path / "profile.ps1").read_text(encoding="utf-8").count("fable.ps1") == 1 + assert (claude / "settings.json.bak").is_file() + + +def test_install_unix_launcher(tmp_path, monkeypatch): + inst = sandbox(tmp_path, windows=False) + monkeypatch.setenv("SHELL", "/bin/zsh") + inst.main() + rc = tmp_path / ".zshrc" + assert rc.is_file() + assert "fable.zsh" in rc.read_text(encoding="utf-8") diff --git a/tests/test_test_after_edit.py b/tests/test_test_after_edit.py new file mode 100644 index 0000000..bdb28de --- /dev/null +++ b/tests/test_test_after_edit.py @@ -0,0 +1,72 @@ +"""Tests for hooks/test-after-edit.py — the run-tests-after-edit reporter. + +Hermetic: builds a throwaway Python project whose test suite the hook runs with +this same interpreter (sys.executable / pytest), so no Node/Make/etc. is needed. +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +HOOK = REPO / "hooks" / "test-after-edit.py" + + +def run(tool_input, tool_name="Edit", env_extra=None): + payload = {"tool_name": tool_name, "tool_input": tool_input} + env = dict(os.environ) + env["FABLE_TEST_HOOK_DEBOUNCE"] = "0" # don't let debounce swallow back-to-back tests + if env_extra: + env.update(env_extra) + p = subprocess.run([sys.executable, str(HOOK)], + input=json.dumps(payload), text=True, + capture_output=True, env=env) + return p.stdout.strip() + + +def make_py_project(root, passing=True): + (root / "pyproject.toml").write_text("[project]\nname = 'sample'\nversion = '0'\n", + encoding="utf-8") + tests = root / "tests" + tests.mkdir() + body = ("def test_ok():\n assert True\n" if passing + else "def test_bad():\n assert False\n") + (tests / "test_sample.py").write_text(body, encoding="utf-8") + src = root / "mod.py" + src.write_text("x = 1\n", encoding="utf-8") + return src + + +def test_passing_project_reports_passed(tmp_path): + src = make_py_project(tmp_path, passing=True) + out = run({"file_path": str(src)}) + assert "passed" in out, out + + +def test_failing_project_reports_failed(tmp_path): + src = make_py_project(tmp_path, passing=False) + out = run({"file_path": str(src)}) + assert "FAILED" in out, out + + +def test_doc_edit_is_skipped(tmp_path): + f = tmp_path / "README.md" + f.write_text("# doc\n", encoding="utf-8") + assert run({"file_path": str(f)}) == "" + + +def test_project_without_tests_is_silent(tmp_path): + f = tmp_path / "loose.py" + f.write_text("x = 1\n", encoding="utf-8") + assert run({"file_path": str(f)}) == "" + + +def test_disabled_via_env(tmp_path): + src = make_py_project(tmp_path, passing=True) + assert run({"file_path": str(src)}, env_extra={"FABLE_NO_TEST_HOOK": "1"}) == "" + + +def test_non_edit_tool_ignored(tmp_path): + src = make_py_project(tmp_path, passing=True) + assert run({"file_path": str(src)}, tool_name="Read") == "" diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..7bbd664 --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,14 @@ +#!/usr/bin/env pwsh +# Native uninstall entry point for Windows. Locates Python and runs uninstall.py. +# macOS / Linux: use ./uninstall.sh (or run `python uninstall.py`). +$ErrorActionPreference = "Stop" + +$Repo = Split-Path -Parent $MyInvocation.MyCommand.Path +$python = Get-Command python -ErrorAction SilentlyContinue +if (-not $python) { $python = Get-Command python3 -ErrorAction SilentlyContinue } +if (-not $python) { + Write-Error "python (or python3) not found on PATH." + exit 1 +} +& $python.Source (Join-Path $Repo "uninstall.py") @args +exit $LASTEXITCODE diff --git a/uninstall.py b/uninstall.py new file mode 100755 index 0000000..240574e --- /dev/null +++ b/uninstall.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Cross-platform uninstaller for Fable mode (Windows / macOS / Linux). + + python uninstall.py # Windows + python3 uninstall.py # macOS / Linux + +Reverses install.py: removes the files it copied into ~/.claude, strips the +launcher line from your shell rc / PowerShell $PROFILE, and removes the two hook +entries it added to settings.json (writing a fresh backup first). + +Conservative on purpose: + - Only removes the skill directories this repo bundles, never your other skills. + - Leaves ~/.claude itself and "alwaysThinkingEnabled" untouched (toggle that in + /config if you want it off — it may predate the install). +""" +import os +import sys +import json +import shutil + +REPO = os.path.dirname(os.path.abspath(__file__)) +HOME = os.path.expanduser("~") +CLAUDE = os.path.join(HOME, ".claude") +IS_WINDOWS = os.name == "nt" + + +def rm_file(path): + if os.path.isfile(path): + os.remove(path) + print(" removed " + path) + + +def rm_tree(path): + if os.path.isdir(path): + shutil.rmtree(path) + print(" removed " + path) + + +def bundled_skill_names(): + skills_dir = os.path.join(REPO, "skills") + if not os.path.isdir(skills_dir): + return [] + return [n for n in os.listdir(skills_dir) + if os.path.isdir(os.path.join(skills_dir, n))] + + +def strip_launcher(path, marker): + """Remove the '# Fable mode' comment + the source/dot line referencing `marker`.""" + if not os.path.isfile(path): + return + with open(path, encoding="utf-8") as f: + lines = f.readlines() + out, removed = [], False + for line in lines: + if marker in line: + removed = True + # drop a preceding "# Fable mode" comment and a blank line if present + while out and (out[-1].lstrip().startswith("# Fable mode") + or out[-1].strip() == ""): + out.pop() + continue + out.append(line) + if removed: + with open(path, "w", encoding="utf-8") as f: + f.write("".join(out)) + print(" removed launcher from " + path) + + +def powershell_profile_path(): + import subprocess + for exe in ("pwsh", "powershell"): + if shutil.which(exe): + try: + out = subprocess.run([exe, "-NoProfile", "-Command", "$PROFILE"], + capture_output=True, text=True, timeout=20) + p = out.stdout.strip() + if p: + return p + except Exception: + pass + return os.path.join(HOME, "Documents", "PowerShell", + "Microsoft.PowerShell_profile.ps1") + + +def remove_launcher(): + if IS_WINDOWS: + strip_launcher(powershell_profile_path(), "fable.ps1") + else: + for rc in (".zshrc", ".bashrc"): + strip_launcher(os.path.join(HOME, rc), "fable.zsh") + + +def clean_settings(): + path = os.path.join(CLAUDE, "settings.json") + if not os.path.isfile(path): + return + try: + d = json.load(open(path)) + except Exception: + print(" settings.json unreadable — left untouched") + return + shutil.copy(path, path + ".uninstall.bak") + + hooks = d.get("hooks", {}) + for event in ("UserPromptSubmit", "PostToolUse"): + arr = hooks.get(event) + if not isinstance(arr, list): + continue + kept = [] + for entry in arr: + cmds = " ".join(h.get("command", "") + for h in entry.get("hooks", [])) + if "fable-trigger.py" in cmds or "test-after-edit.py" in cmds: + continue # drop the entry we added + kept.append(entry) + if kept: + hooks[event] = kept + else: + hooks.pop(event, None) + if not hooks: + d.pop("hooks", None) + elif "hooks" in d: + d["hooks"] = hooks + + json.dump(d, open(path, "w"), indent=2) + print(" removed Fable hooks from settings.json (backup: settings.json.uninstall.bak)") + + +def main(): + print("-> files in ~/.claude") + rm_file(os.path.join(CLAUDE, "hooks", "fable-trigger.py")) + rm_file(os.path.join(CLAUDE, "hooks", "test-after-edit.py")) + rm_file(os.path.join(CLAUDE, "FABLE_PLAYBOOK.md")) + rm_file(os.path.join(CLAUDE, "fable-system.md")) + rm_file(os.path.join(CLAUDE, "agents", "grounding-verifier.md")) + for name in bundled_skill_names(): + rm_tree(os.path.join(CLAUDE, "skills", name)) + + print("-> launcher") + remove_launcher() + + print("-> settings.json") + clean_settings() + + print() + print("Done. Fable mode removed. ~/.claude and 'alwaysThinkingEnabled' were kept.") + print("Reload your shell ('. $PROFILE' or 'source ~/.zshrc') to drop the 'fable' command.") + + +if __name__ == "__main__": + main() diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..a481948 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Native uninstall entry point for macOS / Linux. Locates Python and runs +# uninstall.py. Windows: use uninstall.ps1 (or run `python uninstall.py`). +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="$(command -v python3 || command -v python || true)" +if [ -z "$PYTHON" ]; then + echo "error: python3 (or python) not found on PATH." >&2 + exit 1 +fi +exec "$PYTHON" "$REPO/uninstall.py" "$@"