Última actividad 1 day ago

Cross-platform Zsh setup scripts and managed dotfiles with Oh My Zsh framework, Starship prompt, aliases, functions, and path configuration.

Vernon Wee Hong KOH revisó este gist 1 day ago. Ir a la revisión

7 files changed, 260 insertions, 319 deletions

README.md

@@ -1,5 +1,6 @@
1 - # Bash Script Installer for Zsh
2 - The "Bash Script Installer" simplifies the setup of Zsh.
1 + # Zsh Setup
2 +
3 + Cross-platform Zsh installers and managed dotfiles for Oh My Zsh and Starship.
3 4
4 5 ## Zsh
5 6 ### Ubuntu
@@ -10,7 +11,7 @@ bash -c "$(curl -fsSL https://tinyurl.com/y6fhb594)"
10 11 ```zsh
11 12 bash -c "$(curl -fsSL https://tinyurl.com/ycyct6wp)"
12 13 ```
13 - ### MacOS
14 + ### macOS
14 15 ```zsh
15 16 bash -c "$(curl -fsSL https://tinyurl.com/yptybtpa)"
16 17 ```
@@ -20,7 +21,28 @@ bash -c "$(curl -fsSL https://tinyurl.com/yptybtpa)"
20 21 bash -c "$(curl -fsSL https://tinyurl.com/y8vf753j)"
21 22 ```
22 23
23 - ### Shell framework and prompt
24 + The platform installers share `config.sh`, which owns the managed-file list and
25 + download logic. Both configuration menu options install the canonical `zshrc`
26 + file. Existing files are replaced only after a non-empty download succeeds;
27 + failures return a nonzero status. Updates are atomic per file, not across the
28 + whole configuration set.
29 +
30 + When run from a checkout, installers load the adjacent `config.sh`. The
31 + curl-based entry points fetch it from the gist. `GIST_RAW_BASE` overrides the
32 + download base URL for both the helper and managed files.
33 +
34 + ## Repository layout
35 +
36 + | Files | Purpose |
37 + | --- | --- |
38 + | `zsh_ubuntu.sh`, `zsh_wsl.sh`, `zsh_macos.sh` | Platform setup and interactive menus |
39 + | `config.sh` | Shared configuration installer; also runnable on its own |
40 + | `zshrc`, `alias`, `func`, `pathrc`, `sourcerc`, `vimrc` | Managed shell and editor dotfiles |
41 + | `starship.toml` | Prompt configuration |
42 + | `ai-commit*.md`, `ai-commit*.txt` | Commit agents and prompts |
43 + | `test_*.py`, `test_plan_build.zsh` | Isolated regression tests |
44 +
45 + ## Shell framework and prompt
24 46 The installers use Oh My Zsh for framework features and plugins, with the OMZ theme disabled:
25 47
26 48 ```zsh
@@ -41,11 +63,12 @@ aicommit opencode
41 63
42 64 The installers place the shared commit instructions at `~/.config/ai-commit-prompt.txt` and the required OpenCode agent at `~/.config/opencode/agents/ai-commit.md`. The agent is hidden from the interactive agent selector but remains available to `aicommit opencode`. It may inspect Git status, the cached diff, and recent history, and may execute `git commit`; all unrelated tools and shell commands are denied.
43 65
44 - **Changes affecting more than 30 staged paths automatically use per-module commits.** They must produce multiple commits, rather than a single combined commit message. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default. Pass `--modules` to request splitting at any size:
66 + **Changes affecting more than 30 staged paths default to per-module commits.** In an interactive terminal, the function asks whether to split; declining requests one combined commit. Without a terminal, or with `--yes`, it splits automatically. Use `--single` to explicitly request one commit, or `--modules` to split at any size; these options are mutually exclusive. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default:
45 67
46 68 ```zsh
47 69 aicommit --modules opencode
48 70 aicommit --modules --yes claude
71 + aicommit --single opencode
49 72 ```
50 73
51 74 Automatic splitting uses the same planning and confirmation flow as `--modules`; `--yes` skips confirmation in either case (for example, `aicommit --yes opencode`). Plans that collapse a change above the threshold into one group are rejected before committing, with the index untouched. Grouping follows cohesive modules or submodules; 30 is the automatic-splitting threshold, not a hard size limit per planned module.
@@ -75,3 +98,18 @@ The canonical executable defines the current command behavior and options. Each
75 98 The launcher defaults to `https://opengist.resetrix.work/weehong/plan-build/raw/HEAD` and `${XDG_CACHE_HOME:-$HOME/.cache}/plan-build`. Override these with `PLAN_BUILD_BASE_URL` and `PLAN_BUILD_CACHE_DIR`. Installed skill paths remain fixed at `~/.claude/skills`; cached invocations receive immutable release paths through environment variables. The downloaded script is remote executable code: syntax validation catches malformed Zsh but is not a security sandbox, so only point `PLAN_BUILD_BASE_URL` at a source you trust. Remote code is executed in a child Zsh process and is never sourced into the caller shell.
76 99
77 100 Invoking `plan_build` once installs or repairs both skill links. To install standalone files without the cached launcher, follow the installation instructions in the canonical gist instead.
101 +
102 + ## Development checks
103 +
104 + Run from the repository root with Bash, Zsh, Python 3, Git, and curl installed:
105 +
106 + ```sh
107 + for file in *.sh; do bash -n "$file" || exit; done
108 + for file in alias func pathrc sourcerc zshrc; do zsh -n "$file" || exit; done
109 + PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -p 'test_*.py'
110 + zsh -f test_plan_build.zsh
111 + git diff --check
112 + ```
113 +
114 + Tests use temporary homes and local fixtures rather than installing packages or
115 + invoking AI services.

config.sh

@@ -1,8 +1,7 @@
1 1 #!/bin/bash
2 - set -euo pipefail
2 + # Shared configuration installer; safe to source from the platform installers.
3 3
4 - # Configuration
5 - GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD"
4 + GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}"
6 5 CONFIG_FILES=(
7 6 ".alias"
8 7 ".func"
@@ -17,37 +16,44 @@ CONFIG_FILES=(
17 16 ".config/opencode/agents/ai-commit-plan.md"
18 17 )
19 18
20 - echo "Starting configuration download..."
21 - download_failed=0
19 + download_config() (
20 + # A subshell keeps temporary-file traps out of the caller's shell.
21 + local file="$1" remote_name target tmp
22 + remote_name="${file##*/}"
23 + remote_name="${remote_name#.}"
24 + target="$HOME/$file"
25 + mkdir -p "$(dirname "$target")" || return 1
26 + tmp="$(mktemp "${target}.tmp.XXXXXX")" || return 1
27 + trap 'rm -f "$tmp"' EXIT
28 + trap 'exit 1' HUP INT TERM
22 29
23 - for f in "${CONFIG_FILES[@]}"; do
24 - # Remove the leading dot for the URL path
25 - remote_name="${f#.}"
26 - [[ "$f" == ".config/starship.toml" ]] && remote_name="starship.toml"
27 - [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_name="ai-commit-prompt.txt"
28 - [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_name="ai-commit-split-prompt.txt"
29 - [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_name="ai-commit.md"
30 - [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_name="ai-commit-plan.md"
31 - url="$GIST_RAW_BASE/$remote_name"
32 - target="$HOME/$f"
33 - tmp="${target}.tmp.$$"
34 -
35 - echo "Downloading $f..."
36 - mkdir -p "$(dirname "$target")"
37 -
38 - # Use -f to fail silently on server errors, -s for silent, -L to follow redirects
39 - if curl -fsSL "$url" -o "$tmp" && mv "$tmp" "$target"; then
40 - echo "Successfully updated $target"
30 + printf 'Downloading %s...\n' "$file"
31 + if curl -fsSL "${GIST_RAW_BASE%/}/$remote_name" -o "$tmp" &&
32 + [[ -s "$tmp" ]] && mv "$tmp" "$target"; then
33 + printf 'Successfully updated %s\n' "$target"
41 34 else
42 - rm -f "$tmp"
43 - echo "Error: Failed to download $f from $url" >&2
44 - download_failed=1
35 + printf 'Error: Failed to install %s\n' "$file" >&2
36 + return 1
45 37 fi
46 - done
38 + )
47 39
48 - if [ "$download_failed" -ne 0 ]; then
49 - echo "Configuration download completed with errors." >&2
50 - exit 1
51 - fi
40 + download_configs() {
41 + local file failed=0
42 + for file in "${CONFIG_FILES[@]}"; do
43 + download_config "$file" || failed=1
44 + done
45 + if [[ "$failed" -ne 0 ]]; then
46 + printf 'Configuration download completed with errors.\n' >&2
47 + return 1
48 + fi
49 + printf 'Done! All configuration files have been replaced.\n'
50 + }
51 +
52 + configure_zshrc() {
53 + download_config .zshrc
54 + }
52 55
53 - echo "Done! All configuration files have been replaced."
56 + if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
57 + set -euo pipefail
58 + download_configs
59 + fi

test_aicommit.py

@@ -68,7 +68,19 @@ class AicommitTests(unittest.TestCase):
68 68 result = self.shell(ROUTE, "--yes", engine)
69 69 self.assertEqual(result.returncode, 7, result.stderr)
70 70 self.assertEqual(result.stdout.strip(), f"MODULES {engine} 1")
71 - self.assertIn("exceed 30", result.stderr)
71 +
72 + def test_explicit_single_above_threshold(self):
73 + self.stage(31)
74 + result = self.shell(ROUTE, "--single", "opencode")
75 + self.assertEqual(result.returncode, 0, result.stderr)
76 + self.assertEqual(result.stdout.strip(), "ENGINE opencode commit")
77 +
78 + def test_conflicting_modes_never_call_engine(self):
79 + self.stage(1)
80 + result = self.shell(ROUTE, "--single", "--modules")
81 + self.assertNotEqual(result.returncode, 0)
82 + self.assertNotIn("ENGINE", result.stdout)
83 + self.assertNotIn("MODULES", result.stdout)
72 84
73 85 def test_explicit_modules_below_threshold(self):
74 86 self.stage(1)

test_config.py(archivo creado)

@@ -0,0 +1,103 @@
1 + #!/usr/bin/env python3
2 + """Test configuration downloads in a temporary home without network access."""
3 +
4 + import os
5 + from pathlib import Path
6 + import subprocess
7 + import tempfile
8 + import unittest
9 +
10 +
11 + ROOT = Path(__file__).resolve().parent
12 + TARGETS = (
13 + ".alias", ".func", ".pathrc", ".sourcerc", ".vimrc", ".zshrc",
14 + ".config/starship.toml", ".config/ai-commit-prompt.txt",
15 + ".config/ai-commit-split-prompt.txt",
16 + ".config/opencode/agents/ai-commit.md",
17 + ".config/opencode/agents/ai-commit-plan.md",
18 + )
19 +
20 +
21 + class ConfigTests(unittest.TestCase):
22 + def setUp(self):
23 + temp = tempfile.TemporaryDirectory(prefix="config-test-")
24 + self.addCleanup(temp.cleanup)
25 + self.root = Path(temp.name)
26 + self.home = self.root / "home"
27 + self.home.mkdir()
28 + self.env = {
29 + **os.environ, "HOME": str(self.home),
30 + "GIST_RAW_BASE": ROOT.as_uri() + "/",
31 + "TMPDIR": str(self.root),
32 + }
33 +
34 + def run_bash(self, *args):
35 + return subprocess.run(
36 + ["bash", *args], env=self.env, capture_output=True, text=True,
37 + )
38 +
39 + def assert_installed(self):
40 + for target in TARGETS:
41 + source = ROOT / Path(target).name.lstrip(".")
42 + self.assertEqual((self.home / target).read_bytes(), source.read_bytes())
43 + self.assertEqual(list(self.home.rglob("*.tmp.*")), [])
44 +
45 + def test_standalone_and_inline_entry_points(self):
46 + for args in ((str(ROOT / "config.sh"),), ("-c", (ROOT / "config.sh").read_text())):
47 + with self.subTest(args=args[:1]):
48 + result = self.run_bash(*args)
49 + self.assertEqual(result.returncode, 0, result.stderr)
50 + self.assert_installed()
51 +
52 + def test_source_has_no_download_side_effects(self):
53 + result = self.run_bash("-c", 'source "$1"', "test", str(ROOT / "config.sh"))
54 + self.assertEqual(result.returncode, 0, result.stderr)
55 + self.assertEqual(list(self.home.iterdir()), [])
56 +
57 + def test_failed_and_empty_downloads_preserve_existing_files(self):
58 + remote = self.root / "remote"
59 + remote.mkdir()
60 + self.env["GIST_RAW_BASE"] = remote.as_uri()
61 + for target in TARGETS:
62 + path = self.home / target
63 + path.parent.mkdir(parents=True, exist_ok=True)
64 + path.write_text("existing config\n")
65 + # One empty response, one successful response, all others missing.
66 + (remote / "zshrc").touch()
67 + (remote / "alias").write_text("new aliases\n")
68 + result = self.run_bash(str(ROOT / "config.sh"))
69 + self.assertNotEqual(result.returncode, 0)
70 + self.assertNotIn("Done!", result.stdout)
71 + for target in TARGETS:
72 + expected = "new aliases\n" if target == ".alias" else "existing config\n"
73 + self.assertEqual((self.home / target).read_text(), expected)
74 + self.assertEqual(list(self.home.rglob("*.tmp.*")), [])
75 +
76 + def test_platform_loaders_local_and_remote(self):
77 + for name in ("zsh_ubuntu.sh", "zsh_wsl.sh", "zsh_macos.sh"):
78 + for local in (True, False):
79 + with self.subTest(platform=name, local=local):
80 + script = ROOT / name
81 + if not local:
82 + # An isolated script has no adjacent config.sh, just like
83 + # the curl entry point, and must fetch the shared code.
84 + script = self.root / name
85 + script.write_text((ROOT / name).read_text())
86 + result = self.run_bash(
87 + "-c", 'source "$1"; load_config_installer; download_configs',
88 + "test", str(script),
89 + )
90 + self.assertEqual(result.returncode, 0, result.stderr)
91 + self.assert_installed()
92 +
93 + def test_zshrc_only_uses_canonical_file(self):
94 + result = self.run_bash(
95 + "-c", 'source "$1"; configure_zshrc', "test", str(ROOT / "config.sh"),
96 + )
97 + self.assertEqual(result.returncode, 0, result.stderr)
98 + self.assertEqual((self.home / ".zshrc").read_bytes(), (ROOT / "zshrc").read_bytes())
99 + self.assertEqual(list(self.home.iterdir()), [self.home / ".zshrc"])
100 +
101 +
102 + if __name__ == "__main__":
103 + unittest.main()

zsh_macos.sh

@@ -18,21 +18,7 @@ err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
18 18 # =============================
19 19 # CONFIG
20 20 # =============================
21 - GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD"
22 -
23 - CONFIG_FILES=(
24 - ".alias"
25 - ".func"
26 - ".pathrc"
27 - ".sourcerc"
28 - ".vimrc"
29 - ".zshrc"
30 - ".config/starship.toml"
31 - ".config/ai-commit-prompt.txt"
32 - ".config/ai-commit-split-prompt.txt"
33 - ".config/opencode/agents/ai-commit.md"
34 - ".config/opencode/agents/ai-commit-plan.md"
35 - )
21 + GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}"
36 22
37 23 # =============================
38 24 # REQUIREMENTS
@@ -87,90 +73,27 @@ install_starship() {
87 73 fi
88 74 }
89 75
90 - download_configs() {
91 - log "Downloading custom config files..."
92 - for f in "${CONFIG_FILES[@]}"; do
93 - # Strip the leading dot for the download URL
94 - local remote_file="${f#.}"
95 - [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml"
96 - [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt"
97 - [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt"
98 - [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md"
99 - [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md"
100 - local url="$GIST_RAW_BASE/$remote_file"
101 - local target="$HOME/$f"
102 - local tmp="${target}.tmp.$$"
103 -
104 - mkdir -p "$(dirname "$target")"
105 -
106 - log "Fetching $remote_file -> $f ..."
107 - if curl -fsSL "$url" -o "$tmp"; then
108 - mv "$tmp" "$target"
109 - else
76 + # Load the shared installer locally or fetch it for the curl-based entry point.
77 + load_config_installer() {
78 + local script_dir="" tmp
79 + if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
80 + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
81 + fi
82 + if [[ -n "$script_dir" && -f "$script_dir/config.sh" ]]; then
83 + source "$script_dir/config.sh"
84 + else
85 + tmp="$(mktemp)" || return 1
86 + if ! curl -fsSL "${GIST_RAW_BASE%/}/config.sh" -o "$tmp" ||
87 + [[ ! -s "$tmp" ]] || ! bash -n "$tmp"; then
110 88 rm -f "$tmp"
111 - warn "Failed to download $remote_file"
89 + err "Failed to load the shared configuration installer"
90 + return 1
112 91 fi
113 - done
114 -
115 - ok "Configs downloaded"
116 - }
117 -
118 - configure_zshrc() {
119 - local zshrc="$HOME/.zshrc"
120 - log "Configuring .zshrc for Oh My Zsh and Starship..."
121 -
122 - cat > "$zshrc" <<'EOF'
123 - # =============================================================================
124 - # 1. HELPER FUNCTIONS & PATH
125 - # =============================================================================
126 - source_if_readable() {
127 - local file="$1"
128 - if [[ -f "$file" && -r "$file" ]]; then
129 - source "$file"
92 + source "$tmp"
93 + rm -f "$tmp"
130 94 fi
131 95 }
132 96
133 - export PATH="$HOME/.local/bin:$PATH"
134 -
135 - # =============================================================================
136 - # 2. OH MY ZSH FRAMEWORK
137 - # =============================================================================
138 - export ZSH="${ZSH:-$HOME/.oh-my-zsh}"
139 - ZSH_THEME=""
140 -
141 - plugins=(
142 - git
143 - zsh-autosuggestions
144 - zsh-syntax-highlighting
145 - )
146 -
147 - source_if_readable "$ZSH/oh-my-zsh.sh"
148 -
149 - # =============================================================================
150 - # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS
151 - # =============================================================================
152 - source_if_readable "$HOME/.sourcerc"
153 - source_if_readable "$HOME/.func"
154 - source_if_readable "$HOME/.pathrc"
155 - source_if_readable "$HOME/.alias"
156 -
157 - # =============================================================================
158 - # 4. STARSHIP PROMPT
159 - # =============================================================================
160 - if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then
161 - export STARSHIP_CONFIG="$HOME/.config/starship.toml"
162 - fi
163 -
164 - if command -v starship >/dev/null 2>&1; then
165 - eval "$(starship init zsh)"
166 - elif [[ -x "$HOME/.local/bin/starship" ]]; then
167 - eval "$("$HOME/.local/bin/starship" init zsh)"
168 - fi
169 - EOF
170 -
171 - ok ".zshrc configured for Oh My Zsh framework with Starship prompt"
172 - }
173 -
174 97 configure_git_identity() {
175 98 log "Configuring global Git identity..."
176 99 git config --global user.name "Vernon Wee Hong KOH"
@@ -258,6 +181,7 @@ run_choices() {
258 181 # =============================
259 182 main() {
260 183 check_requirements
184 + load_config_installer
261 185 while true; do
262 186 show_menu
263 187 run_choices
@@ -267,4 +191,6 @@ main() {
267 191 ok "macOS minimal Zsh configuration complete!"
268 192 }
269 193
270 - main "$@"
194 + if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
195 + main "$@"
196 + fi

zsh_ubuntu.sh

@@ -22,20 +22,7 @@ SKIP_PACKAGES=false
22 22 SKIP_SHELL_CHANGE=false
23 23 INSTALL_HOMEBREW=false
24 24
25 - GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD"
26 - CONFIG_FILES=(
27 - ".alias"
28 - ".func"
29 - ".pathrc"
30 - ".sourcerc"
31 - ".vimrc"
32 - ".zshrc"
33 - ".config/starship.toml"
34 - ".config/ai-commit-prompt.txt"
35 - ".config/ai-commit-split-prompt.txt"
36 - ".config/opencode/agents/ai-commit.md"
37 - ".config/opencode/agents/ai-commit-plan.md"
38 - )
25 + GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}"
39 26
40 27 # =============================
41 28 # OS DETECTION
@@ -179,89 +166,27 @@ install_starship() {
179 166 fi
180 167 }
181 168
182 - download_configs() {
183 - log "Downloading custom config files from OpenGist..."
184 - for f in "${CONFIG_FILES[@]}"; do
185 - local remote_file="${f#.}"
186 - [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml"
187 - [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt"
188 - [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt"
189 - [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md"
190 - [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md"
191 - local url="$GIST_RAW_BASE/$remote_file"
192 - local target="$HOME/$f"
193 - local tmp="${target}.tmp.$$"
194 -
195 - mkdir -p "$(dirname "$target")"
196 -
197 - log "Fetching $remote_file -> $f ..."
198 - if curl -fsSL "$url" -o "$tmp"; then
199 - mv "$tmp" "$target"
200 - else
169 + # Load the shared installer locally or fetch it for the curl-based entry point.
170 + load_config_installer() {
171 + local script_dir="" tmp
172 + if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
173 + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
174 + fi
175 + if [[ -n "$script_dir" && -f "$script_dir/config.sh" ]]; then
176 + source "$script_dir/config.sh"
177 + else
178 + tmp="$(mktemp)" || return 1
179 + if ! curl -fsSL "${GIST_RAW_BASE%/}/config.sh" -o "$tmp" ||
180 + [[ ! -s "$tmp" ]] || ! bash -n "$tmp"; then
201 181 rm -f "$tmp"
202 - warn "Failed to download $remote_file"
182 + err "Failed to load the shared configuration installer"
183 + return 1
203 184 fi
204 - done
205 -
206 - ok "Configs downloaded"
207 - }
208 -
209 - configure_zshrc() {
210 - local zshrc="$HOME/.zshrc"
211 - log "Configuring .zshrc for Oh My Zsh and Starship..."
212 -
213 - cat > "$zshrc" <<'EOF'
214 - # =============================================================================
215 - # 1. HELPER FUNCTIONS & PATH
216 - # =============================================================================
217 - source_if_readable() {
218 - local file="$1"
219 - if [[ -f "$file" && -r "$file" ]]; then
220 - source "$file"
185 + source "$tmp"
186 + rm -f "$tmp"
221 187 fi
222 188 }
223 189
224 - export PATH="$HOME/.local/bin:$PATH"
225 -
226 - # =============================================================================
227 - # 2. OH MY ZSH FRAMEWORK
228 - # =============================================================================
229 - export ZSH="${ZSH:-$HOME/.oh-my-zsh}"
230 - ZSH_THEME=""
231 -
232 - plugins=(
233 - git
234 - zsh-autosuggestions
235 - zsh-syntax-highlighting
236 - )
237 -
238 - source_if_readable "$ZSH/oh-my-zsh.sh"
239 -
240 - # =============================================================================
241 - # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS
242 - # =============================================================================
243 - source_if_readable "$HOME/.sourcerc"
244 - source_if_readable "$HOME/.func"
245 - source_if_readable "$HOME/.pathrc"
246 - source_if_readable "$HOME/.alias"
247 -
248 - # =============================================================================
249 - # 4. STARSHIP PROMPT
250 - # =============================================================================
251 - if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then
252 - export STARSHIP_CONFIG="$HOME/.config/starship.toml"
253 - fi
254 -
255 - if command -v starship >/dev/null 2>&1; then
256 - eval "$(starship init zsh)"
257 - elif [[ -x "$HOME/.local/bin/starship" ]]; then
258 - eval "$("$HOME/.local/bin/starship" init zsh)"
259 - fi
260 - EOF
261 -
262 - ok ".zshrc configured for Oh My Zsh framework with Starship prompt"
263 - }
264 -
265 190 configure_git_identity() {
266 191 log "Configuring global Git identity..."
267 192 git config --global user.name "Vernon Wee Hong KOH"
@@ -355,6 +280,7 @@ run_choices() {
355 280 # =============================
356 281 main() {
357 282 check_requirements
283 + load_config_installer
358 284 while true; do
359 285 show_menu
360 286 run_choices
@@ -364,4 +290,6 @@ main() {
364 290 ok "Zsh installation/configuration complete!"
365 291 }
366 292
367 - main "$@"
293 + if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
294 + main "$@"
295 + fi

zsh_wsl.sh

@@ -22,20 +22,7 @@ SKIP_PACKAGES=false
22 22 SKIP_SHELL_CHANGE=false
23 23 INSTALL_HOMEBREW=false
24 24
25 - GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD"
26 - CONFIG_FILES=(
27 - ".alias"
28 - ".func"
29 - ".pathrc"
30 - ".sourcerc"
31 - ".vimrc"
32 - ".zshrc"
33 - ".config/starship.toml"
34 - ".config/ai-commit-prompt.txt"
35 - ".config/ai-commit-split-prompt.txt"
36 - ".config/opencode/agents/ai-commit.md"
37 - ".config/opencode/agents/ai-commit-plan.md"
38 - )
25 + GIST_RAW_BASE="${GIST_RAW_BASE:-https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD}"
39 26
40 27 # =============================
41 28 # PLATFORM DETECTION
@@ -181,89 +168,27 @@ install_starship() {
181 168 fi
182 169 }
183 170
184 - download_configs() {
185 - log "Downloading custom config files from OpenGist..."
186 - for f in "${CONFIG_FILES[@]}"; do
187 - local remote_file="${f#.}"
188 - [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml"
189 - [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt"
190 - [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt"
191 - [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md"
192 - [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md"
193 - local url="$GIST_RAW_BASE/$remote_file"
194 - local target="$HOME/$f"
195 - local tmp="${target}.tmp.$$"
196 -
197 - mkdir -p "$(dirname "$target")"
198 -
199 - log "Fetching $remote_file -> $f ..."
200 - if curl -fsSL "$url" -o "$tmp"; then
201 - mv "$tmp" "$target"
202 - else
171 + # Load the shared installer locally or fetch it for the curl-based entry point.
172 + load_config_installer() {
173 + local script_dir="" tmp
174 + if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then
175 + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
176 + fi
177 + if [[ -n "$script_dir" && -f "$script_dir/config.sh" ]]; then
178 + source "$script_dir/config.sh"
179 + else
180 + tmp="$(mktemp)" || return 1
181 + if ! curl -fsSL "${GIST_RAW_BASE%/}/config.sh" -o "$tmp" ||
182 + [[ ! -s "$tmp" ]] || ! bash -n "$tmp"; then
203 183 rm -f "$tmp"
204 - warn "Failed to download $remote_file"
184 + err "Failed to load the shared configuration installer"
185 + return 1
205 186 fi
206 - done
207 -
208 - ok "Configs downloaded"
209 - }
210 -
211 - configure_zshrc() {
212 - local zshrc="$HOME/.zshrc"
213 - log "Configuring .zshrc for Oh My Zsh and Starship..."
214 -
215 - cat > "$zshrc" <<'EOF'
216 - # =============================================================================
217 - # 1. HELPER FUNCTIONS & PATH
218 - # =============================================================================
219 - source_if_readable() {
220 - local file="$1"
221 - if [[ -f "$file" && -r "$file" ]]; then
222 - source "$file"
187 + source "$tmp"
188 + rm -f "$tmp"
223 189 fi
224 190 }
225 191
226 - export PATH="$HOME/.local/bin:$PATH"
227 -
228 - # =============================================================================
229 - # 2. OH MY ZSH FRAMEWORK
230 - # =============================================================================
231 - export ZSH="${ZSH:-$HOME/.oh-my-zsh}"
232 - ZSH_THEME=""
233 -
234 - plugins=(
235 - git
236 - zsh-autosuggestions
237 - zsh-syntax-highlighting
238 - )
239 -
240 - source_if_readable "$ZSH/oh-my-zsh.sh"
241 -
242 - # =============================================================================
243 - # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS
244 - # =============================================================================
245 - source_if_readable "$HOME/.sourcerc"
246 - source_if_readable "$HOME/.func"
247 - source_if_readable "$HOME/.pathrc"
248 - source_if_readable "$HOME/.alias"
249 -
250 - # =============================================================================
251 - # 4. STARSHIP PROMPT
252 - # =============================================================================
253 - if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then
254 - export STARSHIP_CONFIG="$HOME/.config/starship.toml"
255 - fi
256 -
257 - if command -v starship >/dev/null 2>&1; then
258 - eval "$(starship init zsh)"
259 - elif [[ -x "$HOME/.local/bin/starship" ]]; then
260 - eval "$("$HOME/.local/bin/starship" init zsh)"
261 - fi
262 - EOF
263 -
264 - ok ".zshrc configured for Oh My Zsh framework with Starship prompt"
265 - }
266 -
267 192 configure_git_identity() {
268 193 log "Configuring global Git identity..."
269 194 git config --global user.name "Vernon Wee Hong KOH"
@@ -357,6 +282,7 @@ run_choices() {
357 282 # =============================
358 283 main() {
359 284 check_requirements
285 + load_config_installer
360 286 while true; do
361 287 show_menu
362 288 run_choices
@@ -366,4 +292,6 @@ main() {
366 292 ok "WSL Zsh installation/configuration complete!"
367 293 }
368 294
369 - main "$@"
295 + if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
296 + main "$@"
297 + fi

weehong revisó este gist 1 day ago. Ir a la revisión

1 file changed, 14 insertions, 1 deletion

func

@@ -1134,6 +1134,7 @@ aicommit() {
1134 1134 local assume_yes=0
1135 1135 local arg
1136 1136 local diff
1137 + local single_hint
1137 1138 local staged_count
1138 1139 local prompt_file="$HOME/.config/ai-commit-prompt.txt"
1139 1140
@@ -1189,9 +1190,21 @@ aicommit() {
1189 1190 return 1
1190 1191 fi
1191 1192
1193 + # Above 30 paths a single commit is the user's explicit choice; tell the AI so
1194 + # it does not refuse under the prompt's own split rule.
1195 + single_hint=""
1196 + if [ "$staged_count" -gt 30 ]; then
1197 + if [ "$single" -eq 1 ]; then
1198 + printf "📦 Committing all %s staged paths as a single commit.\n" "$staged_count" >&2
1199 + fi
1200 + single_hint="USER OVERRIDE: The user explicitly chose ONE commit for all $staged_count staged paths. This overrides any instruction above about splitting commits above 30 paths or by module. Do not refuse, do not suggest --modules, and do not ask for confirmation. Create exactly one commit covering everything staged, with a summary line for the overall change and a body that groups the changes by area.
1201 +
1202 + "
1203 + fi
1204 +
1192 1205 _aicommit_engine "$engine" commit "$(cat "$prompt_file")
1193 1206
1194 - Staged path count: $staged_count
1207 + ${single_hint}Staged path count: $staged_count
1195 1208
1196 1209 Here is the git diff:
1197 1210 $diff"

weehong revisó este gist 1 day ago. Ir a la revisión

1 file changed, 40 insertions, 6 deletions

func

@@ -733,9 +733,30 @@ plan_build() {
733 733 # Internal: _aicommit_usage (Print aicommit usage)
734 734 # -----------------------------------------------------------------------------
735 735 _aicommit_usage() {
736 - printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n"
737 - printf " --modules Force per-module commits (automatic above 30 staged paths)\n"
738 - printf " --yes Skip the plan confirmation for per-module commits\n"
736 + printf "Usage: aicommit [--modules | --single] [--yes] [claude | copilot | opencode]\n"
737 + printf " --modules Split into per module commits without asking\n"
738 + printf " --single Make one commit without asking, even above 30 staged paths\n"
739 + printf " --yes Accept defaults: split above 30 paths and skip the plan confirmation\n"
740 + }
741 +
742 + # -----------------------------------------------------------------------------
743 + # Internal: _aicommit_ask_modules (Ask whether to split above 30 staged paths)
744 + # -----------------------------------------------------------------------------
745 + _aicommit_ask_modules() {
746 + local staged_count="$1"
747 + local reply
748 +
749 + if ! (: </dev/tty) 2>/dev/null; then
750 + printf "🧩 %s staged paths exceed 30 and no terminal is available; splitting by module.\n" "$staged_count" >&2
751 + return 0
752 + fi
753 +
754 + printf "🧩 %s staged paths exceed 30. Split into per module commits? (Y/n) " "$staged_count" >/dev/tty
755 + IFS= read -r reply </dev/tty || reply=""
756 + case "$reply" in
757 + ""|y|Y|yes|YES|Yes) return 0 ;;
758 + *) return 1 ;;
759 + esac
739 760 }
740 761
741 762 # -----------------------------------------------------------------------------
@@ -1109,6 +1130,7 @@ _aicommit_modules() {
1109 1130 aicommit() {
1110 1131 local engine="claude"
1111 1132 local modules=0
1133 + local single=0
1112 1134 local assume_yes=0
1113 1135 local arg
1114 1136 local diff
@@ -1119,6 +1141,7 @@ aicommit() {
1119 1141 case "$arg" in
1120 1142 claude|copilot|opencode) engine="$arg" ;;
1121 1143 --modules) modules=1 ;;
1144 + --single) single=1 ;;
1122 1145 -y|--yes) assume_yes=1 ;;
1123 1146 -h|--help)
1124 1147 _aicommit_usage
@@ -1132,6 +1155,11 @@ aicommit() {
1132 1155 esac
1133 1156 done
1134 1157
1158 + if [ "$modules" -eq 1 ] && [ "$single" -eq 1 ]; then
1159 + printf "❌ Error: --modules and --single cannot be used together.\n" >&2
1160 + return 1
1161 + fi
1162 +
1135 1163 if [ ! -f "$prompt_file" ]; then
1136 1164 printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2
1137 1165 printf "Please create it and paste your system prompt inside.\n" >&2
@@ -1141,10 +1169,16 @@ aicommit() {
1141 1169 # Count the full index, including excluded previews and both sides of renames.
1142 1170 # NUL delimiters keep whitespace/newlines in filenames from skewing the count.
1143 1171 staged_count="$(set -o pipefail; git diff --cached --name-only --no-renames -z | tr -cd '\0' | wc -c)" || return 1
1144 - if [ "$staged_count" -gt 30 ] && [ "$modules" -ne 1 ]; then
1145 - printf "🧩 %s staged paths exceed 30; splitting commits by module.\n" "$staged_count" >&2
1172 +
1173 + if [ "$modules" -ne 1 ] && [ "$single" -ne 1 ] && [ "$staged_count" -gt 30 ]; then
1174 + if [ "$assume_yes" -eq 1 ] || _aicommit_ask_modules "$staged_count"; then
1175 + modules=1
1176 + else
1177 + printf "📦 Committing all %s staged paths as a single commit.\n" "$staged_count" >&2
1178 + fi
1146 1179 fi
1147 - if [ "$modules" -eq 1 ] || [ "$staged_count" -gt 30 ]; then
1180 +
1181 + if [ "$modules" -eq 1 ]; then
1148 1182 _aicommit_modules "$engine" "$assume_yes"
1149 1183 return
1150 1184 fi

Vernon Wee Hong KOH revisó este gist 3 days ago. Ir a la revisión

1 file changed, 12 insertions, 5 deletions

func

@@ -147,17 +147,24 @@ ch() {
147 147 opencode)
148 148 local opencode_state_home="${XDG_STATE_HOME:-$HOME/.local/state}/opencode"
149 149 local opencode_data_home="${XDG_DATA_HOME:-$HOME/.local/share}/opencode"
150 + local opencode_db="${OPENCODE_DB:-$opencode_data_home/opencode.db}"
151 +
152 + require_cli opencode "OpenCode CLI" || return 1
153 + # Close OpenCode clients before running this from a separate shell.
154 + # Stop the service before unlinking its database and SQLite sidecars.
155 + command opencode service stop || return 1
150 156
151 157 command mkdir -p -- "$opencode_state_home" "$opencode_data_home" || return 1
152 158 : > "$opencode_state_home/prompt-history.jsonl" || return 1
153 159 command rm -f -- \
154 - "$opencode_data_home/opencode.db" \
155 - "$opencode_data_home/opencode.db-shm" \
156 - "$opencode_data_home/opencode.db-wal" || return 1
160 + "$opencode_db" \
161 + "$opencode_db-shm" \
162 + "$opencode_db-wal" \
163 + "$opencode_db-journal" || return 1
157 164 command rm -rf -- "$opencode_data_home/repos" "$opencode_data_home/log" "$opencode_data_home/snapshot" || return 1
158 165 command mkdir -p -- "$opencode_data_home/repos" "$opencode_data_home/log" "$opencode_data_home/snapshot" || return 1
159 166
160 - echo "OpenCode history cleared."
167 + echo "OpenCode database and history cleared. Launch OpenCode to start fresh."
161 168 ;;
162 169
163 170 ""|shell)
@@ -1359,4 +1366,4 @@ add_path_to_config() {
1359 1366
1360 1367 printf "✅ Success: Added to %s\n" "$config_file"
1361 1368 append_path "$new_path" && export PATH
1362 - }
1369 + }

weehong revisó este gist 6 days ago. Ir a la revisión

2 files changed, 38 insertions, 3 deletions

func

@@ -739,6 +739,31 @@ _aicommit_staged_diff() {
739 739 git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000
740 740 }
741 741
742 + # -----------------------------------------------------------------------------
743 + # Internal: _aicommit_opencode_model (Read OpenCode's most recent model selection)
744 + # -----------------------------------------------------------------------------
745 + _aicommit_opencode_model() {
746 + python3 - <<'PY'
747 + import json
748 + import os
749 + from pathlib import Path
750 +
751 + state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
752 + try:
753 + data = json.loads((state / "opencode/model.json").read_text())
754 + recent = data.get("recent", [])
755 + if recent:
756 + selected = recent[0]
757 + model = selected["providerID"] + "/" + selected["modelID"]
758 + variant = data.get("variant", {}).get(model)
759 + if variant and variant != "default":
760 + model += "#" + variant
761 + print(model)
762 + except FileNotFoundError:
763 + pass # Let OpenCode use its configured default on a fresh installation.
764 + PY
765 + }
766 +
742 767 # -----------------------------------------------------------------------------
743 768 # Internal: _aicommit_engine (Ask an AI CLI to commit or to plan a split)
744 769 # -----------------------------------------------------------------------------
@@ -747,6 +772,16 @@ _aicommit_engine() {
747 772 local mode="$2"
748 773 local input="$3"
749 774 local name
775 + local selected_model
776 + local -a model_args
777 + model_args=()
778 +
779 + if [ "$engine" = "opencode" ]; then
780 + selected_model="$(_aicommit_opencode_model)" || return 1
781 + if [ -n "$selected_model" ]; then
782 + model_args=(--model "$selected_model")
783 + fi
784 + fi
750 785
751 786 case "$engine" in
752 787 claude) name="Claude" ;;
@@ -779,10 +814,10 @@ _aicommit_engine() {
779 814 copilot -p "$input" -s --no-ask-user
780 815 ;;
781 816 opencode:commit)
782 - printf "%s\n" "$input" | opencode run --agent ai-commit
817 + printf "%s\n" "$input" | opencode run --agent ai-commit "${model_args[@]}"
783 818 ;;
784 819 opencode:plan)
785 - printf "%s\n" "$input" | opencode run --agent ai-commit-plan
820 + printf "%s\n" "$input" | opencode run --agent ai-commit-plan "${model_args[@]}"
786 821 ;;
787 822 esac
788 823 }
@@ -1324,4 +1359,4 @@ add_path_to_config() {
1324 1359
1325 1360 printf "✅ Success: Added to %s\n" "$config_file"
1326 1361 append_path "$new_path" && export PATH
1327 - }
1362 + }

Archivo vacío

Vernon Wee Hong KOH revisó este gist 1 week ago. Ir a la revisión

1 file changed, 4 insertions

func

@@ -176,6 +176,10 @@ ch() {
176 176 return 2
177 177 ;;
178 178 esac
179 +
180 + if [ -t 1 ]; then
181 + command clear
182 + fi
179 183 }
180 184
181 185 # -----------------------------------------------------------------------------

Vernon Wee Hong KOH revisó este gist 1 week ago. Ir a la revisión

7 files changed, 192 insertions, 7 deletions

README.md

@@ -41,13 +41,15 @@ aicommit opencode
41 41
42 42 The installers place the shared commit instructions at `~/.config/ai-commit-prompt.txt` and the required OpenCode agent at `~/.config/opencode/agents/ai-commit.md`. The agent is hidden from the interactive agent selector but remains available to `aicommit opencode`. It may inspect Git status, the cached diff, and recent history, and may execute `git commit`; all unrelated tools and shell commands are denied.
43 43
44 - Pass `--modules` to split a large staged change into one commit per module instead:
44 + **Changes affecting more than 30 staged paths automatically use per-module commits.** They must produce multiple commits, rather than a single combined commit message. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default. Pass `--modules` to request splitting at any size:
45 45
46 46 ```zsh
47 47 aicommit --modules opencode
48 48 aicommit --modules --yes claude
49 49 ```
50 50
51 + Automatic splitting uses the same planning and confirmation flow as `--modules`; `--yes` skips confirmation in either case (for example, `aicommit --yes opencode`). Plans that collapse a change above the threshold into one group are rejected before committing, with the index untouched. Grouping follows cohesive modules or submodules; 30 is the automatic-splitting threshold, not a hard size limit per planned module.
52 +
51 53 The engine first plans the split using `~/.config/ai-commit-split-prompt.txt` and replies with a JSON plan that maps every staged path to a `type(scope)` group, ordered so dependencies land first. The function validates the plan against the staged paths, ignores paths that are not staged, appends any unplanned paths as a final commit, and shows the plan for confirmation (`--yes` skips it). It then stages each group from a snapshot of the original index, so partially staged files keep only their staged hunks and unstaged or untracked work is never touched, and asks the engine to commit that group with the normal commit instructions. If a group produces no commit, or a commit includes files outside its group, the run stops and restages the remaining groups. With OpenCode, planning uses the hidden `ai-commit-plan` agent at `~/.config/opencode/agents/ai-commit-plan.md`, which is denied every tool. Python 3 is required to parse the plan.
52 54
53 55 ## Plan-build workflow

ai-commit-plan.md

@@ -7,3 +7,5 @@ permission:
7 7 ---
8 8
9 9 Follow the supplied planning instructions and staged file list. Reply with only the JSON plan. Do not run commands, modify files, or change the staged set.
10 +
11 + Changes affecting more than 30 staged paths must be modularized into at least two non-empty commits; never return a single combined group. Count the full staged file list, including lock/minified files and both paths of a rename. Split a large module into cohesive, independently reviewable submodules. At 30 paths or fewer, a single group is allowed if the changes form one module. This threshold is not a hard file limit per planned module.

ai-commit-prompt.txt

@@ -18,6 +18,18 @@ git diff --cached
18 18
19 19 ## Strict Rules (Zero Tolerance)
20 20
21 + ### Mandatory Module Splitting
22 + Changes affecting more than 30 staged paths MUST be modularized into
23 + multiple commits. Do not generate or execute a single combined commit for
24 + them. Count the full staged set, including lock/minified files omitted from
25 + the diff preview and both paths of a rename, not just the visible diff.
26 + If this input has more than 30 staged paths and is not explicitly identified
27 + as one module in a multi-commit plan, stop and request `aicommit --modules`.
28 + This refusal takes precedence over the output and execution instructions below.
29 + When the input says "commit X of Y, split by module" with Y greater than 1,
30 + generate and execute only that module's commit. The threshold applies to
31 + the original staged change, not a hard file limit per planned module.
32 +
21 33 ### 1. Header Format
22 34 **Pattern:** `type(scope): subject`
23 35

ai-commit-split-prompt.txt

@@ -49,8 +49,13 @@ to a single Conventional Commit `type(scope)`. Typical modules:
49 49 6. Each commit should have ONE type and ONE scope. If a group would need two
50 50 types (e.g. `feat` and `docs`), split it.
51 51 7. Do not over-split. A module normally spans several files; do not create
52 - one commit per file unless each file really is its own module. If all the
53 - changes form one module, return a single commit.
52 + one commit per file unless each file really is its own module. Changes
53 + affecting more than 30 staged paths MUST be modularized into at least two
54 + non-empty commits; never return a single combined commit for them. Count
55 + all staged paths, including lock/minified files and both paths of a rename.
56 + Split a large module into cohesive, independently reviewable submodules.
57 + At 30 paths or fewer, a single commit is allowed if all changes form one
58 + module. This threshold is not a hard file limit per planned module.
54 59 8. Order commits so dependencies land first:
55 60 build/deps -> shared code (ui, i18n, lib, types) -> features that use
56 61 them -> tests -> stories/docs.

ai-commit.md

@@ -12,4 +12,6 @@ permission:
12 12 "git commit *": allow
13 13 ---
14 14
15 - Follow the supplied commit instructions and staged diff. Generate a concise conventional commit message, then execute exactly one `git commit` with that message. You may inspect Git status, the cached diff, and recent log. Do not modify files or change the staged set.
15 + Follow the supplied commit instructions and staged diff. Changes affecting more than 30 staged paths must be modularized into multiple commits; never emit or execute a single combined commit for them. Use the supplied full staged path count, or inspect `git diff --cached --name-only --no-renames` if it is absent, including files omitted from the diff preview. Unless the input explicitly identifies this as one module in a multi-commit plan ("commit X of Y, split by module", Y > 1), stop and request `aicommit --modules` when the count exceeds 30. This refusal takes precedence over commit execution.
16 +
17 + For an eligible staged change or an individual module from that plan, generate a concise conventional commit message, then execute exactly one `git commit` with that message. You may inspect Git status, the cached diff, and recent log. Do not modify files or change the staged set.

func

@@ -723,8 +723,8 @@ plan_build() {
723 723 # -----------------------------------------------------------------------------
724 724 _aicommit_usage() {
725 725 printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n"
726 - printf " --modules Split staged changes into one commit per module\n"
727 - printf " --yes Skip the plan confirmation in --modules mode\n"
726 + printf " --modules Force per-module commits (automatic above 30 staged paths)\n"
727 + printf " --yes Skip the plan confirmation for per-module commits\n"
728 728 }
729 729
730 730 # -----------------------------------------------------------------------------
@@ -874,6 +874,9 @@ if missing:
874 874 warnings.append("%d staged path(s) were not planned; adding them as a final commit" % len(missing))
875 875 groups.append(("", missing))
876 876
877 + if len(staged) > 30 and len(groups) < 2:
878 + sys.exit("❌ Error: Changes affecting more than 30 staged paths require multiple module commits.")
879 +
877 880 for number, (label, files) in enumerate(groups, 1):
878 881 base = os.path.join(run_dir, "%03d" % number)
879 882 with open(base + ".files", "wb") as fh:
@@ -1063,6 +1066,7 @@ aicommit() {
1063 1066 local assume_yes=0
1064 1067 local arg
1065 1068 local diff
1069 + local staged_count
1066 1070 local prompt_file="$HOME/.config/ai-commit-prompt.txt"
1067 1071
1068 1072 for arg in "$@"; do
@@ -1088,7 +1092,13 @@ aicommit() {
1088 1092 return 1
1089 1093 fi
1090 1094
1091 - if [ "$modules" -eq 1 ]; then
1095 + # Count the full index, including excluded previews and both sides of renames.
1096 + # NUL delimiters keep whitespace/newlines in filenames from skewing the count.
1097 + staged_count="$(set -o pipefail; git diff --cached --name-only --no-renames -z | tr -cd '\0' | wc -c)" || return 1
1098 + if [ "$staged_count" -gt 30 ] && [ "$modules" -ne 1 ]; then
1099 + printf "🧩 %s staged paths exceed 30; splitting commits by module.\n" "$staged_count" >&2
1100 + fi
1101 + if [ "$modules" -eq 1 ] || [ "$staged_count" -gt 30 ]; then
1092 1102 _aicommit_modules "$engine" "$assume_yes"
1093 1103 return
1094 1104 fi
@@ -1101,6 +1111,8 @@ aicommit() {
1101 1111
1102 1112 _aicommit_engine "$engine" commit "$(cat "$prompt_file")
1103 1113
1114 + Staged path count: $staged_count
1115 +
1104 1116 Here is the git diff:
1105 1117 $diff"
1106 1118 }

test_aicommit.py(archivo creado)

@@ -0,0 +1,150 @@
1 + #!/usr/bin/env python3
2 + """Exercise aicommit routing and plan validation without invoking an AI CLI."""
3 +
4 + import json
5 + import os
6 + from pathlib import Path
7 + import subprocess
8 + import tempfile
9 + import unittest
10 +
11 +
12 + SOURCE = Path(__file__).resolve().parent / "func"
13 + ROUTE = '''
14 + source "$1"
15 + shift
16 + _aicommit_engine() { printf 'ENGINE %s %s\n' "$1" "$2"; }
17 + _aicommit_modules() { printf 'MODULES %s %s\n' "$1" "$2"; return 7; }
18 + aicommit "$@"
19 + '''
20 +
21 +
22 + class AicommitTests(unittest.TestCase):
23 + def setUp(self):
24 + self.temp = tempfile.TemporaryDirectory(prefix="aicommit-test-")
25 + self.addCleanup(self.temp.cleanup)
26 + self.root = Path(self.temp.name)
27 + self.repo = self.root / "repo"
28 + self.repo.mkdir()
29 + self.home = self.root / "home"
30 + config = self.home / ".config"
31 + config.mkdir(parents=True)
32 + for name in ("ai-commit-prompt.txt", "ai-commit-split-prompt.txt"):
33 + (config / name).write_text((SOURCE.parent / name).read_text())
34 + self.env = {**os.environ, "HOME": str(self.home)}
35 + self.git("init", "-q")
36 +
37 + def git(self, *args):
38 + return subprocess.check_output(
39 + ["git", *args], cwd=self.repo, env=self.env
40 + )
41 +
42 + def shell(self, script, *args, cwd=None):
43 + return subprocess.run(
44 + ["zsh", "-f", "-c", script, "test", str(SOURCE), *args],
45 + cwd=cwd or self.repo, env=self.env, text=True, capture_output=True,
46 + )
47 +
48 + def stage(self, count, suffix=".txt"):
49 + # A newline is part of one path, not an extra staged file.
50 + files = [f"file {i}{suffix}" for i in range(count)]
51 + if files:
52 + files[0] = "line\nbreak" + suffix
53 + for name in files:
54 + (self.repo / name).write_text("staged content\n")
55 + self.git("add", "--all")
56 + return files
57 +
58 + def test_thirty_paths_use_single_commit(self):
59 + self.stage(30)
60 + result = self.shell(ROUTE, "opencode")
61 + self.assertEqual(result.returncode, 0, result.stderr)
62 + self.assertEqual(result.stdout.strip(), "ENGINE opencode commit")
63 +
64 + def test_thirty_one_paths_route_all_engines_and_forward_yes(self):
65 + self.stage(31)
66 + for engine in ("claude", "copilot", "opencode"):
67 + with self.subTest(engine=engine):
68 + result = self.shell(ROUTE, "--yes", engine)
69 + self.assertEqual(result.returncode, 7, result.stderr)
70 + self.assertEqual(result.stdout.strip(), f"MODULES {engine} 1")
71 + self.assertIn("exceed 30", result.stderr)
72 +
73 + def test_explicit_modules_below_threshold(self):
74 + self.stage(1)
75 + result = self.shell(ROUTE, "--modules", "copilot")
76 + self.assertEqual(result.returncode, 7, result.stderr)
77 + self.assertEqual(result.stdout.strip(), "MODULES copilot 0")
78 +
79 + def test_filtered_files_still_trigger_modules_from_subdirectory(self):
80 + self.stage(31, ".lock")
81 + nested = self.repo / "nested"
82 + nested.mkdir()
83 + result = self.shell(ROUTE, cwd=nested)
84 + self.assertEqual(result.returncode, 7, result.stderr)
85 + self.assertEqual(result.stdout.strip(), "MODULES claude 0")
86 +
87 + def test_empty_index_and_git_errors_never_call_engine(self):
88 + for cwd in (self.repo, self.root):
89 + with self.subTest(cwd=cwd):
90 + result = self.shell(ROUTE, cwd=cwd)
91 + self.assertNotEqual(result.returncode, 0)
92 + self.assertNotIn("ENGINE", result.stdout)
93 + self.assertNotIn("MODULES", result.stdout)
94 +
95 + def plan(self, count, groups):
96 + run = self.root / "plan"
97 + run.mkdir()
98 + files = [f"file-{i}" for i in range(count)]
99 + (run / "staged").write_bytes(b"".join(p.encode() + b"\0" for p in files))
100 + (run / "plan.out").write_text(json.dumps({"commits": [
101 + {"type": "feat", "scope": "test", "files": group(files)}
102 + for group in groups
103 + ]}))
104 + return self.shell('source "$1"; _aicommit_plan "$2"', str(run)), run
105 +
106 + def test_single_group_plan_allowed_at_thirty(self):
107 + result, run = self.plan(30, [lambda files: files])
108 + self.assertEqual(result.returncode, 0, result.stderr)
109 + self.assertEqual((run / "count").read_text(), "1")
110 +
111 + def test_single_group_plan_rejected_above_thirty_after_deduplication(self):
112 + result, run = self.plan(31, [lambda files: files, lambda files: files])
113 + self.assertNotEqual(result.returncode, 0)
114 + self.assertIn("require multiple module commits", result.stderr)
115 + self.assertFalse((run / "count").exists())
116 +
117 + def test_empty_plan_cannot_fall_back_to_one_large_commit(self):
118 + result, _ = self.plan(31, [])
119 + self.assertNotEqual(result.returncode, 0)
120 + self.assertIn("require multiple module commits", result.stderr)
121 +
122 + def test_multiple_groups_allowed_even_with_large_module(self):
123 + result, run = self.plan(32, [lambda files: files[:-1], lambda files: files[-1:]])
124 + self.assertEqual(result.returncode, 0, result.stderr)
125 + self.assertEqual((run / "count").read_text(), "2")
126 +
127 + def test_rejected_auto_plan_preserves_partial_index_and_worktree(self):
128 + files = self.stage(31)
129 + changed = self.repo / files[0]
130 + changed.write_text("unstaged content\n")
131 + before = self.git("write-tree")
132 + plan = self.root / "single-plan.json"
133 + plan.write_text(json.dumps({"commits": [{"files": files}]}))
134 + result = self.shell('''
135 + source "$1"
136 + plan_file="$2"
137 + _aicommit_engine() {
138 + if [ "$2" = plan ]; then cat "$plan_file"; else printf 'UNEXPECTED COMMIT\n'; fi
139 + }
140 + aicommit --yes opencode
141 + ''', str(plan))
142 + self.assertNotEqual(result.returncode, 0)
143 + self.assertIn("require multiple module commits", result.stderr)
144 + self.assertNotIn("UNEXPECTED COMMIT", result.stdout)
145 + self.assertEqual(self.git("write-tree"), before)
146 + self.assertEqual(changed.read_text(), "unstaged content\n")
147 +
148 +
149 + if __name__ == "__main__":
150 + unittest.main()

Vernon Wee Hong KOH revisó este gist 2 weeks ago. Ir a la revisión

8 files changed, 492 insertions, 40 deletions

README.md

@@ -33,7 +33,7 @@ Starship owns the prompt and loads after Oh My Zsh. The managed Starship config
33 33
34 34 ## AI commits
35 35
36 - The managed `aicommit` function can use Claude, Codex, Copilot, or OpenCode to create a commit from staged changes:
36 + The managed `aicommit` function can use Claude, Copilot, or OpenCode to create a commit from staged changes:
37 37
38 38 ```zsh
39 39 aicommit opencode
@@ -41,6 +41,15 @@ aicommit opencode
41 41
42 42 The installers place the shared commit instructions at `~/.config/ai-commit-prompt.txt` and the required OpenCode agent at `~/.config/opencode/agents/ai-commit.md`. The agent is hidden from the interactive agent selector but remains available to `aicommit opencode`. It may inspect Git status, the cached diff, and recent history, and may execute `git commit`; all unrelated tools and shell commands are denied.
43 43
44 + Pass `--modules` to split a large staged change into one commit per module instead:
45 +
46 + ```zsh
47 + aicommit --modules opencode
48 + aicommit --modules --yes claude
49 + ```
50 +
51 + The engine first plans the split using `~/.config/ai-commit-split-prompt.txt` and replies with a JSON plan that maps every staged path to a `type(scope)` group, ordered so dependencies land first. The function validates the plan against the staged paths, ignores paths that are not staged, appends any unplanned paths as a final commit, and shows the plan for confirmation (`--yes` skips it). It then stages each group from a snapshot of the original index, so partially staged files keep only their staged hunks and unstaged or untracked work is never touched, and asks the engine to commit that group with the normal commit instructions. If a group produces no commit, or a commit includes files outside its group, the run stops and restages the remaining groups. With OpenCode, planning uses the hidden `ai-commit-plan` agent at `~/.config/opencode/agents/ai-commit-plan.md`, which is denied every tool. Python 3 is required to parse the plan.
52 +
44 53 ## Plan-build workflow
45 54
46 55 The local `plan_build` function is a thin cached launcher for the canonical public [plan-build gist](https://opengist.resetrix.work/weehong/plan-build). When curl is available, each invocation stages `plan_build.zsh`, `SKILL.md`, and `ARCHITECT.md`, requires all three to be non-empty, and syntax-checks the executable. It then moves the complete bundle into a unique immutable release directory and atomically switches one `current` symlink. Refresh activation is serialized with a kernel-backed Zsh file lock so concurrent callers cannot mix releases and crashes cannot leave a stale lock. If refresh fails, the launcher preserves and uses the last complete, validated release and repairs its skill links. Zsh is always required; curl is required only when no valid cached release exists.

ai-commit-plan.md(archivo creado)

@@ -0,0 +1,9 @@
1 + ---
2 + description: Plans how to split the staged diff supplied in the prompt into per-module commits.
3 + mode: primary
4 + hidden: true
5 + permission:
6 + "*": deny
7 + ---
8 +
9 + Follow the supplied planning instructions and staged file list. Reply with only the JSON plan. Do not run commands, modify files, or change the staged set.

ai-commit-split-prompt.txt(archivo creado)

@@ -0,0 +1,91 @@
1 + # Git Commit Split Planner Prompt
2 +
3 + ## Role
4 + You are an Automated Git Commit Planner.
5 +
6 + ## Objective
7 + Split the staged changes into a sequence of small, atomic commits, one per
8 + **module**. You only decide the grouping and order. You do NOT write commit
9 + messages and you do NOT run any commands.
10 +
11 + ---
12 +
13 + ## Input
14 + 1. The staged file list (`git diff --cached --name-status -M`)
15 + 2. A diffstat (`git diff --cached --numstat -M`: added, deleted, path)
16 + 3. The staged diff (lock files and minified files are omitted; it may be truncated)
17 +
18 + ---
19 +
20 + ## What Is a Module
21 + A module is one cohesive, independently reviewable unit of change that maps
22 + to a single Conventional Commit `type(scope)`. Typical modules:
23 +
24 + | Module | Example scope |
25 + | :--- | :--- |
26 + | A feature or domain area (pages, routes, services, models) | `organisation`, `auth` |
27 + | Shared UI components / design system | `ui` |
28 + | Translations / localisation | `i18n` |
29 + | End-to-end or integration tests not tied to one module | `e2e` |
30 + | Storybook stories | `storybook` |
31 + | Documentation | `docs` |
32 + | Build, dependencies, tooling config | `deps`, `eslint` |
33 + | CI/CD pipelines | `ci` |
34 +
35 + ---
36 +
37 + ## Strict Rules (Zero Tolerance)
38 +
39 + 1. Every path in the staged file list MUST appear in exactly one commit.
40 + Copy paths exactly as listed. Never invent, rename, or omit a path.
41 + 2. A rename line (`R<score> old new`) lists two paths. Put BOTH in the same
42 + commit.
43 + 3. Never split a single file across commits.
44 + 4. Lock files go in the same commit as the manifest that changed them
45 + (e.g. `package.json` with `package-lock.json`).
46 + 5. Unit tests that sit next to, or only exercise, one module go in that
47 + module's commit. E2E tests, storybook stories and docs get their own
48 + commits unless they are the only change.
49 + 6. Each commit should have ONE type and ONE scope. If a group would need two
50 + types (e.g. `feat` and `docs`), split it.
51 + 7. Do not over-split. A module normally spans several files; do not create
52 + one commit per file unless each file really is its own module. If all the
53 + changes form one module, return a single commit.
54 + 8. Order commits so dependencies land first:
55 + build/deps -> shared code (ui, i18n, lib, types) -> features that use
56 + them -> tests -> stories/docs.
57 +
58 + ---
59 +
60 + ## Output Format
61 +
62 + Output ONLY one JSON object. No prose, no explanation, no markdown fences.
63 +
64 + ```json
65 + {"commits":[{"type":"feat","scope":"i18n","files":["src/locales/en/organisation.json"]}]}
66 + ```
67 +
68 + * `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`,
69 + `test`, `build`, `ci`, `chore`
70 + * `scope`: lowercase scope, or an empty string if none fits
71 + * `files`: the exact paths from the staged file list
72 +
73 + ---
74 +
75 + ## Example
76 +
77 + * **Staged files:** `src/locales/en/organisation.json`,
78 + `src/locales/en/settings.json`, `src/components/ui/data-table.tsx`,
79 + `src/components/ui/stat-card.tsx`, `src/app/organisation/page.tsx`,
80 + `src/app/organisation/list/page.tsx`,
81 + `src/components/ui/data-table.stories.tsx`, `e2e/home.spec.ts`
82 + * **Output:**
83 + ```json
84 + {"commits":[
85 + {"type":"feat","scope":"i18n","files":["src/locales/en/organisation.json","src/locales/en/settings.json"]},
86 + {"type":"feat","scope":"ui","files":["src/components/ui/data-table.tsx","src/components/ui/stat-card.tsx"]},
87 + {"type":"feat","scope":"organisation","files":["src/app/organisation/page.tsx","src/app/organisation/list/page.tsx"]},
88 + {"type":"docs","scope":"storybook","files":["src/components/ui/data-table.stories.tsx"]},
89 + {"type":"test","scope":"e2e","files":["e2e/home.spec.ts"]}
90 + ]}
91 + ```

config.sh

@@ -12,7 +12,9 @@ CONFIG_FILES=(
12 12 ".zshrc"
13 13 ".config/starship.toml"
14 14 ".config/ai-commit-prompt.txt"
15 + ".config/ai-commit-split-prompt.txt"
15 16 ".config/opencode/agents/ai-commit.md"
17 + ".config/opencode/agents/ai-commit-plan.md"
16 18 )
17 19
18 20 echo "Starting configuration download..."
@@ -23,7 +25,9 @@ for f in "${CONFIG_FILES[@]}"; do
23 25 remote_name="${f#.}"
24 26 [[ "$f" == ".config/starship.toml" ]] && remote_name="starship.toml"
25 27 [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_name="ai-commit-prompt.txt"
28 + [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_name="ai-commit-split-prompt.txt"
26 29 [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_name="ai-commit.md"
30 + [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_name="ai-commit-plan.md"
27 31 url="$GIST_RAW_BASE/$remote_name"
28 32 target="$HOME/$f"
29 33 tmp="${target}.tmp.$$"

func

@@ -719,63 +719,390 @@ plan_build() {
719 719 }
720 720
721 721 # -----------------------------------------------------------------------------
722 - # Function: aicommit (Automated Conventional Commit Engine Wrapper)
722 + # Internal: _aicommit_usage (Print aicommit usage)
723 723 # -----------------------------------------------------------------------------
724 - aicommit() {
725 - local diff engine prompt
726 - engine=${1:-claude}
724 + _aicommit_usage() {
725 + printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n"
726 + printf " --modules Split staged changes into one commit per module\n"
727 + printf " --yes Skip the plan confirmation in --modules mode\n"
728 + }
729 +
730 + # -----------------------------------------------------------------------------
731 + # Internal: _aicommit_staged_diff (Staged diff without lock/minified files)
732 + # -----------------------------------------------------------------------------
733 + _aicommit_staged_diff() {
734 + # Exclude lock files/minified files and cap at ~100k characters to prevent CLI crashes
735 + git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000
736 + }
737 +
738 + # -----------------------------------------------------------------------------
739 + # Internal: _aicommit_engine (Ask an AI CLI to commit or to plan a split)
740 + # -----------------------------------------------------------------------------
741 + _aicommit_engine() {
742 + local engine="$1"
743 + local mode="$2"
744 + local input="$3"
745 + local name
746 +
727 747 case "$engine" in
728 - claude|copilot|opencode) ;;
729 - *)
730 - printf "❌ Error: Invalid engine '%s'\n" "$engine" >&2
731 - printf "Usage: aicommit [claude | copilot | opencode]\n" >&2
732 - return 1
748 + claude) name="Claude" ;;
749 + copilot) name="Copilot" ;;
750 + opencode) name="OpenCode" ;;
751 + esac
752 +
753 + # Status goes to stderr so plan output on stdout stays clean JSON.
754 + if [ "$mode" = "plan" ]; then
755 + printf "🧩 %s is planning per-module commits...\n" "$name" >&2
756 + else
757 + printf "🤖 %s is analyzing staged changes...\n" "$name" >&2
758 + fi
759 +
760 + case "$engine:$mode" in
761 + claude:commit)
762 + printf "%s\n" "$input" \
763 + | command claude --allowedTools 'Bash(git commit *)' --permission-mode dontAsk -p \
764 + "Read the provided instructions and diff, then generate and execute the commit."
765 + ;;
766 + claude:plan)
767 + printf "%s\n" "$input" \
768 + | command claude --permission-mode dontAsk -p \
769 + "Read the provided instructions and staged changes, then reply with only the JSON plan."
770 + ;;
771 + copilot:commit)
772 + copilot -p "$input" -s --no-ask-user --allow-tool='shell(git:*)'
773 + ;;
774 + copilot:plan)
775 + copilot -p "$input" -s --no-ask-user
776 + ;;
777 + opencode:commit)
778 + printf "%s\n" "$input" | opencode run --agent ai-commit
779 + ;;
780 + opencode:plan)
781 + printf "%s\n" "$input" | opencode run --agent ai-commit-plan
733 782 ;;
734 783 esac
735 -
736 - # 1. Get diff safely: exclude lock files/minified files and cap at ~100k characters to prevent CLI crashes
737 - diff=$(git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000)
738 -
739 - if [ -z "$diff" ]; then
784 + }
785 +
786 + # -----------------------------------------------------------------------------
787 + # Internal: _aicommit_restage (Stage HEAD plus plan groups first..last from a tree)
788 + # -----------------------------------------------------------------------------
789 + _aicommit_restage() {
790 + local tree="$1"
791 + local run_dir="$2"
792 + local first="$3"
793 + local last="$4"
794 + local number
795 +
796 + if git rev-parse -q --verify HEAD >/dev/null; then
797 + git read-tree HEAD || return 1
798 + else
799 + git read-tree --empty || return 1
800 + fi
801 +
802 + [ "$first" -le "$last" ] || return 0
803 +
804 + number="$first"
805 + while [ "$number" -le "$last" ]; do
806 + cat "$run_dir/$(printf "%03d" "$number").files"
807 + number=$((number + 1))
808 + done | git --literal-pathspecs restore --staged --source="$tree" --pathspec-from-file=- --pathspec-file-nul
809 + }
810 +
811 + # -----------------------------------------------------------------------------
812 + # Internal: _aicommit_plan (Validate the AI split plan into per-group files)
813 + # -----------------------------------------------------------------------------
814 + _aicommit_plan() {
815 + local run_dir="$1"
816 +
817 + python3 -c '
818 + import json, os, re, sys
819 +
820 + sys.stdout.reconfigure(errors="backslashreplace")
821 + sys.stderr.reconfigure(errors="backslashreplace")
822 + run_dir = sys.argv[1]
823 + types = {"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore"}
824 +
825 + with open(os.path.join(run_dir, "staged"), "rb") as fh:
826 + staged = [p for p in fh.read().decode("utf-8", "surrogateescape").split("\0") if p]
827 + with open(os.path.join(run_dir, "plan.out"), encoding="utf-8", errors="replace") as fh:
828 + raw = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", fh.read())
829 +
830 + # Take the last {"commits": [...]} object in the reply, ignoring fences and prose.
831 + plan, decoder, pos = None, json.JSONDecoder(), raw.find("{")
832 + while pos != -1:
833 + try:
834 + obj, end = decoder.raw_decode(raw, pos)
835 + except ValueError:
836 + pos = raw.find("{", pos + 1)
837 + continue
838 + if isinstance(obj, dict) and isinstance(obj.get("commits"), list):
839 + plan = obj
840 + pos = raw.find("{", end)
841 + else:
842 + pos = raw.find("{", pos + 1)
843 +
844 + if plan is None:
845 + sys.exit("❌ Error: The AI reply did not contain a JSON commit plan.")
846 +
847 + # Every staged path must land in exactly one group; anything unassigned goes last.
848 + staged_set, seen, groups, warnings = set(staged), set(), [], []
849 + for commit in plan["commits"]:
850 + if not isinstance(commit, dict) or not isinstance(commit.get("files"), list):
851 + continue
852 + files = []
853 + for path in commit["files"]:
854 + if not isinstance(path, str):
855 + continue
856 + if path not in staged_set:
857 + warnings.append("ignoring path that is not staged: " + path)
858 + elif path in seen:
859 + warnings.append("ignoring duplicate path: " + path)
860 + else:
861 + seen.add(path)
862 + files.append(path)
863 + if not files:
864 + continue
865 + kind = str(commit.get("type", "")).strip().lower()
866 + scope = re.sub(r"[^a-z0-9._/-]", "", str(commit.get("scope", "")).lower())
867 + label = ""
868 + if kind in types:
869 + label = kind + ("(" + scope + ")" if scope else "")
870 + groups.append((label, files))
871 +
872 + missing = [p for p in staged if p not in seen]
873 + if missing:
874 + warnings.append("%d staged path(s) were not planned; adding them as a final commit" % len(missing))
875 + groups.append(("", missing))
876 +
877 + for number, (label, files) in enumerate(groups, 1):
878 + base = os.path.join(run_dir, "%03d" % number)
879 + with open(base + ".files", "wb") as fh:
880 + fh.write("".join(p + "\0" for p in files).encode("utf-8", "surrogateescape"))
881 + with open(base + ".label", "w") as fh:
882 + fh.write(label)
883 + with open(os.path.join(run_dir, "count"), "w") as fh:
884 + fh.write(str(len(groups)))
885 +
886 + for warning in warnings:
887 + print("⚠️ " + warning, file=sys.stderr)
888 + print("📋 Planned commits:")
889 + for number, (label, files) in enumerate(groups, 1):
890 + print(" %d. %s (%d file%s)" % (number, label or "unplanned", len(files), "" if len(files) == 1 else "s"))
891 + for path in files:
892 + print(" " + path)
893 + ' "$run_dir"
894 + }
895 +
896 + # -----------------------------------------------------------------------------
897 + # Internal: _aicommit_modules_run (Plan, confirm, and commit each module group)
898 + # -----------------------------------------------------------------------------
899 + _aicommit_modules_run() {
900 + local engine="$1"
901 + local assume_yes="$2"
902 + local run_dir="$3"
903 + local prompt_file="$HOME/.config/ai-commit-prompt.txt"
904 + local split_prompt_file="$HOME/.config/ai-commit-split-prompt.txt"
905 + local tree
906 + local total
907 + local current
908 + local group
909 + local label
910 + local hint
911 + local diff
912 + local expected
913 + local head_before
914 + local head_after
915 + local unexpected
916 + local reply
917 +
918 + if ! tree="$(git write-tree 2>/dev/null)"; then
919 + printf "❌ Error: Unable to snapshot the index (unresolved conflicts?).\n" >&2
920 + return 1
921 + fi
922 +
923 + git diff --cached --name-only --no-renames -z > "$run_dir/staged" || return 1
924 + if [ ! -s "$run_dir/staged" ]; then
740 925 printf "❌ Error: Nothing staged to commit.\n" >&2
741 926 return 1
742 927 fi
743 928
744 - if [ ! -f "$HOME/.config/ai-commit-prompt.txt" ]; then
745 - printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2
746 - printf "Please create it and paste your system prompt inside.\n" >&2
929 + _aicommit_engine "$engine" plan "$(cat "$split_prompt_file")
930 +
931 + Here are the staged files:
932 + $(git -c core.quotePath=false diff --cached --name-status -M)
933 +
934 + Here is the diffstat:
935 + $(git -c core.quotePath=false diff --cached --numstat -M)
936 +
937 + Here is the git diff:
938 + $(_aicommit_staged_diff)" > "$run_dir/plan.out"
939 +
940 + if ! _aicommit_plan "$run_dir"; then
941 + printf "Last lines of the AI reply:\n" >&2
942 + tail -n 20 "$run_dir/plan.out" >&2
747 943 return 1
748 944 fi
945 + total="$(cat "$run_dir/count")"
749 946
750 - prompt=$(cat "$HOME/.config/ai-commit-prompt.txt")
947 + if [ "$assume_yes" -ne 1 ]; then
948 + if ! (: </dev/tty) 2>/dev/null; then
949 + printf "❌ Error: No interactive terminal to confirm the plan; rerun with --yes.\n" >&2
950 + return 1
951 + fi
952 + printf "Create these %s commit(s)? (Y/n) " "$total" >/dev/tty
953 + IFS= read -r reply </dev/tty || reply="n"
954 + case "$reply" in
955 + ""|y|Y|yes|YES|Yes) ;;
956 + *)
957 + printf "Aborted. Staged changes are untouched.\n"
958 + return 1
959 + ;;
960 + esac
961 + fi
751 962
752 - case "$engine" in
753 - claude)
754 - echo "🤖 Claude is analyzing staged changes..."
755 - cat "$HOME/.config/ai-commit-prompt.txt" <(echo -e "\n\nHere is the git diff:\n$diff") \
756 - | command claude --allowedTools 'Bash(git commit *)' --permission-mode dontAsk -p \
757 - "Read the provided instructions and diff, then generate and execute the commit."
758 - ;;
963 + printf "💾 Staged snapshot: %s (recover with: git read-tree %s)\n" "$tree" "$tree"
964 + current=1
965 + trap '_aicommit_restage "$tree" "$run_dir" "$current" "$total"; printf "\n⚠️ Interrupted. Uncommitted groups are staged again.\n" >&2; rm -rf "$run_dir"; exit 130' INT TERM
966 +
967 + while [ "$current" -le "$total" ]; do
968 + group="$run_dir/$(printf "%03d" "$current").files"
969 + label="$(cat "$run_dir/$(printf "%03d" "$current").label")"
970 +
971 + if ! _aicommit_restage "$tree" "$run_dir" "$current" "$current"; then
972 + printf "❌ Error: Unable to stage commit %s/%s.\n" "$current" "$total" >&2
973 + _aicommit_restage "$tree" "$run_dir" "$current" "$total"
974 + return 1
975 + fi
976 +
977 + expected="$(git write-tree)"
978 + head_before="$(git rev-parse -q --verify HEAD)"
979 + diff="$(_aicommit_staged_diff)"
980 + [ -n "$diff" ] || diff="$(git diff --cached --stat)"
981 +
982 + hint="This is commit $current of $total, split by module."
983 + if [ -n "$label" ]; then
984 + hint="$hint The planned type and scope is $label; use it unless the diff clearly calls for another."
985 + fi
759 986
760 - copilot)
761 - echo "🤖 Copilot is analyzing staged changes..."
762 - copilot -p "$prompt
987 + printf "\n[%s/%s] %s\n" "$current" "$total" "${label:-unplanned}"
988 + _aicommit_engine "$engine" commit "$(cat "$prompt_file")
989 +
990 + $hint
763 991
764 992 Here is the git diff:
765 - $diff" -s --no-ask-user --allow-tool='shell(git:*)'
766 - ;;
993 + $diff"
767 994
768 - opencode)
769 - echo "🤖 OpenCode is analyzing staged changes..."
770 - cat "$HOME/.config/ai-commit-prompt.txt" <(echo -e "\n\nHere is the git diff:\n$diff") | opencode run --agent ai-commit
771 - ;;
995 + head_after="$(git rev-parse -q --verify HEAD)"
996 + if [ -z "$head_after" ] || [ "$head_after" = "$head_before" ]; then
997 + printf "❌ Error: No commit was created for group %s/%s.\n" "$current" "$total" >&2
998 + _aicommit_restage "$tree" "$run_dir" "$current" "$total"
999 + printf "Uncommitted groups are staged again.\n" >&2
1000 + return 1
1001 + fi
772 1002
773 - *)
774 - printf "❌ Error: Invalid engine '%s'\n" "$engine" >&2
775 - printf "Usage: aicommit [claude | copilot | opencode]\n" >&2
1003 + # Hooks may reformat this group's files, but nothing outside it may sneak in.
1004 + unexpected="$(LC_ALL=C comm -13 \
1005 + <(tr '\0' '\n' < "$group" | LC_ALL=C sort) \
1006 + <(git diff --name-only --no-renames -z "$expected" HEAD | tr '\0' '\n' | LC_ALL=C sort))"
1007 + if [ -n "$unexpected" ]; then
1008 + printf "❌ Error: Commit %s/%s included files outside its group:\n%s\n" "$current" "$total" "$unexpected" >&2
1009 + _aicommit_restage "$tree" "$run_dir" "$((current + 1))" "$total"
1010 + printf "Inspect it with 'git show'; undo it with 'git reset --soft HEAD~1'.\n" >&2
776 1011 return 1
777 - ;;
778 - esac
1012 + fi
1013 +
1014 + current=$((current + 1))
1015 + done
1016 +
1017 + trap - INT TERM
1018 + printf "\n✅ Created %s commit(s):\n" "$total"
1019 + git --no-pager log --oneline -n "$total"
1020 + }
1021 +
1022 + # -----------------------------------------------------------------------------
1023 + # Internal: _aicommit_modules (Split staged changes into per-module commits)
1024 + # -----------------------------------------------------------------------------
1025 + _aicommit_modules() {
1026 + local engine="$1"
1027 + local assume_yes="$2"
1028 + local top
1029 + local run_dir
1030 + local rc
1031 +
1032 + if [ ! -f "$HOME/.config/ai-commit-split-prompt.txt" ]; then
1033 + printf "❌ Error: Configuration file not found at ~/.config/ai-commit-split-prompt.txt\n" >&2
1034 + return 1
1035 + fi
1036 +
1037 + require_cli python3 "Python 3" || return 1
1038 +
1039 + if ! top="$(git rev-parse --show-toplevel 2>/dev/null)"; then
1040 + printf "❌ Error: Not inside a git work tree.\n" >&2
1041 + return 1
1042 + fi
1043 +
1044 + run_dir="$(mktemp -d "${TMPDIR:-/tmp}/aicommit.XXXXXX")" || return 1
1045 +
1046 + # Subshell: run from the repo root and keep the traps away from the caller.
1047 + (
1048 + trap 'rm -rf "$run_dir"; exit 130' INT TERM
1049 + cd "$top" || exit 1
1050 + _aicommit_modules_run "$engine" "$assume_yes" "$run_dir"
1051 + )
1052 + rc=$?
1053 + rm -rf "$run_dir"
1054 + return "$rc"
1055 + }
1056 +
1057 + # -----------------------------------------------------------------------------
1058 + # Function: aicommit (Automated Conventional Commit Engine Wrapper)
1059 + # -----------------------------------------------------------------------------
1060 + aicommit() {
1061 + local engine="claude"
1062 + local modules=0
1063 + local assume_yes=0
1064 + local arg
1065 + local diff
1066 + local prompt_file="$HOME/.config/ai-commit-prompt.txt"
1067 +
1068 + for arg in "$@"; do
1069 + case "$arg" in
1070 + claude|copilot|opencode) engine="$arg" ;;
1071 + --modules) modules=1 ;;
1072 + -y|--yes) assume_yes=1 ;;
1073 + -h|--help)
1074 + _aicommit_usage
1075 + return 0
1076 + ;;
1077 + *)
1078 + printf "❌ Error: Invalid argument '%s'\n" "$arg" >&2
1079 + _aicommit_usage >&2
1080 + return 1
1081 + ;;
1082 + esac
1083 + done
1084 +
1085 + if [ ! -f "$prompt_file" ]; then
1086 + printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2
1087 + printf "Please create it and paste your system prompt inside.\n" >&2
1088 + return 1
1089 + fi
1090 +
1091 + if [ "$modules" -eq 1 ]; then
1092 + _aicommit_modules "$engine" "$assume_yes"
1093 + return
1094 + fi
1095 +
1096 + diff="$(_aicommit_staged_diff)"
1097 + if [ -z "$diff" ]; then
1098 + printf "❌ Error: Nothing staged to commit.\n" >&2
1099 + return 1
1100 + fi
1101 +
1102 + _aicommit_engine "$engine" commit "$(cat "$prompt_file")
1103 +
1104 + Here is the git diff:
1105 + $diff"
779 1106 }
780 1107
781 1108 # -----------------------------------------------------------------------------

zsh_macos.sh

@@ -29,7 +29,9 @@ CONFIG_FILES=(
29 29 ".zshrc"
30 30 ".config/starship.toml"
31 31 ".config/ai-commit-prompt.txt"
32 + ".config/ai-commit-split-prompt.txt"
32 33 ".config/opencode/agents/ai-commit.md"
34 + ".config/opencode/agents/ai-commit-plan.md"
33 35 )
34 36
35 37 # =============================
@@ -92,7 +94,9 @@ download_configs() {
92 94 local remote_file="${f#.}"
93 95 [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml"
94 96 [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt"
97 + [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt"
95 98 [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md"
99 + [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md"
96 100 local url="$GIST_RAW_BASE/$remote_file"
97 101 local target="$HOME/$f"
98 102 local tmp="${target}.tmp.$$"

zsh_ubuntu.sh

@@ -32,7 +32,9 @@ CONFIG_FILES=(
32 32 ".zshrc"
33 33 ".config/starship.toml"
34 34 ".config/ai-commit-prompt.txt"
35 + ".config/ai-commit-split-prompt.txt"
35 36 ".config/opencode/agents/ai-commit.md"
37 + ".config/opencode/agents/ai-commit-plan.md"
36 38 )
37 39
38 40 # =============================
@@ -183,7 +185,9 @@ download_configs() {
183 185 local remote_file="${f#.}"
184 186 [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml"
185 187 [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt"
188 + [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt"
186 189 [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md"
190 + [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md"
187 191 local url="$GIST_RAW_BASE/$remote_file"
188 192 local target="$HOME/$f"
189 193 local tmp="${target}.tmp.$$"

zsh_wsl.sh

@@ -32,7 +32,9 @@ CONFIG_FILES=(
32 32 ".zshrc"
33 33 ".config/starship.toml"
34 34 ".config/ai-commit-prompt.txt"
35 + ".config/ai-commit-split-prompt.txt"
35 36 ".config/opencode/agents/ai-commit.md"
37 + ".config/opencode/agents/ai-commit-plan.md"
36 38 )
37 39
38 40 # =============================
@@ -185,7 +187,9 @@ download_configs() {
185 187 local remote_file="${f#.}"
186 188 [[ "$f" == ".config/starship.toml" ]] && remote_file="starship.toml"
187 189 [[ "$f" == ".config/ai-commit-prompt.txt" ]] && remote_file="ai-commit-prompt.txt"
190 + [[ "$f" == ".config/ai-commit-split-prompt.txt" ]] && remote_file="ai-commit-split-prompt.txt"
188 191 [[ "$f" == ".config/opencode/agents/ai-commit.md" ]] && remote_file="ai-commit.md"
192 + [[ "$f" == ".config/opencode/agents/ai-commit-plan.md" ]] && remote_file="ai-commit-plan.md"
189 193 local url="$GIST_RAW_BASE/$remote_file"
190 194 local target="$HOME/$f"
191 195 local tmp="${target}.tmp.$$"

Vernon Wee Hong KOH revisó este gist 2 weeks ago. Ir a la revisión

1 file changed, 10 insertions, 10 deletions

func

@@ -102,18 +102,18 @@ open_file() {
102 102 alias open='open_file'
103 103
104 104 # -----------------------------------------------------------------------------
105 - # Function: clear_history (Supports shell, Claude, Codex, and OpenCode)
105 + # Function: ch (Supports shell, Claude, Codex, and OpenCode)
106 106 # -----------------------------------------------------------------------------
107 - clear_history() {
107 + ch() {
108 108 if [ "$#" -gt 1 ]; then
109 - printf "Usage: clear_history [--all | shell | claude | codex | opencode]\n" >&2
109 + printf "Usage: ch [--all | shell | claude | codex | opencode]\n" >&2
110 110 return 2
111 111 fi
112 112 case "${1:-}" in
113 113 --all)
114 114 local result=0 target
115 115 for target in claude codex opencode shell; do
116 - clear_history "$target" || result=1
116 + ch "$target" || result=1
117 117 done
118 118
119 119 if [ -t 1 ]; then
@@ -172,7 +172,7 @@ clear_history() {
172 172 echo "Shell history cleared."
173 173 ;;
174 174 *)
175 - printf "Usage: clear_history [--all | shell | claude | codex | opencode]\n" >&2
175 + printf "Usage: ch [--all | shell | claude | codex | opencode]\n" >&2
176 176 return 2
177 177 ;;
178 178 esac
@@ -207,7 +207,7 @@ require_cli() {
207 207 claude() {
208 208 # Shortcut for clearing cache
209 209 if [[ "$1" == "clear" ]]; then
210 - clear_history claude
210 + ch claude
211 211 return $?
212 212 fi
213 213
@@ -813,9 +813,9 @@ create() {
813 813 }
814 814
815 815 # -----------------------------------------------------------------------------
816 - # Function: kill_port (Kill or check the process using a port)
816 + # Function: ckp (Kill or check the process using a port)
817 817 # -----------------------------------------------------------------------------
818 - kill_port() {
818 + ckp() {
819 819 local action="kill"
820 820 local signal="TERM"
821 821 local port
@@ -837,7 +837,7 @@ kill_port() {
837 837 shift
838 838 ;;
839 839 -h|--help|"")
840 - printf "Usage: kill_port [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2
840 + printf "Usage: ckp [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2
841 841 return 1
842 842 ;;
843 843 esac
@@ -845,7 +845,7 @@ kill_port() {
845 845 port="${1:-}"
846 846 if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
847 847 printf "Error: port must be a number between 1 and 65535.\n" >&2
848 - printf "Usage: kill_port [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2
848 + printf "Usage: ckp [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2
849 849 return 1
850 850 fi
851 851

Vernon Wee Hong KOH revisó este gist 3 weeks ago. Ir a la revisión

1 file changed, 9 insertions

alias

@@ -31,6 +31,15 @@ alias gstu='git stash push --keep-index --include-untracked'
31 31 alias gsta='git stash push --include-untracked'
32 32 alias grmrf='git checkout -- . && git clean -fd'
33 33
34 + # Create a feature worktree from origin/integration: gwa pill
35 + gwa() {
36 + if [ "$#" -ne 1 ] || [ -z "$1" ]; then
37 + printf 'Usage: gwa <feature-name>\n' >&2
38 + return 1
39 + fi
40 + git worktree add "../worktree/$1" -b "feature/$1" origin/integration
41 + }
42 +
34 43 # List loaded aliases, optionally filtered by a case-insensitive keyword.
35 44 ah() {
36 45 if [ "$#" -eq 0 ]; then
Siguiente Anterior