feat: set opencode as default

This commit is contained in:
Prad Nukala committed 2026-10-16 14:06:23 -04:00
1 parent 2ab62dd347
commit d754bae5ad
4 files changed
+93 -68

No files matched your search

+10 -8
View File
@@ -1,7 +1,7 @@
# gh-commit # gh-commit
AI-powered scoped git commits as a GitHub CLI extension, driven by the AI-powered scoped git commits as a GitHub CLI extension, driven by the
[Crush](https://github.com/charmbracelet/crush) CLI. Scopes map Conventional [OpenCode](https://opencode.ai) CLI. Scopes map Conventional
Commit scope names to path prefixes. They are generated from the repository Commit scope names to path prefixes. They are generated from the repository
file tree, stored in a local DuckDB database, and auto-regenerate whenever file tree, stored in a local DuckDB database, and auto-regenerate whenever
`.gitignore` changes. Changed files are grouped by scope and committed one `.gitignore` changes. Changed files are grouped by scope and committed one
@@ -16,10 +16,12 @@ gh extension install prdlk/gh-commit
### Requirements ### Requirements
- [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically) - [uv](https://docs.astral.sh/uv) — Python package runner (handles dependencies automatically)
- [Crush](https://github.com/charmbracelet/crush) — non-interactive AI runner - [OpenCode](https://opencode.ai) — non-interactive AI runner (`opencode run`)
- `GROQ_API_KEY` — authenticates the default Groq model - OpenCode Go access for the default model — `opencode auth login` (choose OpenCode Go) or set `OPENCODE_API_KEY`
By default, gh-commit uses Groq's `openai/gpt-oss-120b` via the bundled `crush-provider` config. Override the model with `GH_COMMIT_CRUSH_MODEL`. By default, gh-commit uses `opencode-go/gpt-5.6-luna`: the fastest model in OpenCode Go's largest context tier (1.05M tokens). Override it with `GH_COMMIT_OPENCODE_MODEL` (any `provider/model` from `opencode models`).
Prompts run through a private `opencode run --standalone` server with an inline read-only `gh-commit` agent: every tool except `read`, `glob`, and `grep` is denied, so the model never modifies the working tree.
## Usage ## Usage
@@ -34,7 +36,7 @@ gh commit # commit changes grouped by scope
| Command | Behavior | | Command | Behavior |
|---|---| |---|---|
| `gh commit` | Full scoped-commit flow: group changed files by scope, generate a message per group, confirm, commit, offer to push | | `gh commit` | Full scoped-commit flow: group changed files by scope, generate a message per group, confirm, commit, offer to push |
| `gh commit init` | Generate scopes from the file tree via Crush; migrates legacy configs; confirms overwrite | | `gh commit init` | Generate scopes from the file tree via OpenCode; migrates legacy configs; confirms overwrite |
| `gh commit refresh` | Show current scopes, regenerate with existing scopes as context, confirm apply | | `gh commit refresh` | Show current scopes, regenerate with existing scopes as context, confirm apply |
| `gh commit sync` | Create/update GitHub labels from scopes (color = first 6 hex chars of MD5 of the scope name) | | `gh commit sync` | Create/update GitHub labels from scopes (color = first 6 hex chars of MD5 of the scope name) |
| `gh commit list` | Table of configured repositories: name, path, scope count | | `gh commit list` | Table of configured repositories: name, path, scope count |
@@ -52,9 +54,9 @@ Flags: `--auto` (skip confirmations), `--push` (auto-push).
| `GH_COMMIT_AUTO=1` | Skip all confirmation prompts | | `GH_COMMIT_AUTO=1` | Skip all confirmation prompts |
| `GH_COMMIT_PUSH=1` | Auto-push after commits | | `GH_COMMIT_PUSH=1` | Auto-push after commits |
| `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes | | `GH_COMMIT_NO_AUTO_REFRESH=1` | Don't auto-regenerate scopes when `.gitignore` changes |
| `GH_COMMIT_CRUSH_CMD` | Override the Crush command (default `crush`) | | `GH_COMMIT_OPENCODE_CMD` | Override the OpenCode command (default `opencode`) |
| `GH_COMMIT_CRUSH_MODEL` | Override the Crush model (default `groq/openai/gpt-oss-120b`) | | `GH_COMMIT_OPENCODE_MODEL` | Override the model (default `opencode-go/gpt-5.6-luna`) |
| `GH_COMMIT_CRUSH_TIMEOUT` | Per-prompt timeout in seconds (default `120`) | | `GH_COMMIT_OPENCODE_TIMEOUT` | Per-prompt timeout in seconds (default `120`) |
| `GH_COMMIT_DEBUG=1` | Show scope-response parse diagnostics | | `GH_COMMIT_DEBUG=1` | Show scope-response parse diagnostics |
## Storage ## Storage
-18
View File
@@ -1,18 +0,0 @@
{
"$schema": "https://charm.land/crush.json",
"providers": {
"groq": {
"type": "openai-compat",
"base_url": "https://api.groq.com/openai/v1",
"api_key": "$GROQ_API_KEY",
"models": [
{
"id": "openai/gpt-oss-120b",
"name": "GPT-OSS 120B on Groq",
"context_window": 131072,
"default_max_tokens": 8192
}
]
}
}
}
BIN
View File
Binary file not shown.
+83 -42
View File
@@ -9,7 +9,7 @@
# /// # ///
"""gh-commit - AI-powered scoped git commits. """gh-commit - AI-powered scoped git commits.
Both AI steps run through `crush run`: scope generation sends the repository's Both AI steps run through `opencode run`: scope generation sends the repository's
file tree with embedded scope-identification instructions, and commit-message file tree with embedded scope-identification instructions, and commit-message
writing sends the staged diff with embedded Conventional Commits instructions. writing sends the staged diff with embedded Conventional Commits instructions.
Scopes live in a local DuckDB and auto-regenerate Scopes live in a local DuckDB and auto-regenerate
@@ -41,20 +41,28 @@ console = Console()
DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit" DB_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "gh-commit"
DB_PATH = DB_DIR / "gh-commit.db" DB_PATH = DB_DIR / "gh-commit.db"
# Crush handles both AI steps in non-interactive mode. The instructions formerly # OpenCode handles both AI steps in non-interactive mode. The instructions
# stored as mods roles are embedded below so this tool is self-contained. # 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() OPENCODE_CMD = os.environ.get("GH_COMMIT_OPENCODE_CMD", "opencode").split()
DEFAULT_CRUSH_MODEL = "groq/openai/gpt-oss-120b" # Fastest model in the largest-context (1.05M) tier of the OpenCode Go provider.
CRUSH_MODEL = os.environ.get("GH_COMMIT_CRUSH_MODEL", DEFAULT_CRUSH_MODEL) DEFAULT_OPENCODE_MODEL = "opencode-go/gpt-5.6-luna"
CRUSH_CONFIG_DIR = Path(__file__).with_name("crush-provider") OPENCODE_MODEL = os.environ.get("GH_COMMIT_OPENCODE_MODEL", DEFAULT_OPENCODE_MODEL)
CRUSH_TIMEOUT = int(os.environ.get("GH_COMMIT_CRUSH_TIMEOUT", "120")) OPENCODE_TIMEOUT = int(os.environ.get("GH_COMMIT_OPENCODE_TIMEOUT", "120"))
# Read-only agent: everything is denied except read/glob/grep, so the model can
# never modify the working tree. (Denying every tool breaks OpenCode's free tier.)
OPENCODE_AGENT = "gh-commit"
OPENCODE_AGENT_CONFIG = {
"mode": "primary",
"description": "gh-commit message and scope generator (read-only)",
"permission": {"*": "deny", "read": "allow", "glob": "allow", "grep": "allow"},
}
AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1" AUTO_CONFIRM = os.environ.get("GH_COMMIT_AUTO", "0") == "1"
AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1" AUTO_PUSH = os.environ.get("GH_COMMIT_PUSH", "0") == "1"
NO_AUTO_REFRESH = os.environ.get("GH_COMMIT_NO_AUTO_REFRESH", "0") == "1" NO_AUTO_REFRESH = os.environ.get("GH_COMMIT_NO_AUTO_REFRESH", "0") == "1"
DEBUG = os.environ.get("GH_COMMIT_DEBUG", "0") == "1" DEBUG = os.environ.get("GH_COMMIT_DEBUG", "0") == "1"
VERSION = "5.0.1" VERSION = "6.0.0"
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -98,10 +106,10 @@ def require_git() -> Path:
return get_repo_root() return get_repo_root()
# ── Crush CLI client ────────────────────────────────────────────────────────── # ── OpenCode CLI client ───────────────────────────────────────────────────────
class CrushError(RuntimeError): class OpencodeError(RuntimeError):
"""Raised when `crush run` fails to produce output.""" """Raised when `opencode run` fails to produce output."""
def build_filetree(repo_path: Path) -> str: def build_filetree(repo_path: Path) -> str:
@@ -112,29 +120,62 @@ def build_filetree(repo_path: Path) -> str:
return "\n".join(files) return "\n".join(files)
def crush_prompt(text: str, cwd: Path) -> str: def opencode_env() -> dict[str, str]:
"""Send `text` to `crush run` and return its reply.""" """Environment with the text-only gh-commit agent merged into inline config."""
cmd = [*CRUSH_CMD, "run", "--quiet"]
if CRUSH_MODEL:
cmd += ["--model", CRUSH_MODEL]
env = os.environ.copy() env = os.environ.copy()
if CRUSH_MODEL == DEFAULT_CRUSH_MODEL: try:
env.setdefault("CRUSH_GLOBAL_CONFIG", str(CRUSH_CONFIG_DIR)) config = json.loads(env.get("OPENCODE_CONFIG_CONTENT") or "{}")
except json.JSONDecodeError:
config = {}
config.setdefault("agent", {})[OPENCODE_AGENT] = OPENCODE_AGENT_CONFIG
env["OPENCODE_CONFIG_CONTENT"] = json.dumps(config)
return env
def parse_opencode_events(stdout: str) -> str:
"""Return the final assistant message text from `opencode run --format json`."""
texts: dict[str, list[str]] = {}
last_message = None
for line in stdout.splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "error":
error = event.get("error") or {}
raise OpencodeError(error.get("message") or json.dumps(error))
part = event.get("part") or {}
if event.get("type") == "text" and part.get("text"):
last_message = part.get("messageID")
texts.setdefault(last_message, []).append(part["text"])
return "".join(texts.get(last_message, [])).strip()
def opencode_prompt(text: str, cwd: Path) -> str:
"""Send `text` to `opencode run` and return its reply."""
# --standalone runs a private server so this process's env (API keys, inline
# agent config) and cwd apply, instead of the shared background service's.
cmd = [*OPENCODE_CMD, "run", "--standalone", "--format", "json", "--agent", OPENCODE_AGENT]
if OPENCODE_MODEL:
cmd += ["--model", OPENCODE_MODEL]
try: try:
result = subprocess.run( result = subprocess.run(
cmd, input=text, capture_output=True, text=True, env=env, cmd, input=text, capture_output=True, text=True, env=opencode_env(),
cwd=str(cwd), timeout=CRUSH_TIMEOUT, cwd=str(cwd), timeout=OPENCODE_TIMEOUT,
) )
except FileNotFoundError as e: except FileNotFoundError as e:
raise CrushError( raise OpencodeError(
f"Could not run the Crush CLI ({CRUSH_CMD[0]!r} not found). " f"Could not run the OpenCode CLI ({OPENCODE_CMD[0]!r} not found). "
"Install Crush, or set GH_COMMIT_CRUSH_CMD to a working command." "Install OpenCode, or set GH_COMMIT_OPENCODE_CMD to a working command."
) from e ) from e
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired as e:
raise CrushError(f"crush run timed out after {CRUSH_TIMEOUT}s") from e raise OpencodeError(f"opencode run timed out after {OPENCODE_TIMEOUT}s") from e
reply = parse_opencode_events(result.stdout)
if result.returncode != 0: if result.returncode != 0:
raise CrushError(result.stderr.strip() or f"crush run exited with {result.returncode}") raise OpencodeError(result.stderr.strip() or f"opencode run exited with {result.returncode}")
return result.stdout.strip() if not reply:
raise OpencodeError("opencode run returned no text")
return reply
# ── Database ────────────────────────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────────────────────────
@@ -417,7 +458,7 @@ def filter_diff(diff: str) -> str:
return "\n".join(lines) return "\n".join(lines)
# ── AI integration (Crush CLI) ──────────────────────────────────────────────── # ── AI integration (OpenCode CLI) ─────────────────────────────────────────────
COMMIT_PROMPT = """\ COMMIT_PROMPT = """\
You are an expert conventional commit message writer. You are an expert conventional commit message writer.
@@ -513,9 +554,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 "" scope_hint = f"Use this scope: {scope}\n" if scope else ""
prompt = COMMIT_PROMPT.format(scope_hint=scope_hint, diff=filter_diff(diff)) prompt = COMMIT_PROMPT.format(scope_hint=scope_hint, diff=filter_diff(diff))
try: try:
message = crush_prompt(prompt, repo_path) message = opencode_prompt(prompt, repo_path)
except CrushError as e: except OpencodeError as e:
console.print(f"[red]Crush failed to generate a commit message: {e}[/]") console.print(f"[red]OpenCode failed to generate a commit message: {e}[/]")
return None return None
# Strip stray code fences if the model added them anyway. # Strip stray code fences if the model added them anyway.
message = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", message.strip()).strip() message = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", message.strip()).strip()
@@ -532,14 +573,14 @@ def generate_scopes(repo_path: Path, existing: Optional[dict[str, list[str]]] =
else: else:
prompt = SCOPE_PROMPT_NEW.format(filetree=filetree) prompt = SCOPE_PROMPT_NEW.format(filetree=filetree)
try: try:
with console.status("[magenta]Analyzing repository with Crush...[/]"): with console.status("[magenta]Analyzing repository with OpenCode...[/]"):
output = crush_prompt(prompt, repo_path) output = opencode_prompt(prompt, repo_path)
except CrushError as e: except OpencodeError as e:
console.print(f"[red]Crush failed: {e}[/]") console.print(f"[red]OpenCode failed: {e}[/]")
return None return None
scopes = parse_scopes_response(output) scopes = parse_scopes_response(output)
if not scopes: if not scopes:
console.print("[red]Could not parse scopes from Crush's response[/]") console.print("[red]Could not parse scopes from OpenCode's response[/]")
if DEBUG: if DEBUG:
console.print(f"[dim]{output}[/]") console.print(f"[dim]{output}[/]")
return scopes return scopes
@@ -637,7 +678,7 @@ def maybe_auto_refresh_scopes(repo_path: Path, repo_name: str):
if current == stored: if current == stored:
return return
console.print("[yellow]↻ .gitignore changed — regenerating scopes with Crush...[/]") console.print("[yellow]↻ .gitignore changed — regenerating scopes with OpenCode...[/]")
existing = get_repo_scopes(str(repo_path)) existing = get_repo_scopes(str(repo_path))
scopes = generate_scopes(repo_path, existing) scopes = generate_scopes(repo_path, existing)
if not scopes: if not scopes:
@@ -656,7 +697,7 @@ def cmd_version():
def cmd_help(): def cmd_help():
console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (Crush)\n") console.print(f"[magenta bold]gh commit[/] [dim]v{VERSION}[/] — AI-powered scoped git commits (OpenCode)\n")
console.print("[cyan]Usage:[/]") console.print("[cyan]Usage:[/]")
console.print(" gh commit Commit changes grouped by scope") console.print(" gh commit Commit changes grouped by scope")
console.print(" gh commit --auto Auto-confirm all prompts") console.print(" gh commit --auto Auto-confirm all prompts")
@@ -676,9 +717,9 @@ def cmd_help():
console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts") console.print(" GH_COMMIT_AUTO=1 Skip all confirmation prompts")
console.print(" GH_COMMIT_PUSH=1 Auto-push after commits") 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_NO_AUTO_REFRESH=1 Don't auto-regenerate scopes on .gitignore change")
console.print(" GH_COMMIT_CRUSH_CMD=... Override the Crush command") console.print(" GH_COMMIT_OPENCODE_CMD=... Override the OpenCode command")
console.print(f" GH_COMMIT_CRUSH_MODEL=... Override the Crush model (default: {DEFAULT_CRUSH_MODEL})") console.print(f" GH_COMMIT_OPENCODE_MODEL=... Override the model (default: {DEFAULT_OPENCODE_MODEL})")
console.print(" GH_COMMIT_CRUSH_TIMEOUT=... Per-prompt timeout in seconds (default: 120)") console.print(" GH_COMMIT_OPENCODE_TIMEOUT=... Per-prompt timeout in seconds (default: 120)")
console.print(" GH_COMMIT_DEBUG=1 Show parse diagnostics\n") console.print(" GH_COMMIT_DEBUG=1 Show parse diagnostics\n")
console.print("[cyan]Scopes auto-refresh whenever .gitignore changes.[/]") 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.[/]") console.print("[cyan]Legacy .github/Repo.toml or scopes.json are auto-migrated on first run.[/]")
@@ -839,7 +880,7 @@ def cmd_commit():
if not repo_has_scopes(str(repo_path)): if not repo_has_scopes(str(repo_path)):
console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n") console.print("\n[yellow bold]⚠ No scopes configured for this repository[/]\n")
console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n") console.print("[cyan]gh-commit organizes commits by project areas (scopes).[/]\n")
if confirm("Generate scopes now using Crush?"): if confirm("Generate scopes now using OpenCode?"):
if cmd_init() != 0: if cmd_init() != 0:
return 1 return 1
else: else: