mirror of
https://github.com/prdlk/gh-commit.git
synced 2026-11-02 08:22:42 +00:00
feat: set opencode as default
This commit is contained in:
1 parent
2ab62dd347
commit
d754bae5ad
4 files changed
+93
-68
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
+83
-42
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user