From 181e936b29f93706c02ac252e23e960fa95f5dd7 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Fri, 17 Jul 2026 22:53:47 -0400 Subject: [PATCH] fix: Refactor to use crush --- README.md | 25 ++++----- gh-commit | 12 ++-- gh-commit.tar.gz | Bin 9399 -> 11722 bytes smartcommit.py | 142 ++++++++++++++++++++++++----------------------- 4 files changed, 91 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index fad44ae..6698bd1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # gh-commit -AI-powered scoped git commits. Groups changes by project area, generates commit messages with Claude, and pushes — all in one command. A single Claude agent, reached over the **Agent Client Protocol (ACP)**, handles both scope generation and commit-message writing. +AI-powered scoped git commits. Groups changes by project area, generates commit messages with `crush run`, and pushes — all in one command. ## Install @@ -11,15 +11,14 @@ gh extension install prdlk/gh-commit ### Requirements - [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically) -- [Node.js](https://nodejs.org) — runs the Claude ACP bridge via `npx` -- [claude](https://docs.anthropic.com/en/docs/claude-code) — authenticated Claude Code CLI (the ACP bridge drives it) +- [Crush](https://github.com/charmbracelet/crush) — configured with an authenticated model provider -> The ACP bridge defaults to `npx --yes @zed-industries/claude-code-acp`. Override it with `GH_COMMIT_ACP_CMD` if you prefer another bridge. +By default, gh-commit uses `openrouter/qwen/qwen3.6-27b`. Configure OpenRouter in Crush or override the model with `GH_COMMIT_CRUSH_MODEL`. ## Usage ```sh -# Initialize scopes for your repo (uses Claude to analyze structure) +# Initialize scopes for your repo (uses Crush to analyze structure) gh commit init # Commit changes grouped by scope @@ -37,17 +36,17 @@ gh commit sync ## How it works -1. **`gh commit init`** — A Claude agent (over ACP) analyzes your repo structure and generates scope definitions (e.g., `core → src/`, `docs → docs/, README.md`, `ci → .github/workflows/`) -2. **`gh commit`** — Groups dirty files by scope, generates a commit message per scope via the same Claude agent, and commits each group separately +1. **`gh commit init`** — `crush run` analyzes your repo structure and generates scope definitions (e.g., `core → src/`, `docs → docs/, README.md`, `ci → .github/workflows/`) +2. **`gh commit`** — Groups dirty files by scope, sends each staged diff to `crush run`, and commits each group separately 3. **Auto-refresh** — Whenever your `.gitignore` changes, scopes are automatically regenerated before committing (a content hash of `.gitignore` is tracked per repo) 4. Remaining unscoped files are handled in a final pass 5. Unpushed commits are offered for push Scopes are stored in a local DuckDB database (`~/.local/share/gh-commit/gh-commit.db`) — no config files in your repo. -### Why ACP? +### Why Crush? -gh-commit talks to Claude through the [Agent Client Protocol](https://agentclientprotocol.com) — the same protocol editors like Zed use to drive coding agents. One Claude backend handles everything (no separate `mods`/LLM CLI), the bridge process is reused across a run, and each generation runs in its own session so context never bleeds between commits. +The former Mods roles now live directly in `smartcommit.py`, so gh-commit no longer depends on Mods configuration. Each generation invokes Crush's supported non-interactive mode with a self-contained prompt. ## Commands @@ -77,10 +76,10 @@ gh-commit talks to Claude through the [Agent Client Protocol](https://agentclien | `GH_COMMIT_AUTO=1` | Skip all confirmation prompts | | `GH_COMMIT_PUSH=1` | Auto-push after commits | | `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes | -| `GH_COMMIT_ACP_CMD` | Override the Claude ACP bridge command (default `npx --yes @zed-industries/claude-code-acp`) | -| `GH_COMMIT_ACP_PERMISSION` | ACP permission mode (default `bypassPermissions`) | -| `GH_COMMIT_ACP_TIMEOUT` | Per-prompt timeout in seconds (default `300`) | -| `GH_COMMIT_DEBUG=1` | Show ACP bridge stderr and parse diagnostics | +| `GH_COMMIT_CRUSH_CMD` | Override the Crush command (default `crush`) | +| `GH_COMMIT_CRUSH_MODEL` | Override the Crush model (default `openrouter/qwen/qwen3.6-27b`) | +| `GH_COMMIT_CRUSH_TIMEOUT` | Per-prompt timeout in seconds (default `120`) | +| `GH_COMMIT_DEBUG=1` | Show scope-response parse diagnostics | ## Migration diff --git a/gh-commit b/gh-commit index 699445d..5304e44 100755 --- a/gh-commit +++ b/gh-commit @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# gh-commit: AI-powered scoped git commits (mods CLI for scopes and messages) +# gh-commit: AI-powered scoped git commits (Crush for scopes and messages) # Install: gh extension install prdlk/gh-commit -# Requires: uv (https://docs.astral.sh/uv) and the `mods` CLI -# (https://github.com/charmbracelet/mods) +# Requires: uv (https://docs.astral.sh/uv) and the Crush CLI +# (https://github.com/charmbracelet/crush) EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT="${EXTENSION_DIR}/smartcommit.py" @@ -15,9 +15,9 @@ if ! command -v uv &>/dev/null; then exit 1 fi -if ! command -v mods &>/dev/null; then - echo "warning: 'mods' not found — gh-commit uses it for scopes and commit messages." - echo "install mods (https://github.com/charmbracelet/mods), or set GH_COMMIT_MODS_CMD." +if [[ -z "${GH_COMMIT_CRUSH_CMD:-}" ]] && ! command -v crush &>/dev/null; then + echo "warning: 'crush' not found — gh-commit uses 'crush run' for scopes and commit messages." + echo "install Crush (https://github.com/charmbracelet/crush), or set GH_COMMIT_CRUSH_CMD." fi exec uv run --script "${SCRIPT}" "$@" diff --git a/gh-commit.tar.gz b/gh-commit.tar.gz index ec1ae8309a1ff6e26a3d04f68add5cd209da82c1..0ec1df67fe7bd7d2c55ce5649c08c31143840eba 100644 GIT binary patch literal 11722 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 diff --git a/smartcommit.py b/smartcommit.py index 76e2bd2..1d0acd5 100644 --- a/smartcommit.py +++ b/smartcommit.py @@ -9,10 +9,10 @@ # /// """gh-commit - AI-powered scoped git commits. -Both AI steps run through the `mods` CLI, so the tool never touches Claude's -ACP credit path: scope generation pipes the repository's file tree into a -`scope-identifier` role, and commit-message writing pipes the staged diff into -a `write-commit` role. Scopes live in a local DuckDB and auto-regenerate +Both AI steps run through `crush run`: scope generation sends the repository's +file tree with embedded scope-identification instructions, and commit-message +writing sends the staged diff with embedded Conventional Commits instructions. +Scopes live in a local DuckDB and auto-regenerate whenever the repository's .gitignore changes. """ @@ -41,15 +41,11 @@ console = Console() DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit" DB_PATH = DB_DIR / "gh-commit.db" -# The `mods` CLI handles both AI steps (a prompt is piped to a predefined role), -# keeping the tool off Claude's ACP credit path. -MODS_CMD = os.environ.get("GH_COMMIT_MODS_CMD", "mods").split() -MODS_SCOPE_ROLE = os.environ.get("GH_COMMIT_MODS_SCOPE_ROLE", "scope-identifier") -MODS_COMMIT_ROLE = os.environ.get("GH_COMMIT_MODS_COMMIT_ROLE", "write-commit") -MODS_API = os.environ.get("GH_COMMIT_MODS_API", "openrouter") -MODS_MODEL = os.environ.get("GH_COMMIT_MODS_MODEL", "qwen/qwen3.6-27b") -MODS_MAX_TOKENS = os.environ.get("GH_COMMIT_MODS_MAX_TOKENS", "2000") -MODS_TIMEOUT = int(os.environ.get("GH_COMMIT_MODS_TIMEOUT", "120")) +# Crush handles both AI steps in non-interactive mode. The instructions formerly +# stored as mods roles are embedded below so this tool is self-contained. +CRUSH_CMD = os.environ.get("GH_COMMIT_CRUSH_CMD", "crush").split() +CRUSH_MODEL = os.environ.get("GH_COMMIT_CRUSH_MODEL", "openrouter/qwen/qwen3.6-27b") +CRUSH_TIMEOUT = int(os.environ.get("GH_COMMIT_CRUSH_TIMEOUT", "120")) AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1" AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1" @@ -100,10 +96,10 @@ def require_git() -> Path: return get_repo_root() -# ── mods CLI client ─────────────────────────────────────────────────────────── +# ── Crush CLI client ────────────────────────────────────────────────────────── -class ModsError(RuntimeError): - """Raised when the `mods` CLI fails to produce output.""" +class CrushError(RuntimeError): + """Raised when `crush run` fails to produce output.""" def build_filetree(repo_path: Path) -> str: @@ -114,44 +110,25 @@ def build_filetree(repo_path: Path) -> str: return "\n".join(files) -def _mods_mcp_servers() -> list[str]: - """Names of MCP servers mods would attach. Disabling them keeps scope - generation a plain completion — function-calling models otherwise try to - emit the JSON as a (failing) tool call instead of as text.""" - result = subprocess.run([*MODS_CMD, "--mcp-list"], capture_output=True, text=True) - if result.returncode != 0: - return [] - names = [] - for line in result.stdout.splitlines(): - line = line.strip() - if line: - names.append(line.split()[0]) - return names - - -def mods_prompt(text: str, role: str, cwd: Path) -> str: - """Pipe `text` into the `mods` CLI under `role` and return its raw reply.""" - cmd = [*MODS_CMD, "-R", role, "-q", "-r", "--no-limit", "--max-tokens", MODS_MAX_TOKENS] - if MODS_API: - cmd += ["-a", MODS_API] - if MODS_MODEL: - cmd += ["-m", MODS_MODEL] - for server in _mods_mcp_servers(): - cmd += ["--mcp-disable", server] +def crush_prompt(text: str, cwd: Path) -> str: + """Send `text` to `crush run` and return its reply.""" + cmd = [*CRUSH_CMD, "run", "--quiet"] + if CRUSH_MODEL: + cmd += ["--model", CRUSH_MODEL] try: result = subprocess.run( cmd, input=text, capture_output=True, text=True, - cwd=str(cwd), timeout=MODS_TIMEOUT, + cwd=str(cwd), timeout=CRUSH_TIMEOUT, ) except FileNotFoundError as e: - raise ModsError( - f"Could not run the mods CLI ({MODS_CMD[0]!r} not found). " - "Install mods, or set GH_COMMIT_MODS_CMD to a working command." + raise CrushError( + f"Could not run the Crush CLI ({CRUSH_CMD[0]!r} not found). " + "Install Crush, or set GH_COMMIT_CRUSH_CMD to a working command." ) from e except subprocess.TimeoutExpired as e: - raise ModsError(f"mods timed out after {MODS_TIMEOUT}s") from e + raise CrushError(f"crush run timed out after {CRUSH_TIMEOUT}s") from e if result.returncode != 0: - raise ModsError(result.stderr.strip() or f"mods exited with {result.returncode}") + raise CrushError(result.stderr.strip() or f"crush run exited with {result.returncode}") return result.stdout.strip() @@ -435,24 +412,53 @@ def filter_diff(diff: str) -> str: return "\n".join(lines) -# ── AI integration (mods CLI) ───────────────────────────────────────────────── +# ── AI integration (Crush CLI) ──────────────────────────────────────────────── -# The `write-commit` mods role carries the Conventional Commits format rules; -# this body just supplies the scope to use and the diff to describe. COMMIT_PROMPT = """\ +You are an expert conventional commit message writer. +Use one of these commit types: feat, fix, docs, style, refactor, breaking, test, perf, build, ci, chore, init. +Determine a precise commit message from the provided diff and scope. +The commit message must follow this format: (): +Examples: +- fix(core): resolve consensus timeout during high load +- feat(did): add WebAuthn biometric authentication support +- refactor(hway): extract Redis connection pooling to shared utility +- init(browser): setup client package for web API-specific logic +- perf(vault): optimize IPFS chunk size for large file uploads +- ci(actions): update GitHub Actions pipeline for parallel module testing +- docs(dwn): clarify data retention policies in API reference +- test(svc): add integration tests for domain verification flow +- breaking(ui): rename Button prop 'type' to 'variant' +- refactor(sdk): migrate off wallet implementation in favor of @sonr.io/enclave +- feat(react): create Enclave stateful hooks +Never explain anything. Return only the commit message. + {scope_hint}### Git Diff {diff} """ -# The `scope-identifier` mods role carries the output-format rules; these prompt -# bodies just frame the piped-in file tree. -SCOPE_PROMPT_NEW = """\ +SCOPE_INSTRUCTIONS = """\ +You are a repository scope identifier for Conventional Commits. +You receive a repository file tree, one repo-relative path per line. +Map logical project areas to the repo-relative path prefixes (directories or files) they cover. +Scope names must read well in Conventional Commits, e.g. core, api, ui, cli, docs, tests, ci, config, scripts, deps. +Each scope maps to a JSON array of repo-relative path prefixes. +Group related paths together and cover the meaningful source areas. +Never create scopes for generated, vendored, or build-output paths. +If existing scopes are provided, keep the ones that still apply, drop scopes whose paths no longer exist, and add scopes for new areas. +Never explain anything or print your thoughts. Never include Markdown code blocks. +Only return a single JSON object of the form {{"scope": ["path", ...], ...}}. +""" + +SCOPE_PROMPT_NEW = SCOPE_INSTRUCTIONS + """ + Repository file tree (one repo-relative path per line): {filetree} """ -SCOPE_PROMPT_UPDATE = """\ +SCOPE_PROMPT_UPDATE = SCOPE_INSTRUCTIONS + """ + Existing scopes (JSON): {existing} @@ -502,9 +508,9 @@ def generate_commit_message(diff: str, repo_path: Path, scope: Optional[str] = N scope_hint = f"Use this scope: {scope}\n" if scope else "" prompt = COMMIT_PROMPT.format(scope_hint=scope_hint, diff=filter_diff(diff)) try: - message = mods_prompt(prompt, MODS_COMMIT_ROLE, repo_path) - except ModsError as e: - console.print(f"[red]mods failed to generate a commit message: {e}[/]") + message = crush_prompt(prompt, repo_path) + except CrushError as e: + console.print(f"[red]Crush failed to generate a commit message: {e}[/]") return None # Strip stray code fences if the model added them anyway. message = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", message.strip()).strip() @@ -521,14 +527,14 @@ def generate_scopes(repo_path: Path, existing: Optional[dict[str, list[str]]] = else: prompt = SCOPE_PROMPT_NEW.format(filetree=filetree) try: - with console.status("[magenta]Analyzing repository with mods...[/]"): - output = mods_prompt(prompt, MODS_SCOPE_ROLE, repo_path) - except ModsError as e: - console.print(f"[red]mods failed: {e}[/]") + with console.status("[magenta]Analyzing repository with Crush...[/]"): + output = crush_prompt(prompt, repo_path) + except CrushError as e: + console.print(f"[red]Crush failed: {e}[/]") return None scopes = parse_scopes_response(output) if not scopes: - console.print("[red]Could not parse scopes from mods' response[/]") + console.print("[red]Could not parse scopes from Crush's response[/]") if DEBUG: console.print(f"[dim]{output}[/]") return scopes @@ -626,7 +632,7 @@ def maybe_auto_refresh_scopes(repo_path: Path, repo_name: str): if current == stored: return - console.print("[yellow]↻ .gitignore changed — regenerating scopes with Claude...[/]") + console.print("[yellow]↻ .gitignore changed — regenerating scopes with Crush...[/]") existing = get_repo_scopes(str(repo_path)) scopes = generate_scopes(repo_path, existing) if not scopes: @@ -645,7 +651,7 @@ def cmd_version(): def cmd_help(): - console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (mods)\n") + console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (Crush)\n") console.print("[cyan]Usage:[/]") console.print(" gh commit Commit changes grouped by scope") console.print(" gh commit --auto Auto-confirm all prompts") @@ -665,11 +671,9 @@ def cmd_help(): console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts") console.print(" GH_COMMIT_PUSH=1 Auto-push after commits") console.print(" GH_COMMIT_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change") - console.print(" GH_COMMIT_MODS_CMD=... Override the mods command") - console.print(" GH_COMMIT_MODS_SCOPE_ROLE= mods role for scopes (default scope-identifier)") - console.print(" GH_COMMIT_MODS_COMMIT_ROLE= mods role for commit messages (default write-commit)") - console.print(" GH_COMMIT_MODS_API=... Override the mods API (default: openrouter)") - console.print(" GH_COMMIT_MODS_MODEL=... Override the mods model (default: qwen/qwen3.6-27b)") + console.print(" GH_COMMIT_CRUSH_CMD=... Override the Crush command") + console.print(" GH_COMMIT_CRUSH_MODEL=... Override the Crush model (default: openrouter/qwen/qwen3.6-27b)") + console.print(" GH_COMMIT_CRUSH_TIMEOUT=... Per-prompt timeout in seconds (default: 120)") console.print(" GH_COMMIT_DEBUG=1 Show parse diagnostics\n") console.print("[cyan]Scopes auto-refresh whenever .gitignore changes.[/]") console.print("[cyan]Legacy .github/Repo.toml or scopes.json are auto-migrated on first run.[/]") @@ -830,7 +834,7 @@ def cmd_commit(): if not repo_has_scopes(str(repo_path)): console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n") console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n") - if confirm("Generate scopes now using Claude?"): + if confirm("Generate scopes now using Crush?"): if cmd_init() != 0: return 1 else: