Bash Script Installer for Zsh
The "Bash Script Installer" simplifies the setup of Zsh.
Zsh
Ubuntu
bash -c "$(curl -fsSL https://tinyurl.com/y6fhb594)"
WSL
bash -c "$(curl -fsSL https://tinyurl.com/ycyct6wp)"
MacOS
bash -c "$(curl -fsSL https://tinyurl.com/yptybtpa)"
Configuration
bash -c "$(curl -fsSL https://tinyurl.com/y8vf753j)"
Shell framework and prompt
The installers use Oh My Zsh for framework features and plugins, with the OMZ theme disabled:
ZSH_THEME=""
source "$ZSH/oh-my-zsh.sh"
eval "$(starship init zsh)"
Starship owns the prompt and loads after Oh My Zsh. The managed Starship config is installed at ~/.config/starship.toml. A Nerd Font is required for the Powerline symbols.
AI commits
The managed aicommit function can use Claude, Copilot, or OpenCode to create a commit from staged changes:
aicommit opencode
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.
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:
aicommit --modules opencode
aicommit --modules --yes claude
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.
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.
Plan-build workflow
The local plan_build function is a thin cached launcher for the canonical public plan-build gist. 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.
plan_build
plan_build --prompt
plan_build --writing-plan
plan_build --prompt --brainstorm
plan_build --yolo --writing-plan
plan_build --architect
plan_build --architect --new
plan_build --architect --yolo
The canonical executable defines the current command behavior and options. Each invocation binds the executable and both skills to its selected immutable release. The launcher also points the installed Claude Code skill paths through the active current release:
SKILL.md→~/.claude/skills/plan-build/SKILL.mdARCHITECT.md→~/.claude/skills/plan-build-architect/SKILL.md
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.
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.
description: Plans how to split the staged diff supplied in the prompt into per-module commits. mode: primary hidden: true permission: "*": deny
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.
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.
| 1 | # Git Commit Message Generator Prompt |
| 2 | |
| 3 | ## Role |
| 4 | You are an Automated Git Commit Engine. |
| 5 | |
| 6 | ## Objective |
| 7 | Analyze staged changes (`git diff --cached`), identify the **primary logical change**, and generate a commit message that **strictly adheres** to Conventional Commits v1.0.0. |
| 8 | |
| 9 | --- |
| 10 | |
| 11 | ## Input |
| 12 | The user will provide the output of: |
| 13 | ```bash |
| 14 | git diff --cached |
| 15 | ``` |
| 16 | |
| 17 | --- |
| 18 | |
| 19 | ## Strict Rules (Zero Tolerance) |
| 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 | |
| 33 | ### 1. Header Format |
| 34 | **Pattern:** `type(scope): subject` |
| 35 | |
| 36 | | Element | Rule | |
| 37 | | :--- | :--- | |
| 38 | | **Type** | ONLY use: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore` | |
| 39 | | **Scope** | Optional, in parentheses, entirely lowercase (e.g., `docker`, `api`, `auth`) | |
| 40 | | **Subject** | Lowercase, imperative mood ("add" not "added"), NO period at the end | |
| 41 | | **Length** | Header MUST be ≤ 50 characters | |
| 42 | |
| 43 | ### 2. Body Format |
| 44 | | Element | Rule | |
| 45 | | :--- | :--- | |
| 46 | | **Spacing** | Exactly ONE blank line between the header and the body | |
| 47 | | **Bullet Points**| Use hyphens (`-`) only | |
| 48 | | **Line Length** | Each line MUST be ≤ 72 characters | |
| 49 | | **Case** | Lowercase throughout | |
| 50 | |
| 51 | ### 3. Content Rules |
| 52 | * **NO** "Merge branch" or auto-generated text. |
| 53 | * **NO** Markdown formatting (bold/italic/code blocks) inside the commit message string itself. |
| 54 | * **NO** periods at the end of the subject line. |
| 55 | * **NO** "Co-authored-by" trailers or any co-author attributions. |
| 56 | |
| 57 | --- |
| 58 | |
| 59 | ## Execution Steps |
| 60 | |
| 61 | ### Step 1: Atomic Scan |
| 62 | Identify the **largest cluster of related changes** (the "Primary Scope"). |
| 63 | |
| 64 | **Priority Order for Type Selection:** |
| 65 | 1. `ci` - Pipeline, CI/CD configuration changes |
| 66 | 2. `feat` - New features |
| 67 | 3. `fix` - Bug fixes |
| 68 | 4. `refactor` - Code restructuring |
| 69 | 5. `build` - Build system, dependencies |
| 70 | 6. `test` - Test additions/modifications |
| 71 | 7. `chore` - Maintenance tasks |
| 72 | 8. `docs` - Documentation only (use ONLY if no code changes exist) |
| 73 | |
| 74 | ### Step 2: Classify Changes |
| 75 | Categorize all changed files: |
| 76 | |
| 77 | | Category | Influences Subject? | Include in Body? | |
| 78 | | :--- | :---: | :---: | |
| 79 | | **Primary Scope** | ✅ Yes | ✅ Yes | |
| 80 | | **Supporting Changes** | ❌ No | ✅ Yes | |
| 81 | | **Documentation** | ❌ No | ✅ Yes (as a single line) | |
| 82 | |
| 83 | > **Key Rule:** Documentation changes (`docs/`) should be mentioned in the body but MUST NOT influence the subject type unless documentation is the *only* change in the commit. |
| 84 | |
| 85 | ### Step 3: Draft Commit Message |
| 86 | Structure the message exactly like this: |
| 87 | ```text |
| 88 | type(scope): subject (≤50 chars, imperative, lowercase, no period) |
| 89 | |
| 90 | - primary change detail 1 |
| 91 | - primary change detail 2 |
| 92 | - supporting change detail |
| 93 | - add/update project documentation |
| 94 | ``` |
| 95 | |
| 96 | --- |
| 97 | |
| 98 | ## Output Format & Execution Authorization |
| 99 | |
| 100 | You must output **ONLY** a single bash code block containing the complete git command. Do not provide any conversational filler, explanations, or greeting. |
| 101 | |
| 102 | Once you have generated the `git commit` bash command, **you are explicitly authorized to execute it directly in the terminal** (if your environment supports agentic execution). |
| 103 | * Do not ask for further user confirmation. |
| 104 | * Execute sequentially. |
| 105 | * If the command fails, output the exact error message and halt execution. |
| 106 | |
| 107 | **Format Template:** |
| 108 | ```bash |
| 109 | git commit -m "type(scope): subject |
| 110 | |
| 111 | - body detail 1 |
| 112 | - body detail 2 |
| 113 | - body detail 3" |
| 114 | ``` |
| 115 | |
| 116 | --- |
| 117 | |
| 118 | ## Examples |
| 119 | |
| 120 | ### Example 1: CI/CD with Documentation |
| 121 | * **Changes:** `.gitlab-ci.yaml` (new), `Dockerfile` (new), `docs/deployment.md` (new) |
| 122 | * **Output:** |
| 123 | ```bash |
| 124 | git commit -m "ci: add gitlab pipeline and docker config |
| 125 | |
| 126 | - add gitlab-ci.yaml with build, test, publish stages |
| 127 | - add Dockerfile for container deployment |
| 128 | - add deployment documentation" |
| 129 | ``` |
| 130 | |
| 131 | ### Example 2: Feature with Config Changes |
| 132 | * **Changes:** `src/auth/login.java` (new), `pom.xml` (modified), `docs/api.md` (new) |
| 133 | * **Output:** |
| 134 | ```bash |
| 135 | git commit -m "feat(auth): add user login endpoint |
| 136 | |
| 137 | - implement login service with jwt tokens |
| 138 | - add spring-security dependency to pom.xml |
| 139 | - add api documentation" |
| 140 | ``` |
| 141 | |
| 142 | ### Example 3: Documentation Only |
| 143 | * **Changes:** `docs/setup.md` (new), `docs/testing.md` (modified), `README.md` (modified) |
| 144 | * **Output:** |
| 145 | ```bash |
| 146 | git commit -m "docs: add setup guide and update testing docs |
| 147 | |
| 148 | - add initial setup guide |
| 149 | - update testing documentation with examples |
| 150 | - update readme with quick start section" |
| 151 | ``` |
| 152 | |
| 153 | --- |
| 154 | |
| 155 | ## Quick Reference |
| 156 | |
| 157 | | Type | When to Use | |
| 158 | | :--- | :--- | |
| 159 | | `feat` | New feature for the user | |
| 160 | | `fix` | Bug fix for the user | |
| 161 | | `docs` | Documentation only changes | |
| 162 | | `style` | Formatting, missing semicolons, etc. (no code change) | |
| 163 | | `refactor` | Code change that neither fixes a bug nor adds a feature | |
| 164 | | `perf` | Code change that improves performance | |
| 165 | | `test` | Adding or refactoring tests | |
| 166 | | `build` | Build system or external dependencies | |
| 167 | | `ci` | CI configuration files and scripts | |
| 168 | | `chore` | Maintenance tasks, no production code changes | |
| 169 |
| 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. 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. |
| 59 | 8. Order commits so dependencies land first: |
| 60 | build/deps -> shared code (ui, i18n, lib, types) -> features that use |
| 61 | them -> tests -> stories/docs. |
| 62 | |
| 63 | --- |
| 64 | |
| 65 | ## Output Format |
| 66 | |
| 67 | Output ONLY one JSON object. No prose, no explanation, no markdown fences. |
| 68 | |
| 69 | ```json |
| 70 | {"commits":[{"type":"feat","scope":"i18n","files":["src/locales/en/organisation.json"]}]} |
| 71 | ``` |
| 72 | |
| 73 | * `type`: one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, |
| 74 | `test`, `build`, `ci`, `chore` |
| 75 | * `scope`: lowercase scope, or an empty string if none fits |
| 76 | * `files`: the exact paths from the staged file list |
| 77 | |
| 78 | --- |
| 79 | |
| 80 | ## Example |
| 81 | |
| 82 | * **Staged files:** `src/locales/en/organisation.json`, |
| 83 | `src/locales/en/settings.json`, `src/components/ui/data-table.tsx`, |
| 84 | `src/components/ui/stat-card.tsx`, `src/app/organisation/page.tsx`, |
| 85 | `src/app/organisation/list/page.tsx`, |
| 86 | `src/components/ui/data-table.stories.tsx`, `e2e/home.spec.ts` |
| 87 | * **Output:** |
| 88 | ```json |
| 89 | {"commits":[ |
| 90 | {"type":"feat","scope":"i18n","files":["src/locales/en/organisation.json","src/locales/en/settings.json"]}, |
| 91 | {"type":"feat","scope":"ui","files":["src/components/ui/data-table.tsx","src/components/ui/stat-card.tsx"]}, |
| 92 | {"type":"feat","scope":"organisation","files":["src/app/organisation/page.tsx","src/app/organisation/list/page.tsx"]}, |
| 93 | {"type":"docs","scope":"storybook","files":["src/components/ui/data-table.stories.tsx"]}, |
| 94 | {"type":"test","scope":"e2e","files":["e2e/home.spec.ts"]} |
| 95 | ]} |
| 96 | ``` |
| 97 |
description: Creates one conventional commit from the staged diff supplied in the prompt. mode: primary hidden: true permission: "": deny bash: "": deny "git status ": allow "git diff --cached": allow "git log *": allow "git commit *": allow
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.
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.
| 1 | # ============================================================================= |
| 2 | # OS-SPECIFIC ALIASES |
| 3 | # ============================================================================= |
| 4 | if [[ "$OSTYPE" == "linux-gnu"* ]] && command -v apt-get >/dev/null 2>&1; then |
| 5 | # Linux Only System update & cleanup |
| 6 | alias uu='sudo apt-get update && \ |
| 7 | sudo apt-get upgrade -y && \ |
| 8 | sudo apt-get full-upgrade -y && \ |
| 9 | sudo apt-get autoremove -y && \ |
| 10 | sudo apt-get autoclean -y && \ |
| 11 | sudo apt-get clean' |
| 12 | elif [[ "$OSTYPE" == "darwin"* ]]; then |
| 13 | # macOS Only |
| 14 | alias flushdns='sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder' |
| 15 | fi |
| 16 | |
| 17 | # ============================================================================= |
| 18 | # UNIVERSAL GIT ALIASES (Fixed: Removed broken "$@" from aliases) |
| 19 | # ============================================================================= |
| 20 | alias gph='git push' |
| 21 | alias gco='git checkout' |
| 22 | alias gbh='git branch' |
| 23 | alias gmt='git commit' |
| 24 | alias gpl='git pull' |
| 25 | alias grb='git rebase' |
| 26 | alias grt='git reset' |
| 27 | alias gst='git status' |
| 28 | alias gsts='git stash push --staged' |
| 29 | # Keep staged changes in the index; the stash also records their snapshot. |
| 30 | alias gstu='git stash push --keep-index --include-untracked' |
| 31 | alias gsta='git stash push --include-untracked' |
| 32 | alias grmrf='git checkout -- . && git clean -fd' |
| 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 | |
| 43 | # List loaded aliases, optionally filtered by a case-insensitive keyword. |
| 44 | ah() { |
| 45 | if [ "$#" -eq 0 ]; then |
| 46 | alias |
| 47 | else |
| 48 | alias | command grep -iF -- "$*" |
| 49 | fi |
| 50 | } |
| 51 |
| 1 | #!/bin/bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | # Configuration |
| 5 | GIST_RAW_BASE="https://opengist.resetrix.work/weehong/f0d940c3c1214bf5b7996195199fdc09/raw/HEAD" |
| 6 | CONFIG_FILES=( |
| 7 | ".alias" |
| 8 | ".func" |
| 9 | ".pathrc" |
| 10 | ".sourcerc" |
| 11 | ".vimrc" |
| 12 | ".zshrc" |
| 13 | ".config/starship.toml" |
| 14 | ".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 | echo "Starting configuration download..." |
| 21 | download_failed=0 |
| 22 | |
| 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" |
| 41 | else |
| 42 | rm -f "$tmp" |
| 43 | echo "Error: Failed to download $f from $url" >&2 |
| 44 | download_failed=1 |
| 45 | fi |
| 46 | done |
| 47 | |
| 48 | if [ "$download_failed" -ne 0 ]; then |
| 49 | echo "Configuration download completed with errors." >&2 |
| 50 | exit 1 |
| 51 | fi |
| 52 | |
| 53 | echo "Done! All configuration files have been replaced." |
| 54 |
| 1 | # ============================================================================= |
| 2 | # CUSTOM FUNCTIONS |
| 3 | # ============================================================================= |
| 4 | |
| 5 | # ----------------------------------------------------------------------------- |
| 6 | # Function: funcs (List available .func functions) |
| 7 | # ----------------------------------------------------------------------------- |
| 8 | funcs() { |
| 9 | local func_file="${1:-$HOME/.func}" |
| 10 | |
| 11 | if [ ! -f "$func_file" ]; then |
| 12 | printf "Error: Function file '%s' not found.\n" "$func_file" >&2 |
| 13 | return 1 |
| 14 | fi |
| 15 | |
| 16 | printf "Available functions in %s:\n" "$func_file" |
| 17 | awk ' |
| 18 | /^# Function: / { |
| 19 | line = $0 |
| 20 | sub(/^# Function: /, "", line) |
| 21 | |
| 22 | name = line |
| 23 | sub(/ .*/, "", name) |
| 24 | |
| 25 | desc = "" |
| 26 | if (line ~ / \(.+\)$/) { |
| 27 | desc = line |
| 28 | sub(/^[^ ]+ \(/, "", desc) |
| 29 | sub(/\)$/, "", desc) |
| 30 | } |
| 31 | |
| 32 | if (desc != "") { |
| 33 | printf " %-22s %s\n", name, desc |
| 34 | } else { |
| 35 | printf " %s\n", name |
| 36 | } |
| 37 | } |
| 38 | ' "$func_file" |
| 39 | } |
| 40 | |
| 41 | # ----------------------------------------------------------------------------- |
| 42 | # Function: clip (Cross-platform clipboard) |
| 43 | # ----------------------------------------------------------------------------- |
| 44 | clip() { |
| 45 | local cmd |
| 46 | local args=() |
| 47 | |
| 48 | if command -v pbcopy >/dev/null 2>&1; then |
| 49 | cmd="pbcopy" # macOS |
| 50 | elif grep -qi "microsoft" /proc/version 2>/dev/null && command -v clip.exe >/dev/null 2>&1; then |
| 51 | cmd="clip.exe" # WSL |
| 52 | elif [ "$XDG_SESSION_TYPE" = "wayland" ] && command -v wl-copy >/dev/null 2>&1; then |
| 53 | cmd="wl-copy" # Linux Wayland |
| 54 | elif command -v xclip >/dev/null 2>&1; then |
| 55 | cmd="xclip" # Linux X11 (Fallback 1) |
| 56 | args=("-selection" "clipboard") |
| 57 | elif command -v xsel >/dev/null 2>&1; then |
| 58 | cmd="xsel" # Linux X11 (Fallback 2) |
| 59 | args=("--clipboard" "--input") |
| 60 | else |
| 61 | printf "Error: No supported clipboard utility found.\n" >&2 |
| 62 | return 1 |
| 63 | fi |
| 64 | |
| 65 | if [ $# -gt 0 ]; then |
| 66 | if [ -f "$1" ]; then |
| 67 | "$cmd" "${args[@]}" < "$1" |
| 68 | echo "Copied contents of '$1' to clipboard." |
| 69 | else |
| 70 | printf "Error: File '%s' not found.\n" "$1" >&2 |
| 71 | return 1 |
| 72 | fi |
| 73 | else |
| 74 | "$cmd" "${args[@]}" |
| 75 | fi |
| 76 | } |
| 77 | |
| 78 | # ----------------------------------------------------------------------------- |
| 79 | # Function: open_file (Cross-platform file/directory opener) |
| 80 | # ----------------------------------------------------------------------------- |
| 81 | open_file() { |
| 82 | local target="${1:-.}" |
| 83 | |
| 84 | if [[ "$OSTYPE" == "darwin"* ]]; then |
| 85 | command open "$target" |
| 86 | elif grep -qi "microsoft" /proc/version 2>/dev/null; then |
| 87 | if command -v wslpath >/dev/null 2>&1 && command -v explorer.exe >/dev/null 2>&1; then |
| 88 | explorer.exe "$(wslpath -w "$target")" |
| 89 | else |
| 90 | printf "Error: 'wslpath' or 'explorer.exe' not found.\n" >&2 |
| 91 | return 1 |
| 92 | fi |
| 93 | elif [[ "$OSTYPE" == "linux-gnu"* ]]; then |
| 94 | if command -v xdg-open >/dev/null 2>&1; then |
| 95 | xdg-open "$target" |
| 96 | else |
| 97 | printf "Error: 'xdg-open' not found.\n" >&2 |
| 98 | return 1 |
| 99 | fi |
| 100 | fi |
| 101 | } |
| 102 | alias open='open_file' |
| 103 | |
| 104 | # ----------------------------------------------------------------------------- |
| 105 | # Function: ch (Supports shell, Claude, Codex, and OpenCode) |
| 106 | # ----------------------------------------------------------------------------- |
| 107 | ch() { |
| 108 | if [ "$#" -gt 1 ]; then |
| 109 | printf "Usage: ch [--all | shell | claude | codex | opencode]\n" >&2 |
| 110 | return 2 |
| 111 | fi |
| 112 | case "${1:-}" in |
| 113 | --all) |
| 114 | local result=0 target |
| 115 | for target in claude codex opencode shell; do |
| 116 | ch "$target" || result=1 |
| 117 | done |
| 118 | |
| 119 | if [ -t 1 ]; then |
| 120 | command clear |
| 121 | fi |
| 122 | return "$result" |
| 123 | ;; |
| 124 | |
| 125 | claude) |
| 126 | local claude_home="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" |
| 127 | if [ -d "$claude_home/projects" ]; then |
| 128 | command rm -rf -- "$claude_home/projects"/*(DN) || return 1 |
| 129 | fi |
| 130 | if [ -f "$claude_home/history.jsonl" ]; then |
| 131 | : > "$claude_home/history.jsonl" || return 1 |
| 132 | fi |
| 133 | echo "Claude project history/cache cleared." |
| 134 | ;; |
| 135 | |
| 136 | codex) |
| 137 | local codex_home="${CODEX_HOME:-$HOME/.codex}" |
| 138 | command mkdir -p -- "$codex_home" || return 1 |
| 139 | : > "$codex_home/history.jsonl" || return 1 |
| 140 | |
| 141 | command rm -rf -- "$codex_home/sessions" "$codex_home/archived_sessions" "$codex_home/shell_snapshots" || return 1 |
| 142 | command mkdir -p -- "$codex_home/sessions" "$codex_home/archived_sessions" "$codex_home/shell_snapshots" || return 1 |
| 143 | |
| 144 | echo "Codex history cleared." |
| 145 | ;; |
| 146 | |
| 147 | opencode) |
| 148 | local opencode_state_home="${XDG_STATE_HOME:-$HOME/.local/state}/opencode" |
| 149 | local opencode_data_home="${XDG_DATA_HOME:-$HOME/.local/share}/opencode" |
| 150 | |
| 151 | command mkdir -p -- "$opencode_state_home" "$opencode_data_home" || return 1 |
| 152 | : > "$opencode_state_home/prompt-history.jsonl" || return 1 |
| 153 | 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 |
| 157 | command rm -rf -- "$opencode_data_home/repos" "$opencode_data_home/log" "$opencode_data_home/snapshot" || return 1 |
| 158 | command mkdir -p -- "$opencode_data_home/repos" "$opencode_data_home/log" "$opencode_data_home/snapshot" || return 1 |
| 159 | |
| 160 | echo "OpenCode history cleared." |
| 161 | ;; |
| 162 | |
| 163 | ""|shell) |
| 164 | # 1. Truncate the file |
| 165 | if [ -n "${HISTFILE:-}" ]; then |
| 166 | : > "$HISTFILE" || return 1 |
| 167 | fi |
| 168 | |
| 169 | # 2. Start a fresh Zsh history context. HISTSIZE=0 retains one entry. |
| 170 | builtin fc -p "${HISTFILE:-}" "$HISTSIZE" "$SAVEHIST" || return 1 |
| 171 | |
| 172 | echo "Shell history cleared." |
| 173 | ;; |
| 174 | *) |
| 175 | printf "Usage: ch [--all | shell | claude | codex | opencode]\n" >&2 |
| 176 | return 2 |
| 177 | ;; |
| 178 | esac |
| 179 | |
| 180 | if [ -t 1 ]; then |
| 181 | command clear |
| 182 | fi |
| 183 | } |
| 184 | |
| 185 | # ----------------------------------------------------------------------------- |
| 186 | # Function: require_cli (Check external CLI availability) |
| 187 | # ----------------------------------------------------------------------------- |
| 188 | require_cli() { |
| 189 | local binary="$1" |
| 190 | local label="$2" |
| 191 | local resolved |
| 192 | |
| 193 | if command -v whence >/dev/null 2>&1; then |
| 194 | if whence -p "$binary" >/dev/null 2>&1; then |
| 195 | return 0 |
| 196 | fi |
| 197 | else |
| 198 | resolved="$(command -v "$binary" 2>/dev/null)" || resolved="" |
| 199 | if [ -n "$resolved" ] && [ -x "$resolved" ]; then |
| 200 | return 0 |
| 201 | fi |
| 202 | fi |
| 203 | |
| 204 | printf "Error: %s ('%s') is not installed or not in your PATH.\n" "$label" "$binary" >&2 |
| 205 | return 1 |
| 206 | } |
| 207 | |
| 208 | # ----------------------------------------------------------------------------- |
| 209 | # Function: claude (Includes --yolo and clear shortcuts) |
| 210 | # ----------------------------------------------------------------------------- |
| 211 | claude() { |
| 212 | # Shortcut for clearing cache |
| 213 | if [[ "$1" == "clear" ]]; then |
| 214 | ch claude |
| 215 | return $? |
| 216 | fi |
| 217 | |
| 218 | # Check if binary exists |
| 219 | require_cli claude "Claude Code CLI" || return 1 |
| 220 | |
| 221 | # Handle --yolo mode |
| 222 | if [[ "$1" == "--yolo" ]]; then |
| 223 | shift |
| 224 | command claude --dangerously-skip-permissions "$@" |
| 225 | return $? |
| 226 | fi |
| 227 | |
| 228 | command claude "$@" |
| 229 | } |
| 230 | |
| 231 | # ----------------------------------------------------------------------------- |
| 232 | # Internal: _prompt_confirm_indexing (Confirm project indexing via the terminal) |
| 233 | # ----------------------------------------------------------------------------- |
| 234 | _prompt_confirm_indexing() { |
| 235 | local project_root="$1" |
| 236 | local reply |
| 237 | |
| 238 | if ! (: </dev/tty) 2>/dev/null; then |
| 239 | printf "Notice: No interactive terminal available; enhancing without project indexing.\n" >&2 |
| 240 | return 1 |
| 241 | fi |
| 242 | |
| 243 | printf "Allow Auggie to index and use project context from '%s'? (y/N) " "$project_root" >/dev/tty |
| 244 | if ! IFS= read -r reply </dev/tty; then |
| 245 | printf "\nNotice: Unable to read confirmation; enhancing without project indexing.\n" >&2 |
| 246 | return 1 |
| 247 | fi |
| 248 | |
| 249 | case "$reply" in |
| 250 | y|Y|yes|YES|Yes) |
| 251 | return 0 |
| 252 | ;; |
| 253 | *) |
| 254 | return 1 |
| 255 | ;; |
| 256 | esac |
| 257 | } |
| 258 | |
| 259 | # ----------------------------------------------------------------------------- |
| 260 | # Internal: _enhance_prompt (Run Auggie and write only the enhanced prompt) |
| 261 | # ----------------------------------------------------------------------------- |
| 262 | _enhance_prompt() { |
| 263 | local prompt="$1" |
| 264 | local output_file="$2" |
| 265 | local run_dir |
| 266 | local prompt_file |
| 267 | local workspace |
| 268 | local cache_dir |
| 269 | local auth_file |
| 270 | local log_file |
| 271 | local parsed_file |
| 272 | local project_root |
| 273 | local use_project_context |
| 274 | local script_pid |
| 275 | local waited |
| 276 | local timeout_seconds |
| 277 | local monitor_status |
| 278 | |
| 279 | if [ -z "$prompt" ]; then |
| 280 | printf "Error: Prompt was empty.\n" >&2 |
| 281 | return 1 |
| 282 | fi |
| 283 | |
| 284 | if [ ! -s "$HOME/.augment/session.json" ]; then |
| 285 | printf "Error: Auggie session file not found. Run 'auggie login' first.\n" >&2 |
| 286 | return 1 |
| 287 | fi |
| 288 | |
| 289 | run_dir="$(mktemp -d "${TMPDIR:-/tmp}/auggie-enhance.XXXXXX")" || return 1 |
| 290 | prompt_file="$run_dir/prompt.txt" |
| 291 | workspace="$run_dir/workspace" |
| 292 | cache_dir="$run_dir/cache" |
| 293 | auth_file="$HOME/.augment/session.json" |
| 294 | log_file="$run_dir/auggie.log" |
| 295 | parsed_file="$run_dir/enhanced.txt" |
| 296 | mkdir -p "$workspace" "$cache_dir" |
| 297 | printf "%s\n" "$prompt" > "$prompt_file" |
| 298 | |
| 299 | project_root="$(git rev-parse --show-toplevel 2>/dev/null)" || project_root="$PWD" |
| 300 | use_project_context=0 |
| 301 | timeout_seconds=90 |
| 302 | |
| 303 | if _prompt_confirm_indexing "$project_root"; then |
| 304 | use_project_context=1 |
| 305 | timeout_seconds=300 |
| 306 | workspace="$project_root" |
| 307 | cache_dir="$HOME/.augment" |
| 308 | printf "Indexing approved; enhancing with project context from '%s'.\n" "$project_root" >&2 |
| 309 | else |
| 310 | printf "Enhancing without project indexing.\n" >&2 |
| 311 | fi |
| 312 | |
| 313 | ( |
| 314 | if [ "$use_project_context" -eq 1 ]; then |
| 315 | AUGGIE_PROMPT_FILE="$prompt_file" \ |
| 316 | AUGGIE_WORKSPACE="$workspace" \ |
| 317 | AUGGIE_CACHE_DIR="$cache_dir" \ |
| 318 | AUGGIE_AUTH_FILE="$auth_file" \ |
| 319 | script -q -f -O "$log_file" -c 'auggie --print --enhance-prompt --workspace-root "$AUGGIE_WORKSPACE" --augment-cache-dir "$AUGGIE_CACHE_DIR" --augment-session-json "$AUGGIE_AUTH_FILE" --allow-indexing --wait-for-indexing --dont-save-session --instruction-file "$AUGGIE_PROMPT_FILE"' </dev/null >/dev/null 2>&1 & |
| 320 | else |
| 321 | AUGGIE_PROMPT_FILE="$prompt_file" \ |
| 322 | AUGGIE_WORKSPACE="$workspace" \ |
| 323 | AUGGIE_CACHE_DIR="$cache_dir" \ |
| 324 | AUGGIE_AUTH_FILE="$auth_file" \ |
| 325 | script -q -f -O "$log_file" -c 'auggie --print --enhance-prompt --no-discover-workspaces --workspace-root "$AUGGIE_WORKSPACE" --augment-cache-dir "$AUGGIE_CACHE_DIR" --augment-session-json "$AUGGIE_AUTH_FILE" --dont-save-session --instruction-file "$AUGGIE_PROMPT_FILE"' </dev/null >/dev/null 2>&1 & |
| 326 | fi |
| 327 | |
| 328 | script_pid=$! |
| 329 | waited=0 |
| 330 | |
| 331 | while kill -0 "$script_pid" >/dev/null 2>&1; do |
| 332 | if grep -aq "🤖" "$log_file" 2>/dev/null || grep -aq "Tool call:" "$log_file" 2>/dev/null; then |
| 333 | kill "$script_pid" >/dev/null 2>&1 || true |
| 334 | wait "$script_pid" >/dev/null 2>&1 || true |
| 335 | exit 0 |
| 336 | fi |
| 337 | |
| 338 | if [ "$waited" -ge "$timeout_seconds" ]; then |
| 339 | kill "$script_pid" >/dev/null 2>&1 || true |
| 340 | wait "$script_pid" >/dev/null 2>&1 || true |
| 341 | exit 124 |
| 342 | fi |
| 343 | |
| 344 | sleep 1 |
| 345 | waited=$((waited + 1)) |
| 346 | done |
| 347 | |
| 348 | wait "$script_pid" >/dev/null 2>&1 || true |
| 349 | exit 0 |
| 350 | ) |
| 351 | monitor_status=$? |
| 352 | |
| 353 | if [ "$monitor_status" -eq 124 ]; then |
| 354 | printf "Error: Timed out waiting for Auggie to enhance the prompt.\n" >&2 |
| 355 | rm -rf "$run_dir" |
| 356 | return 124 |
| 357 | fi |
| 358 | |
| 359 | perl -ne ' |
| 360 | # Auggie redraws streamed terminal lines with carriage returns. Keep only |
| 361 | # the final rendered segment so stale text cannot corrupt the prompt. |
| 362 | s/\r$//; |
| 363 | s/.*\r//; |
| 364 | 1 while s/[^\x08]\x08//g; |
| 365 | s/\x08//g; |
| 366 | s/\e\][^\a]*(?:\a|\e\\)//g; |
| 367 | s/\e\[[0-?]*[ -\/]*[@-~]//g; |
| 368 | next if /Script started on/ || /Script done on/; |
| 369 | if (/^(?:✨\s*)?Enhanced prompt:\s*(.*)$/) { |
| 370 | $capturing = 1; |
| 371 | $output .= "$1\n" if length $1; |
| 372 | next; |
| 373 | } |
| 374 | next unless $capturing; |
| 375 | exit if /^🤖/ || /Tool call:/ || /Session terminated/; |
| 376 | $output .= $_; |
| 377 | END { |
| 378 | $output =~ s/^\s*\n//; |
| 379 | $output =~ s/\s+\z//; |
| 380 | print "$output\n" if length $output; |
| 381 | } |
| 382 | ' "$log_file" > "$parsed_file" |
| 383 | |
| 384 | if [ ! -s "$parsed_file" ]; then |
| 385 | printf "Error: Auggie did not return an enhanced prompt.\n" >&2 |
| 386 | rm -rf "$run_dir" |
| 387 | return 1 |
| 388 | fi |
| 389 | |
| 390 | if ! command cp "$parsed_file" "$output_file"; then |
| 391 | printf "Error: Unable to save the enhanced prompt.\n" >&2 |
| 392 | rm -rf "$run_dir" |
| 393 | return 1 |
| 394 | fi |
| 395 | rm -rf "$run_dir" |
| 396 | } |
| 397 | |
| 398 | # ----------------------------------------------------------------------------- |
| 399 | # Function: prompt (Enhance a prompt with Auggie and optional project context) |
| 400 | # ----------------------------------------------------------------------------- |
| 401 | prompt() { |
| 402 | local prompt |
| 403 | local line |
| 404 | local output_file |
| 405 | local input_keymap |
| 406 | local input_status |
| 407 | |
| 408 | require_cli auggie "Auggie CLI" || return 1 |
| 409 | require_cli script "script utility" || return 1 |
| 410 | |
| 411 | if [ $# -gt 0 ]; then |
| 412 | prompt="$*" |
| 413 | elif [ ! -t 0 ]; then |
| 414 | prompt="$(cat)" |
| 415 | elif [ -n "${ZSH_VERSION:-}" ]; then |
| 416 | # Keep all lines in one ZLE buffer so previous lines remain editable. |
| 417 | input_keymap="auggie-prompt-$$-$RANDOM" |
| 418 | bindkey -N "$input_keymap" emacs || return 1 |
| 419 | bindkey -M "$input_keymap" '^J' self-insert |
| 420 | bindkey -M "$input_keymap" '^M' self-insert-unmeta |
| 421 | bindkey -M "$input_keymap" '^[[13;2u' accept-line |
| 422 | bindkey -M "$input_keymap" '^[[27;2;13~' accept-line |
| 423 | bindkey -M "$input_keymap" '^D' accept-line |
| 424 | printf 'Enter your prompt: Enter adds a line; Shift+Enter (configured terminal) or Ctrl+D submits; Ctrl+C cancels.\n' |
| 425 | prompt="" |
| 426 | vared -M "$input_keymap" -p '> ' prompt |
| 427 | input_status=$? |
| 428 | bindkey -D "$input_keymap" |
| 429 | [ "$input_status" -eq 0 ] || return "$input_status" |
| 430 | else |
| 431 | echo "Reading prompt... Type 'EOF' on a new line and press Enter when finished." |
| 432 | prompt="" |
| 433 | |
| 434 | while IFS= read -r line; do |
| 435 | [ "$line" = "EOF" ] && break |
| 436 | if [ -z "$prompt" ]; then |
| 437 | prompt="$line" |
| 438 | else |
| 439 | prompt="${prompt}"$'\n'"${line}" |
| 440 | fi |
| 441 | done |
| 442 | fi |
| 443 | |
| 444 | if [ -z "$prompt" ]; then |
| 445 | printf "Error: Prompt was empty.\n" >&2 |
| 446 | return 1 |
| 447 | fi |
| 448 | |
| 449 | output_file="$(mktemp "${TMPDIR:-/tmp}/auggie-enhanced-prompt.XXXXXX")" || return 1 |
| 450 | if ! _enhance_prompt "$prompt" "$output_file"; then |
| 451 | rm -f "$output_file" |
| 452 | return 1 |
| 453 | fi |
| 454 | |
| 455 | if [ -t 1 ]; then |
| 456 | clear |
| 457 | fi |
| 458 | |
| 459 | printf "Enhanced prompt:\n" |
| 460 | command cat "$output_file" |
| 461 | if clip < "$output_file"; then |
| 462 | printf '\nEnhanced prompt copied to clipboard.\n' >&2 |
| 463 | else |
| 464 | printf '\nUnable to copy to clipboard; the enhanced prompt is shown above.\n' >&2 |
| 465 | fi |
| 466 | rm -f "$output_file" |
| 467 | |
| 468 | command auggie account status |
| 469 | } |
| 470 | |
| 471 | # ----------------------------------------------------------------------------- |
| 472 | # Function: plan_build (Cached canonical plan-build launcher) |
| 473 | # ----------------------------------------------------------------------------- |
| 474 | plan_build() { |
| 475 | emulate -L zsh |
| 476 | setopt localtraps |
| 477 | local base_url="${PLAN_BUILD_BASE_URL:-https://opengist.resetrix.work/weehong/plan-build/raw/HEAD}" |
| 478 | local cache_dir="${PLAN_BUILD_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/plan-build}" |
| 479 | local curl_command="${PLAN_BUILD_CURL_COMMAND:-curl}" |
| 480 | local skill_file="$HOME/.claude/skills/plan-build/SKILL.md" |
| 481 | local architect_file="$HOME/.claude/skills/plan-build-architect/SKILL.md" |
| 482 | local stage_dir="" release_dir="" current_tmp="" |
| 483 | local skill_tmp="" architect_tmp="" skill_backup="" architect_backup="" |
| 484 | local activation_lock="$cache_dir/.activate.lock" |
| 485 | local activation_lock_fd="" |
| 486 | local selected_release="" active_pid="" refresh_rc=1 call_rc=0 interrupted_rc=0 |
| 487 | |
| 488 | if ! zmodload zsh/system 2>/dev/null; then |
| 489 | printf "Error: Unable to load the Zsh system module required by plan-build.\n" >&2 |
| 490 | return 1 |
| 491 | fi |
| 492 | |
| 493 | require_cli zsh "Zsh" || return 1 |
| 494 | |
| 495 | if ! command mkdir -p "$cache_dir" "${skill_file:h}" "${architect_file:h}"; then |
| 496 | printf "Error: Unable to create plan-build cache or skill directories.\n" >&2 |
| 497 | return 1 |
| 498 | fi |
| 499 | |
| 500 | _plan_build_cleanup() { |
| 501 | if [[ -n "${active_pid:-}" ]] && command kill -0 "$active_pid" 2>/dev/null; then |
| 502 | command kill "$active_pid" 2>/dev/null || true |
| 503 | wait "$active_pid" 2>/dev/null || true |
| 504 | fi |
| 505 | active_pid="" |
| 506 | [[ -n "$stage_dir" && -d "$stage_dir" ]] && command rm -rf -- "$stage_dir" |
| 507 | command rm -f -- "$current_tmp" "$skill_tmp" "$architect_tmp" |
| 508 | command rm -f -- "$skill_backup" "$architect_backup" |
| 509 | if [[ -n "${activation_lock_fd:-}" ]]; then |
| 510 | zsystem flock -u "$activation_lock_fd" 2>/dev/null || true |
| 511 | activation_lock_fd="" |
| 512 | fi |
| 513 | } |
| 514 | _plan_build_cancel() { |
| 515 | interrupted_rc="$1" |
| 516 | [[ -n "${active_pid:-}" ]] && command kill "$active_pid" 2>/dev/null || true |
| 517 | } |
| 518 | trap '_plan_build_cleanup' EXIT |
| 519 | trap '_plan_build_cancel 129' HUP |
| 520 | trap '_plan_build_cancel 130' INT |
| 521 | trap '_plan_build_cancel 143' TERM |
| 522 | |
| 523 | _plan_build_validate_release() { |
| 524 | local candidate="$1" |
| 525 | [[ -d "$candidate" && |
| 526 | -s "$candidate/plan_build.zsh" && |
| 527 | -s "$candidate/SKILL.md" && |
| 528 | -s "$candidate/ARCHITECT.md" ]] && |
| 529 | command zsh -n "$candidate/plan_build.zsh" |
| 530 | } |
| 531 | |
| 532 | _plan_build_replace_path() { |
| 533 | local replacement="$1" destination="$2" |
| 534 | if command mv -fT "$replacement" "$destination" 2>/dev/null; then |
| 535 | return 0 |
| 536 | fi |
| 537 | command mv -fh "$replacement" "$destination" |
| 538 | } |
| 539 | |
| 540 | _plan_build_select_current() { |
| 541 | local resolved |
| 542 | [[ -L "$cache_dir/current" ]] || return 1 |
| 543 | resolved="$cache_dir/current" |
| 544 | resolved="${resolved:A}" |
| 545 | _plan_build_validate_release "$resolved" || return 1 |
| 546 | selected_release="$resolved" |
| 547 | } |
| 548 | |
| 549 | _plan_build_acquire_activation_lock() { |
| 550 | (( interrupted_rc )) && return 1 |
| 551 | command touch "$activation_lock" || return 1 |
| 552 | zsystem flock -t 10 -f activation_lock_fd "$activation_lock" |
| 553 | } |
| 554 | |
| 555 | _plan_build_release_activation_lock() { |
| 556 | [[ -n "$activation_lock_fd" ]] || return 0 |
| 557 | zsystem flock -u "$activation_lock_fd" || return 1 |
| 558 | activation_lock_fd="" |
| 559 | } |
| 560 | |
| 561 | _plan_build_download() { |
| 562 | local remote="$1" destination="$2" download_rc |
| 563 | command "$curl_command" -fsSL "$remote" -o "$destination" & |
| 564 | active_pid=$! |
| 565 | wait "$active_pid" |
| 566 | download_rc=$? |
| 567 | active_pid="" |
| 568 | (( interrupted_rc )) && return "$interrupted_rc" |
| 569 | return "$download_rc" |
| 570 | } |
| 571 | |
| 572 | _plan_build_backup_path() { |
| 573 | local source="$1" backup="$2" |
| 574 | [[ -e "$source" || -L "$source" ]] || return 0 |
| 575 | command rm -f -- "$backup" && command cp -RP "$source" "$backup" |
| 576 | } |
| 577 | |
| 578 | _plan_build_restore_path() { |
| 579 | local destination="$1" backup="$2" had_old="$3" |
| 580 | if (( had_old )); then |
| 581 | _plan_build_replace_path "$backup" "$destination" || return 1 |
| 582 | else |
| 583 | command rm -f -- "$destination" |
| 584 | fi |
| 585 | } |
| 586 | |
| 587 | _plan_build_install_skill_links() { |
| 588 | local activation_ok=1 |
| 589 | local skill_had_old=0 architect_had_old=0 |
| 590 | |
| 591 | skill_tmp="$(command mktemp "${skill_file}.link.XXXXXX")" && |
| 592 | command rm -f "$skill_tmp" && |
| 593 | command ln -s "$cache_dir/current/SKILL.md" "$skill_tmp" || skill_tmp="" |
| 594 | architect_tmp="$(command mktemp "${architect_file}.link.XXXXXX")" && |
| 595 | command rm -f "$architect_tmp" && |
| 596 | command ln -s "$cache_dir/current/ARCHITECT.md" "$architect_tmp" || architect_tmp="" |
| 597 | [[ -n "$skill_tmp" && -L "$skill_tmp" && |
| 598 | -n "$architect_tmp" && -L "$architect_tmp" ]] || return 1 |
| 599 | |
| 600 | skill_backup="${skill_file}.backup.$$.$RANDOM" |
| 601 | architect_backup="${architect_file}.backup.$$.$RANDOM" |
| 602 | [[ -e "$skill_file" || -L "$skill_file" ]] && skill_had_old=1 |
| 603 | [[ -e "$architect_file" || -L "$architect_file" ]] && architect_had_old=1 |
| 604 | _plan_build_backup_path "$skill_file" "$skill_backup" || activation_ok=0 |
| 605 | (( activation_ok )) && _plan_build_backup_path "$architect_file" "$architect_backup" || activation_ok=0 |
| 606 | (( activation_ok && ! interrupted_rc )) && |
| 607 | _plan_build_replace_path "$skill_tmp" "$skill_file" || activation_ok=0 |
| 608 | (( activation_ok )) && skill_tmp="" |
| 609 | (( activation_ok && ! interrupted_rc )) && |
| 610 | _plan_build_replace_path "$architect_tmp" "$architect_file" || activation_ok=0 |
| 611 | (( activation_ok )) && architect_tmp="" |
| 612 | |
| 613 | if (( activation_ok )); then |
| 614 | command rm -f -- "$skill_backup" "$architect_backup" |
| 615 | skill_backup="" architect_backup="" |
| 616 | return 0 |
| 617 | fi |
| 618 | |
| 619 | if ! _plan_build_restore_path "$skill_file" "$skill_backup" "$skill_had_old"; then |
| 620 | printf "Error: Unable to restore plan-build skill; backup preserved at %s.\n" "$skill_backup" >&2 |
| 621 | skill_backup="" |
| 622 | fi |
| 623 | if ! _plan_build_restore_path "$architect_file" "$architect_backup" "$architect_had_old"; then |
| 624 | printf "Error: Unable to restore architect skill; backup preserved at %s.\n" "$architect_backup" >&2 |
| 625 | architect_backup="" |
| 626 | fi |
| 627 | return 1 |
| 628 | } |
| 629 | |
| 630 | if (( $+commands[$curl_command] )) || [[ "$curl_command" == */* && -x "$curl_command" ]]; then |
| 631 | stage_dir="$(command mktemp -d "$cache_dir/.stage.XXXXXX")" || stage_dir="" |
| 632 | if [[ -n "$stage_dir" ]]; then |
| 633 | _plan_build_download "$base_url/plan_build.zsh" "$stage_dir/plan_build.zsh" && |
| 634 | _plan_build_download "$base_url/SKILL.md" "$stage_dir/SKILL.md" && |
| 635 | _plan_build_download "$base_url/ARCHITECT.md" "$stage_dir/ARCHITECT.md" |
| 636 | refresh_rc=$? |
| 637 | if (( interrupted_rc )); then |
| 638 | _plan_build_cleanup |
| 639 | trap - EXIT HUP INT TERM |
| 640 | return "$interrupted_rc" |
| 641 | fi |
| 642 | else |
| 643 | refresh_rc=1 |
| 644 | fi |
| 645 | if (( refresh_rc == 0 )) && |
| 646 | _plan_build_validate_release "$stage_dir"; then |
| 647 | release_dir="$(command mktemp -d "$cache_dir/release.XXXXXX")" || release_dir="" |
| 648 | if [[ -n "$release_dir" ]] && |
| 649 | command rmdir "$release_dir" && |
| 650 | command mv "$stage_dir" "$release_dir" && |
| 651 | _plan_build_acquire_activation_lock; then |
| 652 | stage_dir="" |
| 653 | release_dir="${release_dir:A}" |
| 654 | current_tmp="$(command mktemp "$cache_dir/.current.XXXXXX")" && |
| 655 | command rm -f "$current_tmp" && |
| 656 | command ln -s "$release_dir" "$current_tmp" || current_tmp="" |
| 657 | |
| 658 | if [[ -n "$current_tmp" && -L "$current_tmp" ]] && |
| 659 | _plan_build_install_skill_links && |
| 660 | _plan_build_replace_path "$current_tmp" "$cache_dir/current"; then |
| 661 | current_tmp="" |
| 662 | selected_release="$release_dir" |
| 663 | fi |
| 664 | _plan_build_release_activation_lock || true |
| 665 | fi |
| 666 | fi |
| 667 | fi |
| 668 | |
| 669 | if (( interrupted_rc )); then |
| 670 | _plan_build_cleanup |
| 671 | trap - EXIT HUP INT TERM |
| 672 | return "$interrupted_rc" |
| 673 | fi |
| 674 | |
| 675 | if [[ -z "$selected_release" ]]; then |
| 676 | if ! _plan_build_select_current; then |
| 677 | if (( ! $+commands[$curl_command] )) && [[ ! ( "$curl_command" == */* && -x "$curl_command" ) ]]; then |
| 678 | printf "Error: curl is required because no valid cached plan-build bundle is available.\n" >&2 |
| 679 | else |
| 680 | printf "Error: Unable to refresh plan-build and no valid cached bundle is available.\n" >&2 |
| 681 | fi |
| 682 | return 1 |
| 683 | fi |
| 684 | if (( ! $+commands[$curl_command] )) && [[ ! ( "$curl_command" == */* && -x "$curl_command" ) ]]; then |
| 685 | printf "Warning: curl is unavailable; using the validated cached plan-build bundle.\n" >&2 |
| 686 | else |
| 687 | printf "Warning: Unable to refresh plan-build; using the validated cached bundle.\n" >&2 |
| 688 | fi |
| 689 | fi |
| 690 | |
| 691 | if [[ "$(command readlink "$skill_file" 2>/dev/null)" != "$cache_dir/current/SKILL.md" || |
| 692 | "$(command readlink "$architect_file" 2>/dev/null)" != "$cache_dir/current/ARCHITECT.md" ]]; then |
| 693 | if ! _plan_build_acquire_activation_lock || ! _plan_build_install_skill_links; then |
| 694 | _plan_build_release_activation_lock 2>/dev/null || true |
| 695 | if (( interrupted_rc )); then |
| 696 | _plan_build_cleanup |
| 697 | trap - EXIT HUP INT TERM |
| 698 | return "$interrupted_rc" |
| 699 | fi |
| 700 | printf "Error: Unable to install the plan-build skill links.\n" >&2 |
| 701 | return 1 |
| 702 | fi |
| 703 | _plan_build_release_activation_lock || true |
| 704 | fi |
| 705 | |
| 706 | if (( interrupted_rc )); then |
| 707 | _plan_build_cleanup |
| 708 | trap - EXIT HUP INT TERM |
| 709 | return "$interrupted_rc" |
| 710 | fi |
| 711 | |
| 712 | PLAN_BUILD_SKILL_PATH="$selected_release/SKILL.md" \ |
| 713 | PLAN_BUILD_ARCHITECT_SKILL_PATH="$selected_release/ARCHITECT.md" \ |
| 714 | command zsh "$selected_release/plan_build.zsh" "$@" & |
| 715 | active_pid=$! |
| 716 | wait "$active_pid" |
| 717 | call_rc=$? |
| 718 | active_pid="" |
| 719 | (( interrupted_rc )) && call_rc="$interrupted_rc" |
| 720 | _plan_build_cleanup |
| 721 | trap - EXIT HUP INT TERM |
| 722 | return "$call_rc" |
| 723 | } |
| 724 | |
| 725 | # ----------------------------------------------------------------------------- |
| 726 | # Internal: _aicommit_usage (Print aicommit usage) |
| 727 | # ----------------------------------------------------------------------------- |
| 728 | _aicommit_usage() { |
| 729 | printf "Usage: aicommit [--modules] [--yes] [claude | copilot | opencode]\n" |
| 730 | printf " --modules Force per-module commits (automatic above 30 staged paths)\n" |
| 731 | printf " --yes Skip the plan confirmation for per-module commits\n" |
| 732 | } |
| 733 | |
| 734 | # ----------------------------------------------------------------------------- |
| 735 | # Internal: _aicommit_staged_diff (Staged diff without lock/minified files) |
| 736 | # ----------------------------------------------------------------------------- |
| 737 | _aicommit_staged_diff() { |
| 738 | # Exclude lock files/minified files and cap at ~100k characters to prevent CLI crashes |
| 739 | git diff --cached -- . ":(exclude)*.lock" ":(exclude)*-lock.json" ":(exclude)*.min.js" | head -c 100000 |
| 740 | } |
| 741 | |
| 742 | # ----------------------------------------------------------------------------- |
| 743 | # Internal: _aicommit_engine (Ask an AI CLI to commit or to plan a split) |
| 744 | # ----------------------------------------------------------------------------- |
| 745 | _aicommit_engine() { |
| 746 | local engine="$1" |
| 747 | local mode="$2" |
| 748 | local input="$3" |
| 749 | local name |
| 750 | |
| 751 | case "$engine" in |
| 752 | claude) name="Claude" ;; |
| 753 | copilot) name="Copilot" ;; |
| 754 | opencode) name="OpenCode" ;; |
| 755 | esac |
| 756 | |
| 757 | # Status goes to stderr so plan output on stdout stays clean JSON. |
| 758 | if [ "$mode" = "plan" ]; then |
| 759 | printf "🧩 %s is planning per-module commits...\n" "$name" >&2 |
| 760 | else |
| 761 | printf "🤖 %s is analyzing staged changes...\n" "$name" >&2 |
| 762 | fi |
| 763 | |
| 764 | case "$engine:$mode" in |
| 765 | claude:commit) |
| 766 | printf "%s\n" "$input" \ |
| 767 | | command claude --allowedTools 'Bash(git commit *)' --permission-mode dontAsk -p \ |
| 768 | "Read the provided instructions and diff, then generate and execute the commit." |
| 769 | ;; |
| 770 | claude:plan) |
| 771 | printf "%s\n" "$input" \ |
| 772 | | command claude --permission-mode dontAsk -p \ |
| 773 | "Read the provided instructions and staged changes, then reply with only the JSON plan." |
| 774 | ;; |
| 775 | copilot:commit) |
| 776 | copilot -p "$input" -s --no-ask-user --allow-tool='shell(git:*)' |
| 777 | ;; |
| 778 | copilot:plan) |
| 779 | copilot -p "$input" -s --no-ask-user |
| 780 | ;; |
| 781 | opencode:commit) |
| 782 | printf "%s\n" "$input" | opencode run --agent ai-commit |
| 783 | ;; |
| 784 | opencode:plan) |
| 785 | printf "%s\n" "$input" | opencode run --agent ai-commit-plan |
| 786 | ;; |
| 787 | esac |
| 788 | } |
| 789 | |
| 790 | # ----------------------------------------------------------------------------- |
| 791 | # Internal: _aicommit_restage (Stage HEAD plus plan groups first..last from a tree) |
| 792 | # ----------------------------------------------------------------------------- |
| 793 | _aicommit_restage() { |
| 794 | local tree="$1" |
| 795 | local run_dir="$2" |
| 796 | local first="$3" |
| 797 | local last="$4" |
| 798 | local number |
| 799 | |
| 800 | if git rev-parse -q --verify HEAD >/dev/null; then |
| 801 | git read-tree HEAD || return 1 |
| 802 | else |
| 803 | git read-tree --empty || return 1 |
| 804 | fi |
| 805 | |
| 806 | [ "$first" -le "$last" ] || return 0 |
| 807 | |
| 808 | number="$first" |
| 809 | while [ "$number" -le "$last" ]; do |
| 810 | cat "$run_dir/$(printf "%03d" "$number").files" |
| 811 | number=$((number + 1)) |
| 812 | done | git --literal-pathspecs restore --staged --source="$tree" --pathspec-from-file=- --pathspec-file-nul |
| 813 | } |
| 814 | |
| 815 | # ----------------------------------------------------------------------------- |
| 816 | # Internal: _aicommit_plan (Validate the AI split plan into per-group files) |
| 817 | # ----------------------------------------------------------------------------- |
| 818 | _aicommit_plan() { |
| 819 | local run_dir="$1" |
| 820 | |
| 821 | python3 -c ' |
| 822 | import json, os, re, sys |
| 823 | |
| 824 | sys.stdout.reconfigure(errors="backslashreplace") |
| 825 | sys.stderr.reconfigure(errors="backslashreplace") |
| 826 | run_dir = sys.argv[1] |
| 827 | types = {"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore"} |
| 828 | |
| 829 | with open(os.path.join(run_dir, "staged"), "rb") as fh: |
| 830 | staged = [p for p in fh.read().decode("utf-8", "surrogateescape").split("\0") if p] |
| 831 | with open(os.path.join(run_dir, "plan.out"), encoding="utf-8", errors="replace") as fh: |
| 832 | raw = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", fh.read()) |
| 833 | |
| 834 | # Take the last {"commits": [...]} object in the reply, ignoring fences and prose. |
| 835 | plan, decoder, pos = None, json.JSONDecoder(), raw.find("{") |
| 836 | while pos != -1: |
| 837 | try: |
| 838 | obj, end = decoder.raw_decode(raw, pos) |
| 839 | except ValueError: |
| 840 | pos = raw.find("{", pos + 1) |
| 841 | continue |
| 842 | if isinstance(obj, dict) and isinstance(obj.get("commits"), list): |
| 843 | plan = obj |
| 844 | pos = raw.find("{", end) |
| 845 | else: |
| 846 | pos = raw.find("{", pos + 1) |
| 847 | |
| 848 | if plan is None: |
| 849 | sys.exit("❌ Error: The AI reply did not contain a JSON commit plan.") |
| 850 | |
| 851 | # Every staged path must land in exactly one group; anything unassigned goes last. |
| 852 | staged_set, seen, groups, warnings = set(staged), set(), [], [] |
| 853 | for commit in plan["commits"]: |
| 854 | if not isinstance(commit, dict) or not isinstance(commit.get("files"), list): |
| 855 | continue |
| 856 | files = [] |
| 857 | for path in commit["files"]: |
| 858 | if not isinstance(path, str): |
| 859 | continue |
| 860 | if path not in staged_set: |
| 861 | warnings.append("ignoring path that is not staged: " + path) |
| 862 | elif path in seen: |
| 863 | warnings.append("ignoring duplicate path: " + path) |
| 864 | else: |
| 865 | seen.add(path) |
| 866 | files.append(path) |
| 867 | if not files: |
| 868 | continue |
| 869 | kind = str(commit.get("type", "")).strip().lower() |
| 870 | scope = re.sub(r"[^a-z0-9._/-]", "", str(commit.get("scope", "")).lower()) |
| 871 | label = "" |
| 872 | if kind in types: |
| 873 | label = kind + ("(" + scope + ")" if scope else "") |
| 874 | groups.append((label, files)) |
| 875 | |
| 876 | missing = [p for p in staged if p not in seen] |
| 877 | if missing: |
| 878 | warnings.append("%d staged path(s) were not planned; adding them as a final commit" % len(missing)) |
| 879 | groups.append(("", missing)) |
| 880 | |
| 881 | if len(staged) > 30 and len(groups) < 2: |
| 882 | sys.exit("❌ Error: Changes affecting more than 30 staged paths require multiple module commits.") |
| 883 | |
| 884 | for number, (label, files) in enumerate(groups, 1): |
| 885 | base = os.path.join(run_dir, "%03d" % number) |
| 886 | with open(base + ".files", "wb") as fh: |
| 887 | fh.write("".join(p + "\0" for p in files).encode("utf-8", "surrogateescape")) |
| 888 | with open(base + ".label", "w") as fh: |
| 889 | fh.write(label) |
| 890 | with open(os.path.join(run_dir, "count"), "w") as fh: |
| 891 | fh.write(str(len(groups))) |
| 892 | |
| 893 | for warning in warnings: |
| 894 | print("⚠️ " + warning, file=sys.stderr) |
| 895 | print("📋 Planned commits:") |
| 896 | for number, (label, files) in enumerate(groups, 1): |
| 897 | print(" %d. %s (%d file%s)" % (number, label or "unplanned", len(files), "" if len(files) == 1 else "s")) |
| 898 | for path in files: |
| 899 | print(" " + path) |
| 900 | ' "$run_dir" |
| 901 | } |
| 902 | |
| 903 | # ----------------------------------------------------------------------------- |
| 904 | # Internal: _aicommit_modules_run (Plan, confirm, and commit each module group) |
| 905 | # ----------------------------------------------------------------------------- |
| 906 | _aicommit_modules_run() { |
| 907 | local engine="$1" |
| 908 | local assume_yes="$2" |
| 909 | local run_dir="$3" |
| 910 | local prompt_file="$HOME/.config/ai-commit-prompt.txt" |
| 911 | local split_prompt_file="$HOME/.config/ai-commit-split-prompt.txt" |
| 912 | local tree |
| 913 | local total |
| 914 | local current |
| 915 | local group |
| 916 | local label |
| 917 | local hint |
| 918 | local diff |
| 919 | local expected |
| 920 | local head_before |
| 921 | local head_after |
| 922 | local unexpected |
| 923 | local reply |
| 924 | |
| 925 | if ! tree="$(git write-tree 2>/dev/null)"; then |
| 926 | printf "❌ Error: Unable to snapshot the index (unresolved conflicts?).\n" >&2 |
| 927 | return 1 |
| 928 | fi |
| 929 | |
| 930 | git diff --cached --name-only --no-renames -z > "$run_dir/staged" || return 1 |
| 931 | if [ ! -s "$run_dir/staged" ]; then |
| 932 | printf "❌ Error: Nothing staged to commit.\n" >&2 |
| 933 | return 1 |
| 934 | fi |
| 935 | |
| 936 | _aicommit_engine "$engine" plan "$(cat "$split_prompt_file") |
| 937 | |
| 938 | Here are the staged files: |
| 939 | $(git -c core.quotePath=false diff --cached --name-status -M) |
| 940 | |
| 941 | Here is the diffstat: |
| 942 | $(git -c core.quotePath=false diff --cached --numstat -M) |
| 943 | |
| 944 | Here is the git diff: |
| 945 | $(_aicommit_staged_diff)" > "$run_dir/plan.out" |
| 946 | |
| 947 | if ! _aicommit_plan "$run_dir"; then |
| 948 | printf "Last lines of the AI reply:\n" >&2 |
| 949 | tail -n 20 "$run_dir/plan.out" >&2 |
| 950 | return 1 |
| 951 | fi |
| 952 | total="$(cat "$run_dir/count")" |
| 953 | |
| 954 | if [ "$assume_yes" -ne 1 ]; then |
| 955 | if ! (: </dev/tty) 2>/dev/null; then |
| 956 | printf "❌ Error: No interactive terminal to confirm the plan; rerun with --yes.\n" >&2 |
| 957 | return 1 |
| 958 | fi |
| 959 | printf "Create these %s commit(s)? (Y/n) " "$total" >/dev/tty |
| 960 | IFS= read -r reply </dev/tty || reply="n" |
| 961 | case "$reply" in |
| 962 | ""|y|Y|yes|YES|Yes) ;; |
| 963 | *) |
| 964 | printf "Aborted. Staged changes are untouched.\n" |
| 965 | return 1 |
| 966 | ;; |
| 967 | esac |
| 968 | fi |
| 969 | |
| 970 | printf "💾 Staged snapshot: %s (recover with: git read-tree %s)\n" "$tree" "$tree" |
| 971 | current=1 |
| 972 | 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 |
| 973 | |
| 974 | while [ "$current" -le "$total" ]; do |
| 975 | group="$run_dir/$(printf "%03d" "$current").files" |
| 976 | label="$(cat "$run_dir/$(printf "%03d" "$current").label")" |
| 977 | |
| 978 | if ! _aicommit_restage "$tree" "$run_dir" "$current" "$current"; then |
| 979 | printf "❌ Error: Unable to stage commit %s/%s.\n" "$current" "$total" >&2 |
| 980 | _aicommit_restage "$tree" "$run_dir" "$current" "$total" |
| 981 | return 1 |
| 982 | fi |
| 983 | |
| 984 | expected="$(git write-tree)" |
| 985 | head_before="$(git rev-parse -q --verify HEAD)" |
| 986 | diff="$(_aicommit_staged_diff)" |
| 987 | [ -n "$diff" ] || diff="$(git diff --cached --stat)" |
| 988 | |
| 989 | hint="This is commit $current of $total, split by module." |
| 990 | if [ -n "$label" ]; then |
| 991 | hint="$hint The planned type and scope is $label; use it unless the diff clearly calls for another." |
| 992 | fi |
| 993 | |
| 994 | printf "\n[%s/%s] %s\n" "$current" "$total" "${label:-unplanned}" |
| 995 | _aicommit_engine "$engine" commit "$(cat "$prompt_file") |
| 996 | |
| 997 | $hint |
| 998 | |
| 999 | Here is the git diff: |
| 1000 | $diff" |
| 1001 | |
| 1002 | head_after="$(git rev-parse -q --verify HEAD)" |
| 1003 | if [ -z "$head_after" ] || [ "$head_after" = "$head_before" ]; then |
| 1004 | printf "❌ Error: No commit was created for group %s/%s.\n" "$current" "$total" >&2 |
| 1005 | _aicommit_restage "$tree" "$run_dir" "$current" "$total" |
| 1006 | printf "Uncommitted groups are staged again.\n" >&2 |
| 1007 | return 1 |
| 1008 | fi |
| 1009 | |
| 1010 | # Hooks may reformat this group's files, but nothing outside it may sneak in. |
| 1011 | unexpected="$(LC_ALL=C comm -13 \ |
| 1012 | <(tr '\0' '\n' < "$group" | LC_ALL=C sort) \ |
| 1013 | <(git diff --name-only --no-renames -z "$expected" HEAD | tr '\0' '\n' | LC_ALL=C sort))" |
| 1014 | if [ -n "$unexpected" ]; then |
| 1015 | printf "❌ Error: Commit %s/%s included files outside its group:\n%s\n" "$current" "$total" "$unexpected" >&2 |
| 1016 | _aicommit_restage "$tree" "$run_dir" "$((current + 1))" "$total" |
| 1017 | printf "Inspect it with 'git show'; undo it with 'git reset --soft HEAD~1'.\n" >&2 |
| 1018 | return 1 |
| 1019 | fi |
| 1020 | |
| 1021 | current=$((current + 1)) |
| 1022 | done |
| 1023 | |
| 1024 | trap - INT TERM |
| 1025 | printf "\n✅ Created %s commit(s):\n" "$total" |
| 1026 | git --no-pager log --oneline -n "$total" |
| 1027 | } |
| 1028 | |
| 1029 | # ----------------------------------------------------------------------------- |
| 1030 | # Internal: _aicommit_modules (Split staged changes into per-module commits) |
| 1031 | # ----------------------------------------------------------------------------- |
| 1032 | _aicommit_modules() { |
| 1033 | local engine="$1" |
| 1034 | local assume_yes="$2" |
| 1035 | local top |
| 1036 | local run_dir |
| 1037 | local rc |
| 1038 | |
| 1039 | if [ ! -f "$HOME/.config/ai-commit-split-prompt.txt" ]; then |
| 1040 | printf "❌ Error: Configuration file not found at ~/.config/ai-commit-split-prompt.txt\n" >&2 |
| 1041 | return 1 |
| 1042 | fi |
| 1043 | |
| 1044 | require_cli python3 "Python 3" || return 1 |
| 1045 | |
| 1046 | if ! top="$(git rev-parse --show-toplevel 2>/dev/null)"; then |
| 1047 | printf "❌ Error: Not inside a git work tree.\n" >&2 |
| 1048 | return 1 |
| 1049 | fi |
| 1050 | |
| 1051 | run_dir="$(mktemp -d "${TMPDIR:-/tmp}/aicommit.XXXXXX")" || return 1 |
| 1052 | |
| 1053 | # Subshell: run from the repo root and keep the traps away from the caller. |
| 1054 | ( |
| 1055 | trap 'rm -rf "$run_dir"; exit 130' INT TERM |
| 1056 | cd "$top" || exit 1 |
| 1057 | _aicommit_modules_run "$engine" "$assume_yes" "$run_dir" |
| 1058 | ) |
| 1059 | rc=$? |
| 1060 | rm -rf "$run_dir" |
| 1061 | return "$rc" |
| 1062 | } |
| 1063 | |
| 1064 | # ----------------------------------------------------------------------------- |
| 1065 | # Function: aicommit (Automated Conventional Commit Engine Wrapper) |
| 1066 | # ----------------------------------------------------------------------------- |
| 1067 | aicommit() { |
| 1068 | local engine="claude" |
| 1069 | local modules=0 |
| 1070 | local assume_yes=0 |
| 1071 | local arg |
| 1072 | local diff |
| 1073 | local staged_count |
| 1074 | local prompt_file="$HOME/.config/ai-commit-prompt.txt" |
| 1075 | |
| 1076 | for arg in "$@"; do |
| 1077 | case "$arg" in |
| 1078 | claude|copilot|opencode) engine="$arg" ;; |
| 1079 | --modules) modules=1 ;; |
| 1080 | -y|--yes) assume_yes=1 ;; |
| 1081 | -h|--help) |
| 1082 | _aicommit_usage |
| 1083 | return 0 |
| 1084 | ;; |
| 1085 | *) |
| 1086 | printf "❌ Error: Invalid argument '%s'\n" "$arg" >&2 |
| 1087 | _aicommit_usage >&2 |
| 1088 | return 1 |
| 1089 | ;; |
| 1090 | esac |
| 1091 | done |
| 1092 | |
| 1093 | if [ ! -f "$prompt_file" ]; then |
| 1094 | printf "❌ Error: Configuration file not found at ~/.config/ai-commit-prompt.txt\n" >&2 |
| 1095 | printf "Please create it and paste your system prompt inside.\n" >&2 |
| 1096 | return 1 |
| 1097 | fi |
| 1098 | |
| 1099 | # Count the full index, including excluded previews and both sides of renames. |
| 1100 | # NUL delimiters keep whitespace/newlines in filenames from skewing the count. |
| 1101 | staged_count="$(set -o pipefail; git diff --cached --name-only --no-renames -z | tr -cd '\0' | wc -c)" || return 1 |
| 1102 | if [ "$staged_count" -gt 30 ] && [ "$modules" -ne 1 ]; then |
| 1103 | printf "🧩 %s staged paths exceed 30; splitting commits by module.\n" "$staged_count" >&2 |
| 1104 | fi |
| 1105 | if [ "$modules" -eq 1 ] || [ "$staged_count" -gt 30 ]; then |
| 1106 | _aicommit_modules "$engine" "$assume_yes" |
| 1107 | return |
| 1108 | fi |
| 1109 | |
| 1110 | diff="$(_aicommit_staged_diff)" |
| 1111 | if [ -z "$diff" ]; then |
| 1112 | printf "❌ Error: Nothing staged to commit.\n" >&2 |
| 1113 | return 1 |
| 1114 | fi |
| 1115 | |
| 1116 | _aicommit_engine "$engine" commit "$(cat "$prompt_file") |
| 1117 | |
| 1118 | Staged path count: $staged_count |
| 1119 | |
| 1120 | Here is the git diff: |
| 1121 | $diff" |
| 1122 | } |
| 1123 | |
| 1124 | # ----------------------------------------------------------------------------- |
| 1125 | # Function: create (Efficiently create file + parent dirs) |
| 1126 | # ----------------------------------------------------------------------------- |
| 1127 | create() { |
| 1128 | if ! command -v install >/dev/null 2>&1; then |
| 1129 | printf "Error: 'install' coreutil is not available.\n" >&2 |
| 1130 | return 1 |
| 1131 | fi |
| 1132 | |
| 1133 | if [ $# -eq 0 ]; then |
| 1134 | printf "Usage: create <file> [file ...]\n" >&2 |
| 1135 | return 2 |
| 1136 | fi |
| 1137 | |
| 1138 | for p in "$@"; do |
| 1139 | # Expand tilde manually if shell doesn't |
| 1140 | [[ "$p" == "~/"* ]] && p="${HOME}/${p#\~/}" |
| 1141 | |
| 1142 | if [ -z "$p" ] || [ "$p" = "/" ]; then |
| 1143 | printf "create: refusing to operate on '%s'\n" "$p" >&2 |
| 1144 | continue |
| 1145 | fi |
| 1146 | |
| 1147 | if [ -e "$p" ]; then |
| 1148 | printf "create: '%s' already exists. Skipping.\n" "$p" >&2 |
| 1149 | continue |
| 1150 | fi |
| 1151 | |
| 1152 | if install -D /dev/null "$p"; then |
| 1153 | echo "Created '$p'" |
| 1154 | fi |
| 1155 | done |
| 1156 | } |
| 1157 | |
| 1158 | # ----------------------------------------------------------------------------- |
| 1159 | # Function: ckp (Kill or check the process using a port) |
| 1160 | # ----------------------------------------------------------------------------- |
| 1161 | ckp() { |
| 1162 | local action="kill" |
| 1163 | local signal="TERM" |
| 1164 | local port |
| 1165 | local pids |
| 1166 | local pid |
| 1167 | |
| 1168 | case "${1:-}" in |
| 1169 | -c|--check) |
| 1170 | action="check" |
| 1171 | shift |
| 1172 | ;; |
| 1173 | -k|--kill) |
| 1174 | action="kill" |
| 1175 | shift |
| 1176 | ;; |
| 1177 | -f|--force) |
| 1178 | action="kill" |
| 1179 | signal="KILL" |
| 1180 | shift |
| 1181 | ;; |
| 1182 | -h|--help|"") |
| 1183 | printf "Usage: ckp [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2 |
| 1184 | return 1 |
| 1185 | ;; |
| 1186 | esac |
| 1187 | |
| 1188 | port="${1:-}" |
| 1189 | if [[ ! "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then |
| 1190 | printf "Error: port must be a number between 1 and 65535.\n" >&2 |
| 1191 | printf "Usage: ckp [--check|-c|--kill|-k|--force|-f] <port_number>\n" >&2 |
| 1192 | return 1 |
| 1193 | fi |
| 1194 | |
| 1195 | if [[ "$OSTYPE" == "darwin"* ]]; then |
| 1196 | pids="$( |
| 1197 | { |
| 1198 | lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null |
| 1199 | lsof -nP -tiUDP:"$port" 2>/dev/null |
| 1200 | } | sort -u |
| 1201 | )" |
| 1202 | |
| 1203 | if [ -z "$pids" ]; then |
| 1204 | printf "Port %s is FREE\n" "$port" |
| 1205 | return 0 |
| 1206 | fi |
| 1207 | |
| 1208 | printf "Port %s is in use:\n" "$port" |
| 1209 | lsof -nP -iTCP:"$port" -sTCP:LISTEN -iUDP:"$port" 2>/dev/null |
| 1210 | else |
| 1211 | local port_hex |
| 1212 | local inodes |
| 1213 | |
| 1214 | port_hex="$(printf "%04X" "$port")" |
| 1215 | inodes="$( |
| 1216 | awk -v port="$port_hex" ' |
| 1217 | NR > 1 { |
| 1218 | split($2, local_addr, ":") |
| 1219 | if (toupper(local_addr[length(local_addr)]) == port && ($4 == "0A" || FILENAME ~ /udp/)) { |
| 1220 | print $10 |
| 1221 | } |
| 1222 | } |
| 1223 | ' /proc/net/tcp /proc/net/tcp6 /proc/net/udp /proc/net/udp6 2>/dev/null | sort -u |
| 1224 | )" |
| 1225 | |
| 1226 | if [ -z "$inodes" ]; then |
| 1227 | printf "Port %s is FREE\n" "$port" |
| 1228 | return 0 |
| 1229 | fi |
| 1230 | |
| 1231 | pids="" |
| 1232 | pids="$( |
| 1233 | find /proc/[0-9]*/fd -maxdepth 1 -type l -printf "%p %l\n" 2>/dev/null | |
| 1234 | awk -v inodes="$inodes" ' |
| 1235 | BEGIN { |
| 1236 | split(inodes, inode_list, " ") |
| 1237 | for (i in inode_list) { |
| 1238 | wanted["socket:[" inode_list[i] "]"] = 1 |
| 1239 | } |
| 1240 | } |
| 1241 | $2 in wanted { |
| 1242 | split($1, path, "/") |
| 1243 | print path[3] |
| 1244 | } |
| 1245 | ' | sort -u |
| 1246 | )" |
| 1247 | |
| 1248 | if [ -z "$pids" ]; then |
| 1249 | printf "Port %s is in use, but no readable process owner was found.\n" "$port" >&2 |
| 1250 | return 1 |
| 1251 | fi |
| 1252 | |
| 1253 | printf "Port %s is in use:\n" "$port" |
| 1254 | for pid in $pids; do |
| 1255 | if [ -r "/proc/$pid/cmdline" ]; then |
| 1256 | printf " PID %-8s %s\n" "$pid" "$(tr '\0' ' ' < "/proc/$pid/cmdline")" |
| 1257 | else |
| 1258 | printf " PID %s\n" "$pid" |
| 1259 | fi |
| 1260 | done |
| 1261 | fi |
| 1262 | |
| 1263 | if [ "$action" = "kill" ]; then |
| 1264 | for pid in $pids; do |
| 1265 | if kill "-$signal" "$pid" 2>/dev/null; then |
| 1266 | printf "Killed PID %s with SIG%s.\n" "$pid" "$signal" |
| 1267 | else |
| 1268 | printf "Error: failed to kill PID %s. Try with sudo or --force.\n" "$pid" >&2 |
| 1269 | return 1 |
| 1270 | fi |
| 1271 | done |
| 1272 | fi |
| 1273 | } |
| 1274 | |
| 1275 | # ----------------------------------------------------------------------------- |
| 1276 | # Function: append_path (Adds to session PATH if exists) |
| 1277 | # ----------------------------------------------------------------------------- |
| 1278 | append_path() { |
| 1279 | local dir="$1" |
| 1280 | if [ -d "$dir" ]; then |
| 1281 | case ":$PATH:" in |
| 1282 | *":$dir:"*) ;; |
| 1283 | *) PATH="$PATH:$dir" ;; |
| 1284 | esac |
| 1285 | elif [ "${SUPPRESS_WARNINGS:-0}" -ne 1 ]; then |
| 1286 | echo "Warning: $dir does not exist." >&2 |
| 1287 | fi |
| 1288 | } |
| 1289 | |
| 1290 | # ----------------------------------------------------------------------------- |
| 1291 | # Function: add_path_to_config (Fixed awk/sed injection) |
| 1292 | # ----------------------------------------------------------------------------- |
| 1293 | add_path_to_config() { |
| 1294 | local new_path="$1" |
| 1295 | local config_file="$HOME/.bashrc" |
| 1296 | [[ "$SHELL" == *"zsh"* ]] && config_file="$HOME/.zshrc" |
| 1297 | |
| 1298 | if [ -z "$new_path" ]; then |
| 1299 | printf "Usage: add_path_to_config <directory>\n" >&2 |
| 1300 | return 1 |
| 1301 | fi |
| 1302 | |
| 1303 | new_path="${new_path/#\~/$HOME}" |
| 1304 | |
| 1305 | if [ ! -d "$new_path" ]; then |
| 1306 | printf "Warning: '%s' does not exist. Add anyway? (y/N) " "$new_path" |
| 1307 | read -r REPLY |
| 1308 | [[ ! $REPLY =~ ^[Yy]$ ]] && return 1 |
| 1309 | fi |
| 1310 | |
| 1311 | # Check if already exists in file |
| 1312 | if grep -Fq "append_path \"$new_path\"" "$config_file"; then |
| 1313 | printf "Notice: Already in %s.\n" "$config_file" |
| 1314 | return 0 |
| 1315 | fi |
| 1316 | |
| 1317 | # Robust Injection: Use sed to insert before export PATH or just append |
| 1318 | if grep -q "^export PATH" "$config_file"; then |
| 1319 | sed -i.bak "/^export PATH/i append_path \"$new_path\"" "$config_file" |
| 1320 | else |
| 1321 | echo "append_path \"$new_path\"" >> "$config_file" |
| 1322 | echo "export PATH" >> "$config_file" |
| 1323 | fi |
| 1324 | |
| 1325 | printf "✅ Success: Added to %s\n" "$config_file" |
| 1326 | append_path "$new_path" && export PATH |
| 1327 | } |
| 1328 |
| 1 | # ============================================================================= |
| 2 | # ENVIRONMENT & PATH CONFIGURATION |
| 3 | # ============================================================================= |
| 4 | |
| 5 | # 1. Source functions first |
| 6 | if [[ -f "$HOME/.func" && -r "$HOME/.func" ]]; then |
| 7 | source "$HOME/.func" |
| 8 | fi |
| 9 | |
| 10 | # 2. Define Root Variables |
| 11 | export DOTNET_ROOT="$HOME/.dotnet" |
| 12 | export PNPM_HOME="$HOME/.local/share/pnpm" |
| 13 | |
| 14 | # 3. PATH INITIALIZATION |
| 15 | # ----------------------------------------------------------------------------- |
| 16 | append_path "$HOME/.local/bin" |
| 17 | append_path "$DOTNET_ROOT" |
| 18 | append_path "$DOTNET_ROOT/tools" |
| 19 | append_path "$HOME/.opencode/bin" |
| 20 | append_path "$HOME/Flutter/bin" |
| 21 | append_path "$PNPM_HOME/bin" |
| 22 | |
| 23 | # macOS specific paths |
| 24 | if [[ "$OSTYPE" == "darwin"* ]]; then |
| 25 | append_path "/Applications/Espanso.app/Contents/MacOS" |
| 26 | fi |
| 27 | |
| 28 | # Finalize PATH |
| 29 | export PATH |
| 1 | # ============================================================================= |
| 2 | # FILE: ~/.sourcerc |
| 3 | # Description: Initializes third-party package managers and external tools. |
| 4 | # ============================================================================= |
| 5 | |
| 6 | # ============================================================================= |
| 7 | # GENERAL ENV |
| 8 | # ============================================================================= |
| 9 | if [[ -f "$HOME/.local/bin/env" && -r "$HOME/.local/bin/env" ]]; then |
| 10 | source "$HOME/.local/bin/env" |
| 11 | fi |
| 12 | |
| 13 | # ============================================================================= |
| 14 | # SDKMAN (Java/Kotlin/Scala Version Manager) |
| 15 | # ============================================================================= |
| 16 | export SDKMAN_DIR="$HOME/.sdkman" |
| 17 | if [[ -f "$SDKMAN_DIR/bin/sdkman-init.sh" && -r "$SDKMAN_DIR/bin/sdkman-init.sh" ]]; then |
| 18 | source "$SDKMAN_DIR/bin/sdkman-init.sh" |
| 19 | fi |
| 20 | |
| 21 | # ============================================================================= |
| 22 | # NVM (Node Version Manager) |
| 23 | # ============================================================================= |
| 24 | export NVM_DIR="$HOME/.nvm" |
| 25 | if [[ -f "$NVM_DIR/nvm.sh" && -r "$NVM_DIR/nvm.sh" ]]; then |
| 26 | source "$NVM_DIR/nvm.sh" |
| 27 | fi |
| 28 | if [[ -f "$NVM_DIR/bash_completion" && -r "$NVM_DIR/bash_completion" ]]; then |
| 29 | source "$NVM_DIR/bash_completion" |
| 30 | fi |
| 31 | |
| 32 | # ============================================================================= |
| 33 | # GOOGLE CLOUD SDK |
| 34 | # ============================================================================= |
| 35 | if [[ -f "/opt/google-cloud-sdk/path.zsh.inc" ]]; then |
| 36 | source "/opt/google-cloud-sdk/path.zsh.inc" |
| 37 | fi |
| 38 | |
| 39 | if [[ -f "/opt/google-cloud-sdk/completion.zsh.inc" ]]; then |
| 40 | source "/opt/google-cloud-sdk/completion.zsh.inc" |
| 41 | fi |
| 42 |
| 1 | "$schema" = 'https://starship.rs/config-schema.json' |
| 2 | |
| 3 | format = """ |
| 4 | [](red)\ |
| 5 | $os\ |
| 6 | $username\ |
| 7 | [](bg:peach fg:red)\ |
| 8 | $directory\ |
| 9 | [](bg:yellow fg:peach)\ |
| 10 | $git_branch\ |
| 11 | $git_status\ |
| 12 | [](fg:yellow bg:green)\ |
| 13 | $c\ |
| 14 | $rust\ |
| 15 | $golang\ |
| 16 | $nodejs\ |
| 17 | $bun\ |
| 18 | $php\ |
| 19 | $java\ |
| 20 | $kotlin\ |
| 21 | $haskell\ |
| 22 | $python\ |
| 23 | [](fg:green bg:sapphire)\ |
| 24 | $conda\ |
| 25 | [](fg:sapphire bg:lavender)\ |
| 26 | $time\ |
| 27 | [ ](fg:lavender)\ |
| 28 | $cmd_duration\ |
| 29 | $line_break\ |
| 30 | $character""" |
| 31 | |
| 32 | palette = 'catppuccin_mocha' |
| 33 | |
| 34 | [os] |
| 35 | disabled = false |
| 36 | style = "bg:red fg:crust" |
| 37 | |
| 38 | [os.symbols] |
| 39 | Windows = "" |
| 40 | Ubuntu = "" |
| 41 | SUSE = "" |
| 42 | Raspbian = "" |
| 43 | Mint = "" |
| 44 | Macos = "" |
| 45 | Manjaro = "" |
| 46 | Linux = "" |
| 47 | Gentoo = "" |
| 48 | Fedora = "" |
| 49 | Alpine = "" |
| 50 | Amazon = "" |
| 51 | Android = "" |
| 52 | AOSC = "" |
| 53 | Arch = "" |
| 54 | Artix = "" |
| 55 | CentOS = "" |
| 56 | Debian = "" |
| 57 | Redhat = "" |
| 58 | RedHatEnterprise = "" |
| 59 | |
| 60 | [username] |
| 61 | show_always = true |
| 62 | style_user = "bg:red fg:crust" |
| 63 | style_root = "bg:red fg:crust" |
| 64 | format = '[ $user]($style)' |
| 65 | |
| 66 | [directory] |
| 67 | style = "bg:peach fg:crust" |
| 68 | format = "[ $path ]($style)" |
| 69 | truncation_length = 3 |
| 70 | truncation_symbol = "…/" |
| 71 | |
| 72 | [directory.substitutions] |
| 73 | "Documents" = " " |
| 74 | "Downloads" = " " |
| 75 | "Music" = " " |
| 76 | "Pictures" = " " |
| 77 | "Developer" = " " |
| 78 | |
| 79 | [git_branch] |
| 80 | symbol = "" |
| 81 | style = "bg:yellow" |
| 82 | format = '[[ $symbol $branch ](fg:crust bg:yellow)]($style)' |
| 83 | |
| 84 | [git_status] |
| 85 | style = "bg:yellow" |
| 86 | format = '[[($all_status$ahead_behind )](fg:crust bg:yellow)]($style)' |
| 87 | |
| 88 | [nodejs] |
| 89 | symbol = "" |
| 90 | style = "bg:green" |
| 91 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 92 | |
| 93 | [bun] |
| 94 | symbol = "" |
| 95 | style = "bg:green" |
| 96 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 97 | |
| 98 | [c] |
| 99 | symbol = " " |
| 100 | style = "bg:green" |
| 101 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 102 | |
| 103 | [rust] |
| 104 | symbol = "" |
| 105 | style = "bg:green" |
| 106 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 107 | |
| 108 | [golang] |
| 109 | symbol = "" |
| 110 | style = "bg:green" |
| 111 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 112 | |
| 113 | [php] |
| 114 | symbol = "" |
| 115 | style = "bg:green" |
| 116 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 117 | |
| 118 | [java] |
| 119 | symbol = " " |
| 120 | style = "bg:green" |
| 121 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 122 | |
| 123 | [kotlin] |
| 124 | symbol = "" |
| 125 | style = "bg:green" |
| 126 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 127 | |
| 128 | [haskell] |
| 129 | symbol = "" |
| 130 | style = "bg:green" |
| 131 | format = '[[ $symbol( $version) ](fg:crust bg:green)]($style)' |
| 132 | |
| 133 | [python] |
| 134 | symbol = "" |
| 135 | style = "bg:green" |
| 136 | format = '[[ $symbol( $version)(\(#$virtualenv\)) ](fg:crust bg:green)]($style)' |
| 137 | |
| 138 | [docker_context] |
| 139 | symbol = "" |
| 140 | style = "bg:sapphire" |
| 141 | format = '[[ $symbol( $context) ](fg:crust bg:sapphire)]($style)' |
| 142 | |
| 143 | [conda] |
| 144 | symbol = " " |
| 145 | style = "fg:crust bg:sapphire" |
| 146 | format = '[$symbol$environment ]($style)' |
| 147 | ignore_base = false |
| 148 | |
| 149 | [time] |
| 150 | disabled = false |
| 151 | time_format = "%R" |
| 152 | style = "bg:lavender" |
| 153 | format = '[[ $time ](fg:crust bg:lavender)]($style)' |
| 154 | |
| 155 | [line_break] |
| 156 | disabled = false |
| 157 | |
| 158 | [character] |
| 159 | disabled = false |
| 160 | success_symbol = '[❯](bold fg:green)' |
| 161 | error_symbol = '[❯](bold fg:red)' |
| 162 | vimcmd_symbol = '[❮](bold fg:green)' |
| 163 | vimcmd_replace_one_symbol = '[❮](bold fg:lavender)' |
| 164 | vimcmd_replace_symbol = '[❮](bold fg:lavender)' |
| 165 | vimcmd_visual_symbol = '[❮](bold fg:yellow)' |
| 166 | |
| 167 | [cmd_duration] |
| 168 | show_milliseconds = true |
| 169 | format = " in $duration " |
| 170 | style = "bg:lavender" |
| 171 | disabled = false |
| 172 | show_notifications = true |
| 173 | min_time_to_notify = 45000 |
| 174 | |
| 175 | [palettes.catppuccin_mocha] |
| 176 | rosewater = "#f5e0dc" |
| 177 | flamingo = "#f2cdcd" |
| 178 | pink = "#f5c2e7" |
| 179 | mauve = "#cba6f7" |
| 180 | red = "#f38ba8" |
| 181 | maroon = "#eba0ac" |
| 182 | peach = "#fab387" |
| 183 | yellow = "#f9e2af" |
| 184 | green = "#a6e3a1" |
| 185 | teal = "#94e2d5" |
| 186 | sky = "#89dceb" |
| 187 | sapphire = "#74c7ec" |
| 188 | blue = "#89b4fa" |
| 189 | lavender = "#b4befe" |
| 190 | text = "#cdd6f4" |
| 191 | subtext1 = "#bac2de" |
| 192 | subtext0 = "#a6adc8" |
| 193 | overlay2 = "#9399b2" |
| 194 | overlay1 = "#7f849c" |
| 195 | overlay0 = "#6c7086" |
| 196 | surface2 = "#585b70" |
| 197 | surface1 = "#45475a" |
| 198 | surface0 = "#313244" |
| 199 | base = "#1e1e2e" |
| 200 | mantle = "#181825" |
| 201 | crust = "#11111b" |
| 202 | |
| 203 | [palettes.catppuccin_frappe] |
| 204 | rosewater = "#f2d5cf" |
| 205 | flamingo = "#eebebe" |
| 206 | pink = "#f4b8e4" |
| 207 | mauve = "#ca9ee6" |
| 208 | red = "#e78284" |
| 209 | maroon = "#ea999c" |
| 210 | peach = "#ef9f76" |
| 211 | yellow = "#e5c890" |
| 212 | green = "#a6d189" |
| 213 | teal = "#81c8be" |
| 214 | sky = "#99d1db" |
| 215 | sapphire = "#85c1dc" |
| 216 | blue = "#8caaee" |
| 217 | lavender = "#babbf1" |
| 218 | text = "#c6d0f5" |
| 219 | subtext1 = "#b5bfe2" |
| 220 | subtext0 = "#a5adce" |
| 221 | overlay2 = "#949cbb" |
| 222 | overlay1 = "#838ba7" |
| 223 | overlay0 = "#737994" |
| 224 | surface2 = "#626880" |
| 225 | surface1 = "#51576d" |
| 226 | surface0 = "#414559" |
| 227 | base = "#303446" |
| 228 | mantle = "#292c3c" |
| 229 | crust = "#232634" |
| 230 | |
| 231 | [palettes.catppuccin_latte] |
| 232 | rosewater = "#dc8a78" |
| 233 | flamingo = "#dd7878" |
| 234 | pink = "#ea76cb" |
| 235 | mauve = "#8839ef" |
| 236 | red = "#d20f39" |
| 237 | maroon = "#e64553" |
| 238 | peach = "#fe640b" |
| 239 | yellow = "#df8e1d" |
| 240 | green = "#40a02b" |
| 241 | teal = "#179299" |
| 242 | sky = "#04a5e5" |
| 243 | sapphire = "#209fb5" |
| 244 | blue = "#1e66f5" |
| 245 | lavender = "#7287fd" |
| 246 | text = "#4c4f69" |
| 247 | subtext1 = "#5c5f77" |
| 248 | subtext0 = "#6c6f85" |
| 249 | overlay2 = "#7c7f93" |
| 250 | overlay1 = "#8c8fa1" |
| 251 | overlay0 = "#9ca0b0" |
| 252 | surface2 = "#acb0be" |
| 253 | surface1 = "#bcc0cc" |
| 254 | surface0 = "#ccd0da" |
| 255 | base = "#eff1f5" |
| 256 | mantle = "#e6e9ef" |
| 257 | crust = "#dce0e8" |
| 258 | |
| 259 | [palettes.catppuccin_macchiato] |
| 260 | rosewater = "#f4dbd6" |
| 261 | flamingo = "#f0c6c6" |
| 262 | pink = "#f5bde6" |
| 263 | mauve = "#c6a0f6" |
| 264 | red = "#ed8796" |
| 265 | maroon = "#ee99a0" |
| 266 | peach = "#f5a97f" |
| 267 | yellow = "#eed49f" |
| 268 | green = "#a6da95" |
| 269 | teal = "#8bd5ca" |
| 270 | sky = "#91d7e3" |
| 271 | sapphire = "#7dc4e4" |
| 272 | blue = "#8aadf4" |
| 273 | lavender = "#b7bdf8" |
| 274 | text = "#cad3f5" |
| 275 | subtext1 = "#b8c0e0" |
| 276 | subtext0 = "#a5adcb" |
| 277 | overlay2 = "#939ab7" |
| 278 | overlay1 = "#8087a2" |
| 279 | overlay0 = "#6e738d" |
| 280 | surface2 = "#5b6078" |
| 281 | surface1 = "#494d64" |
| 282 | surface0 = "#363a4f" |
| 283 | base = "#24273a" |
| 284 | mantle = "#1e2030" |
| 285 | crust = "#181926" |
| 286 |
| 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() |
| 151 |
| 1 | #!/usr/bin/env zsh |
| 2 | |
| 3 | set -u |
| 4 | |
| 5 | repo_dir="${0:A:h}" |
| 6 | source "$repo_dir/func" |
| 7 | |
| 8 | failures=0 |
| 9 | fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/plan build launcher.XXXXXX")" || exit 1 |
| 10 | source_dir="$(mktemp -d "${TMPDIR:-/tmp}/plan-build-source.XXXXXX")" || exit 1 |
| 11 | export PLAN_BUILD_CACHE_DIR="$fixture_dir/cache with spaces" |
| 12 | export PLAN_BUILD_BASE_URL="file://$source_dir" |
| 13 | export HOME="$fixture_dir/home with spaces" |
| 14 | PLAN_BUILD_SKILLS_DIR="$HOME/.claude/skills" |
| 15 | mkdir -p "$source_dir" "$PLAN_BUILD_SKILLS_DIR/plan-build" "$PLAN_BUILD_SKILLS_DIR/plan-build-architect" |
| 16 | print -r -- "old standard" > "$PLAN_BUILD_SKILLS_DIR/plan-build/SKILL.md" |
| 17 | print -r -- "old architect" > "$PLAN_BUILD_SKILLS_DIR/plan-build-architect/SKILL.md" |
| 18 | trap 'rm -rf -- "$fixture_dir" "$source_dir"' EXIT |
| 19 | |
| 20 | fail() { |
| 21 | print -u2 -- "FAIL: $1" |
| 22 | failures=$((failures + 1)) |
| 23 | } |
| 24 | |
| 25 | assert_equal() { |
| 26 | [[ "$1" = "$2" ]] || fail "$3 (expected '$1', got '$2')" |
| 27 | } |
| 28 | |
| 29 | assert_contains() { |
| 30 | [[ "$1" == *"$2"* ]] || fail "$3 (missing '$2')" |
| 31 | } |
| 32 | |
| 33 | assert_file_contains() { |
| 34 | command grep -Fq -- "$2" "$1" || fail "$3" |
| 35 | } |
| 36 | |
| 37 | assert_bundle_consistent() { |
| 38 | local release="$PLAN_BUILD_CACHE_DIR/current" |
| 39 | local script_generation skill_generation architect_generation |
| 40 | release="${release:A}" |
| 41 | [[ -L "$PLAN_BUILD_CACHE_DIR/current" ]] || fail "$1 current is a symlink" |
| 42 | [[ -d "$release" ]] || fail "$1 current resolves to a release" |
| 43 | script_generation="$(command sed -n 's/^# generation: //p' "$release/plan_build.zsh")" |
| 44 | skill_generation="$(command sed -n 's/ standard skill$//p' "$release/SKILL.md")" |
| 45 | architect_generation="$(command sed -n 's/ architect skill$//p' "$release/ARCHITECT.md")" |
| 46 | assert_equal "$script_generation" "$skill_generation" "$1 script/standard generation" |
| 47 | assert_equal "$script_generation" "$architect_generation" "$1 script/architect generation" |
| 48 | } |
| 49 | |
| 50 | assert_skill_links() { |
| 51 | [[ -L "$PLAN_BUILD_SKILLS_DIR/plan-build/SKILL.md" ]] || fail "$1 standard path is symlink" |
| 52 | [[ -L "$PLAN_BUILD_SKILLS_DIR/plan-build-architect/SKILL.md" ]] || fail "$1 architect path is symlink" |
| 53 | assert_equal "$PLAN_BUILD_CACHE_DIR/current/SKILL.md" \ |
| 54 | "$(command readlink "$PLAN_BUILD_SKILLS_DIR/plan-build/SKILL.md")" "$1 standard link target" |
| 55 | assert_equal "$PLAN_BUILD_CACHE_DIR/current/ARCHITECT.md" \ |
| 56 | "$(command readlink "$PLAN_BUILD_SKILLS_DIR/plan-build-architect/SKILL.md")" "$1 architect link target" |
| 57 | } |
| 58 | |
| 59 | write_bundle() { |
| 60 | local generation="$1" |
| 61 | local exit_code="${2:-0}" |
| 62 | local marker_file="${3:-}" |
| 63 | print -r -- '#!/usr/bin/env zsh' > "$source_dir/plan_build.zsh" |
| 64 | print -r -- "# generation: $generation" >> "$source_dir/plan_build.zsh" |
| 65 | print -r -- "print -r -- \"$generation:\${(j:|:)@}\"" >> "$source_dir/plan_build.zsh" |
| 66 | print -r -- "print -r -- \"path:\${0}\"" >> "$source_dir/plan_build.zsh" |
| 67 | print -r -- 'print -r -- "skills:${PLAN_BUILD_SKILL_PATH}|${PLAN_BUILD_ARCHITECT_SKILL_PATH}"' >> "$source_dir/plan_build.zsh" |
| 68 | [[ -n "$marker_file" ]] && print -r -- ": > ${(q)marker_file}" >> "$source_dir/plan_build.zsh" |
| 69 | print -r -- "exit $exit_code" >> "$source_dir/plan_build.zsh" |
| 70 | print -r -- "$generation standard skill" > "$source_dir/SKILL.md" |
| 71 | print -r -- "$generation architect skill" > "$source_dir/ARCHITECT.md" |
| 72 | } |
| 73 | |
| 74 | run_plan_build() { |
| 75 | output="$(plan_build "$@" 2>&1)" |
| 76 | rc=$? |
| 77 | } |
| 78 | |
| 79 | write_bundle initial |
| 80 | run_plan_build alpha "two words" |
| 81 | assert_equal 0 "$rc" "initial migration status" |
| 82 | assert_contains "$output" "initial:alpha|two words" "argument forwarding" |
| 83 | assert_skill_links "initial migration" |
| 84 | assert_bundle_consistent "initial migration" |
| 85 | assert_file_contains "$PLAN_BUILD_SKILLS_DIR/plan-build/SKILL.md" "initial standard" "initial standard content" |
| 86 | assert_file_contains "$PLAN_BUILD_SKILLS_DIR/plan-build-architect/SKILL.md" "initial architect" "initial architect content" |
| 87 | execution_path="${${(f)output}[(r)path:*]#path:}" |
| 88 | [[ "$execution_path" == "$PLAN_BUILD_CACHE_DIR"/release.*/plan_build.zsh ]] || |
| 89 | fail "execution uses an immutable release path" |
| 90 | assert_contains "$output" "skills:${execution_path:h}/SKILL.md|${execution_path:h}/ARCHITECT.md" \ |
| 91 | "execution binds immutable skill paths" |
| 92 | |
| 93 | old_release="$PLAN_BUILD_CACHE_DIR/current" |
| 94 | old_release="${old_release:A}" |
| 95 | write_bundle replacement 23 |
| 96 | run_plan_build forwarded |
| 97 | assert_equal 23 "$rc" "canonical status forwarding" |
| 98 | assert_contains "$output" "replacement:forwarded" "replacement executes" |
| 99 | new_release="$PLAN_BUILD_CACHE_DIR/current" |
| 100 | new_release="${new_release:A}" |
| 101 | [[ "$new_release" != "$old_release" ]] || fail "refresh activates a unique release" |
| 102 | assert_skill_links "replacement" |
| 103 | assert_bundle_consistent "replacement" |
| 104 | |
| 105 | print -r -- 'if broken' > "$source_dir/plan_build.zsh" |
| 106 | print -r -- "bad standard skill" > "$source_dir/SKILL.md" |
| 107 | print -r -- "bad architect skill" > "$source_dir/ARCHITECT.md" |
| 108 | run_plan_build fallback |
| 109 | assert_equal 23 "$rc" "failed refresh fallback status" |
| 110 | assert_contains "$output" "Warning: Unable to refresh" "failed refresh warning" |
| 111 | assert_contains "$output" "replacement:fallback" "failed refresh executes current" |
| 112 | preserved_release="$PLAN_BUILD_CACHE_DIR/current" |
| 113 | preserved_release="${preserved_release:A}" |
| 114 | assert_equal "$new_release" "$preserved_release" "failed refresh preserves current" |
| 115 | assert_skill_links "failed refresh" |
| 116 | assert_bundle_consistent "failed refresh" |
| 117 | |
| 118 | export PLAN_BUILD_CURL_COMMAND="definitely-missing-plan-build-curl" |
| 119 | run_plan_build no-curl |
| 120 | assert_equal 23 "$rc" "missing curl fallback status" |
| 121 | assert_contains "$output" "curl is unavailable" "missing curl warning" |
| 122 | assert_contains "$output" "replacement:no-curl" "missing curl executes current" |
| 123 | rm -f "$PLAN_BUILD_SKILLS_DIR/plan-build/SKILL.md" "$PLAN_BUILD_SKILLS_DIR/plan-build-architect/SKILL.md" |
| 124 | run_plan_build repaired-offline |
| 125 | assert_equal 23 "$rc" "offline skill repair status" |
| 126 | assert_skill_links "offline skill repair" |
| 127 | unset PLAN_BUILD_CURL_COMMAND |
| 128 | |
| 129 | # An already selected immutable script must not change beneath a concurrent caller. |
| 130 | ready_file="$fixture_dir/ready" |
| 131 | go_file="$fixture_dir/go" |
| 132 | first_output="$fixture_dir/first.out" |
| 133 | print -r -- '#!/usr/bin/env zsh' > "$source_dir/plan_build.zsh" |
| 134 | print -r -- '# generation: concurrent_first' >> "$source_dir/plan_build.zsh" |
| 135 | print -r -- 'print -r -- "concurrent_first:${(j:|:)@}"' >> "$source_dir/plan_build.zsh" |
| 136 | print -r -- ": > ${(q)ready_file}" >> "$source_dir/plan_build.zsh" |
| 137 | print -r -- "while [[ ! -e ${(q)go_file} ]]; do sleep 0.01; done" >> "$source_dir/plan_build.zsh" |
| 138 | print -r -- 'print -r -- "finished:first"' >> "$source_dir/plan_build.zsh" |
| 139 | print -r -- "concurrent_first standard skill" > "$source_dir/SKILL.md" |
| 140 | print -r -- "concurrent_first architect skill" > "$source_dir/ARCHITECT.md" |
| 141 | (plan_build one > "$first_output" 2>&1) & |
| 142 | first_pid=$! |
| 143 | for attempt in {1..500}; do |
| 144 | [[ -e "$ready_file" ]] && break |
| 145 | sleep 0.01 |
| 146 | done |
| 147 | [[ -e "$ready_file" ]] || fail "first concurrent call reached canonical execution" |
| 148 | write_bundle concurrent_second |
| 149 | run_plan_build two |
| 150 | assert_equal 0 "$rc" "second concurrent status" |
| 151 | assert_contains "$output" "concurrent_second:two" "second concurrent generation" |
| 152 | : > "$go_file" |
| 153 | wait "$first_pid" || fail "first concurrent status" |
| 154 | first_text="$(<"$first_output")" |
| 155 | assert_contains "$first_text" "concurrent_first:one" "first call retains selected release" |
| 156 | assert_contains "$first_text" "finished:first" "first immutable script finishes" |
| 157 | assert_bundle_consistent "concurrent final current" |
| 158 | assert_skill_links "concurrent final links" |
| 159 | |
| 160 | # Cancellation while refreshing must clean staging and never execute canonical code. |
| 161 | blocking_curl="$fixture_dir/blocking-curl" |
| 162 | cancel_started="$fixture_dir/cancel.started" |
| 163 | cancel_executed="$fixture_dir/cancel.executed" |
| 164 | write_bundle cancellation-marker 0 "$cancel_executed" |
| 165 | run_plan_build activate-cancellation-marker |
| 166 | assert_equal 0 "$rc" "cancellation marker bundle activation" |
| 167 | rm -f "$cancel_executed" |
| 168 | { |
| 169 | print -r -- '#!/bin/sh' |
| 170 | print -r -- "touch ${(q)cancel_started}" |
| 171 | print -r -- 'while :; do sleep 1; done' |
| 172 | } > "$blocking_curl" |
| 173 | chmod +x "$blocking_curl" |
| 174 | export PLAN_BUILD_CURL_COMMAND="$blocking_curl" |
| 175 | (plan_build cancelled > "$fixture_dir/cancel.out" 2>&1; cancel_rc=$?; print -r -- "$cancel_rc" > "$fixture_dir/cancel.rc"; exit "$cancel_rc") & |
| 176 | cancel_pid=$! |
| 177 | for attempt in {1..500}; do |
| 178 | [[ -e "$cancel_started" ]] && break |
| 179 | sleep 0.01 |
| 180 | done |
| 181 | kill -TERM "$cancel_pid" |
| 182 | wait "$cancel_pid" |
| 183 | cancel_wait_rc=$? |
| 184 | cancel_recorded_rc="" |
| 185 | [[ -s "$fixture_dir/cancel.rc" ]] && cancel_recorded_rc="$(<"$fixture_dir/cancel.rc")" |
| 186 | [[ "$cancel_wait_rc" = 143 || "$cancel_recorded_rc" = 143 ]] || |
| 187 | fail "TERM returns conventional status 143" |
| 188 | [[ ! -e "$cancel_executed" ]] || fail "TERM does not execute canonical plan-build" |
| 189 | unset PLAN_BUILD_CURL_COMMAND |
| 190 | |
| 191 | # A pre-existing lock file must not be mistaken for an active kernel lock. |
| 192 | print -r -- "stale contents" > "$PLAN_BUILD_CACHE_DIR/.activate.lock" |
| 193 | write_bundle stale-lock-recovery |
| 194 | run_plan_build recovered |
| 195 | assert_equal 0 "$rc" "stale activation lock recovery status" |
| 196 | assert_contains "$output" "stale-lock-recovery:recovered" "stale activation lock recovery execution" |
| 197 | |
| 198 | # Cancellation while canonical code runs must terminate the child promptly. |
| 199 | canonical_started="$fixture_dir/canonical.started" |
| 200 | canonical_finished="$fixture_dir/canonical.finished" |
| 201 | print -r -- '#!/usr/bin/env zsh' > "$source_dir/plan_build.zsh" |
| 202 | print -r -- '# generation: canonical-cancel' >> "$source_dir/plan_build.zsh" |
| 203 | print -r -- "trap 'exit 143' TERM" >> "$source_dir/plan_build.zsh" |
| 204 | print -r -- ": > ${(q)canonical_started}" >> "$source_dir/plan_build.zsh" |
| 205 | print -r -- 'while :; do sleep 1; done' >> "$source_dir/plan_build.zsh" |
| 206 | print -r -- ": > ${(q)canonical_finished}" >> "$source_dir/plan_build.zsh" |
| 207 | print -r -- "canonical-cancel standard skill" > "$source_dir/SKILL.md" |
| 208 | print -r -- "canonical-cancel architect skill" > "$source_dir/ARCHITECT.md" |
| 209 | (plan_build canonical-cancel > "$fixture_dir/canonical-cancel.out" 2>&1; print -r -- "$?" > "$fixture_dir/canonical-cancel.rc") & |
| 210 | canonical_pid=$! |
| 211 | for attempt in {1..500}; do |
| 212 | [[ -e "$canonical_started" ]] && break |
| 213 | sleep 0.01 |
| 214 | done |
| 215 | [[ -e "$canonical_started" ]] || fail "canonical cancellation reached execution" |
| 216 | kill -TERM "$canonical_pid" |
| 217 | wait "$canonical_pid" |
| 218 | assert_equal 143 "$(<"$fixture_dir/canonical-cancel.rc")" "canonical cancellation status" |
| 219 | [[ ! -e "$canonical_finished" ]] || fail "canonical child continued after TERM" |
| 220 | |
| 221 | temp_count="$(command find "$PLAN_BUILD_CACHE_DIR" -maxdepth 1 \ |
| 222 | \( -name '.stage.*' -o -name '.current.*' \) -print | command wc -l | command tr -d ' ')" |
| 223 | assert_equal 0 "$temp_count" "cache staging and temporary links cleaned" |
| 224 | skill_temp_count="$(command find "$PLAN_BUILD_SKILLS_DIR" \ |
| 225 | \( -name '*.link.*' -o -name '*.backup.*' \) -print | command wc -l | command tr -d ' ')" |
| 226 | assert_equal 0 "$skill_temp_count" "skill temporary links and backups cleaned" |
| 227 | |
| 228 | if (( failures )); then |
| 229 | exit 1 |
| 230 | fi |
| 231 | |
| 232 | print -- "PASS: plan_build launcher tests" |
| 233 |
| 1 | " Enable line numbers |
| 2 | set number |
| 3 | |
| 4 | " Enable relative line numbers |
| 5 | set relativenumber |
| 6 | |
| 7 | " Enable syntax highlighting |
| 8 | syntax on |
| 9 | |
| 10 | " Set colorscheme |
| 11 | colorscheme slate |
| 12 | |
| 13 | " Enable file type detection and plugins |
| 14 | filetype plugin indent on |
| 15 | |
| 16 | " Set the tab width to 4 spaces |
| 17 | set tabstop=4 |
| 18 | set shiftwidth=4 |
| 19 | set expandtab |
| 20 | |
| 21 | " Enable auto-indentation |
| 22 | set autoindent |
| 23 | set smartindent |
| 24 | |
| 25 | " Highlight current line |
| 26 | set cursorline |
| 27 | |
| 28 | " Show matching parentheses |
| 29 | set showmatch |
| 30 | |
| 31 | " Enable line wrapping |
| 32 | set wrap |
| 33 | |
| 34 | " Enable mouse support |
| 35 | set mouse=a |
| 36 | |
| 37 | " Use Wayland's clipboard tools when this Vim lacks native clipboard support |
| 38 | if !has('clipboard') && exists('v:clipproviders') |
| 39 | \ && executable('wl-copy') && executable('wl-paste') |
| 40 | function! s:WaylandCopy(register, type, lines) abort |
| 41 | let l:text = join(a:lines, "\n") |
| 42 | if a:type ==# 'V' |
| 43 | let l:text ..= "\n" |
| 44 | endif |
| 45 | call system('wl-copy', l:text) |
| 46 | endfunction |
| 47 | |
| 48 | function! s:WaylandPaste(register) abort |
| 49 | return ['', systemlist('wl-paste --no-newline')] |
| 50 | endfunction |
| 51 | |
| 52 | let v:clipproviders['wltools'] = { |
| 53 | \ 'copy': { |
| 54 | \ '+': function('s:WaylandCopy'), |
| 55 | \ '*': function('s:WaylandCopy') |
| 56 | \ }, |
| 57 | \ 'paste': { |
| 58 | \ '+': function('s:WaylandPaste'), |
| 59 | \ '*': function('s:WaylandPaste') |
| 60 | \ } |
| 61 | \ } |
| 62 | set clipmethod^=wltools |
| 63 | endif |
| 64 | set clipboard=unnamedplus |
| 65 | |
| 66 | " Disable swap file |
| 67 | set noswapfile |
| 68 | |
| 69 | " Enable incremental search |
| 70 | set incsearch |
| 71 | |
| 72 | " Ignore case in search |
| 73 | set ignorecase |
| 74 | |
| 75 | " Override ignorecase if search contains capital letters |
| 76 | set smartcase |
| 77 | |
| 78 | " Display line and column number of the cursor position |
| 79 | set ruler |
| 80 | |
| 81 | " Set the status line at the bottom |
| 82 | set laststatus=2 |
| 83 | |
| 84 | " Show command in bottom bar |
| 85 | set showcmd |
| 86 | |
| 87 | " Set command height |
| 88 | set cmdheight=2 |
| 89 | |
| 90 | " Set history lines |
| 91 | set history=1000 |
| 92 | |
| 93 | " Disable backup file |
| 94 | set nobackup |
| 95 | |
| 96 | " Enable persistent undo |
| 97 | set undofile |
| 98 | |
| 99 | " Set maximum number of undo levels |
| 100 | set undolevels=1000 |
| 101 | |
| 102 | " Set undo directory |
| 103 | if has("persistent_undo") |
| 104 | silent !mkdir ~/.vim/undodir > /dev/null 2>&1 |
| 105 | set undodir=~/.vim/undodir |
| 106 | endif |
| 107 | |
| 108 | " Set search highlighting |
| 109 | set hlsearch |
| 110 | |
| 111 | " Enable visual bell |
| 112 | set visualbell |
| 113 | |
| 114 | " Set default file encoding |
| 115 | set encoding=utf-8 |
| 116 | |
| 117 | " Set the leader key to space |
| 118 | let mapleader = " " |
| 119 | |
| 120 | " Map <Leader>w to save the file |
| 121 | nnoremap <Leader>w :w<CR> |
| 122 | |
| 123 | " Map <Leader>q to quit |
| 124 | nnoremap <Leader>q :q<CR> |
| 125 | |
| 126 | " Map <Leader>x to save and quit |
| 127 | nnoremap <Leader>x :wq<CR> |
| 128 | |
| 129 | " Enable folding |
| 130 | set foldmethod=syntax |
| 131 | set foldlevelstart=99 |
| 132 | |
| 133 | " Enable line wrapping at 80 characters |
| 134 | set textwidth=80 |
| 135 | set colorcolumn=80 |
| 136 | |
| 137 | " Add some basic key mappings |
| 138 | " Map jj to escape insert mode |
| 139 | inoremap jj <Esc> |
| 140 | |
| 141 | " Map <Leader>n to toggle line numbers |
| 142 | nnoremap <Leader>n :set number!<CR> |
| 143 | |
| 144 | " Map <Leader>r to toggle relative line numbers |
| 145 | nnoremap <Leader>r :set relativenumber!<CR> |
| 146 | |
| 147 | " Configure plugins (if you use a plugin manager like vim-plug) |
| 148 | " Example with vim-plug: |
| 149 | " call plug#begin('~/.vim/plugged') |
| 150 | " Plug 'tpope/vim-sensible' |
| 151 | " Plug 'preservim/nerdtree' |
| 152 | " Plug 'junegunn/fzf', { 'do': { -> fzf#install() } } |
| 153 | " Plug 'airblade/vim-gitgutter' |
| 154 | " call plug#end() |
| 155 | |
| 156 | " NERDTree key mappings |
| 157 | " nnoremap <C-n> :NERDTreeToggle<CR> |
| 158 | |
| 159 | " Enable automatic hard wrapping at textwidth (80 chars) |
| 160 | set formatoptions+=t " Auto-wrap text using textwidth |
| 161 | set formatoptions+=c " Auto-wrap comments using textwidth |
| 162 | set formatoptions+=r " Continue comments when pressing Enter |
| 163 | set formatoptions+=o " Continue comments when using 'o' or 'O' |
| 164 | set formatoptions+=q " Allow formatting of comments with 'gq' |
| 165 | set formatoptions+=n " Recognize numbered lists |
| 166 | set formatoptions+=l " Don't break lines that were already long |
| 167 | |
| 168 | " Enable syntax highlighting |
| 169 | syntax on |
| 170 | |
| 171 | " Increase memory limit for complex syntax parsing (Prevents E363) |
| 172 | set maxmempattern=20000 |
| 173 |
| 1 | #!/bin/bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | # ============================= |
| 5 | # COLORS & LOGGING |
| 6 | # ============================= |
| 7 | RED='\033[0;31m' |
| 8 | GREEN='\033[0;32m' |
| 9 | YELLOW='\033[1;33m' |
| 10 | BLUE='\033[0;34m' |
| 11 | NC='\033[0m' |
| 12 | |
| 13 | log() { echo -e "${BLUE}[INFO]${NC} $*"; } |
| 14 | ok() { echo -e "${GREEN}[OK]${NC} $*"; } |
| 15 | warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } |
| 16 | err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } |
| 17 | |
| 18 | # ============================= |
| 19 | # CONFIG |
| 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 | ) |
| 36 | |
| 37 | # ============================= |
| 38 | # REQUIREMENTS |
| 39 | # ============================= |
| 40 | check_requirements() { |
| 41 | if [[ $EUID -eq 0 ]]; then |
| 42 | err "Do not run as root on macOS" |
| 43 | exit 1 |
| 44 | fi |
| 45 | } |
| 46 | |
| 47 | # ============================= |
| 48 | # INSTALLATION FUNCTIONS |
| 49 | # ============================= |
| 50 | install_oh_my_zsh() { |
| 51 | log "Installing Oh My Zsh and plugins..." |
| 52 | export RUNZSH=no |
| 53 | export CHSH=no |
| 54 | export KEEP_ZSHRC=yes |
| 55 | |
| 56 | if [[ ! -d "$HOME/.oh-my-zsh" ]]; then |
| 57 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" --unattended |
| 58 | else |
| 59 | ok "Oh My Zsh already installed" |
| 60 | fi |
| 61 | |
| 62 | local custom="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}" |
| 63 | local plugin_dir="$custom/plugins" |
| 64 | mkdir -p "$plugin_dir" |
| 65 | |
| 66 | [[ -d "$plugin_dir/zsh-autosuggestions" ]] || \ |
| 67 | git clone https://github.com/zsh-users/zsh-autosuggestions "$plugin_dir/zsh-autosuggestions" |
| 68 | |
| 69 | [[ -d "$plugin_dir/zsh-syntax-highlighting" ]] || \ |
| 70 | git clone https://github.com/zsh-users/zsh-syntax-highlighting "$plugin_dir/zsh-syntax-highlighting" |
| 71 | |
| 72 | ok "Oh My Zsh plugins installed to $plugin_dir" |
| 73 | } |
| 74 | |
| 75 | install_starship() { |
| 76 | if command -v starship >/dev/null 2>&1 || [[ -f "$HOME/.local/bin/starship" ]]; then |
| 77 | ok "Starship already installed" |
| 78 | elif command -v brew >/dev/null 2>&1; then |
| 79 | log "Installing Starship with Homebrew..." |
| 80 | brew install starship |
| 81 | ok "Starship installed" |
| 82 | else |
| 83 | log "Installing Starship..." |
| 84 | mkdir -p "$HOME/.local/bin" |
| 85 | curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin" |
| 86 | ok "Starship installed" |
| 87 | fi |
| 88 | } |
| 89 | |
| 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 |
| 110 | rm -f "$tmp" |
| 111 | warn "Failed to download $remote_file" |
| 112 | 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" |
| 130 | fi |
| 131 | } |
| 132 | |
| 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 | configure_git_identity() { |
| 175 | log "Configuring global Git identity..." |
| 176 | git config --global user.name "Vernon Wee Hong KOH" |
| 177 | git config --global user.email "261549452+weehong@users.noreply.github.com" |
| 178 | ok "Global Git identity configured" |
| 179 | } |
| 180 | |
| 181 | switch_shell() { |
| 182 | log "Starting Zsh session..." |
| 183 | echo -e "${YELLOW}Type 'exit' to return to this installer menu.${NC}" |
| 184 | echo "----------------------------------------" |
| 185 | zsh -l |
| 186 | echo "----------------------------------------" |
| 187 | ok "Returned from Zsh session" |
| 188 | } |
| 189 | |
| 190 | # ============================= |
| 191 | # INTERACTIVE MENU |
| 192 | # ============================= |
| 193 | show_menu() { |
| 194 | echo "===========================================" |
| 195 | echo "macOS Minimal Zsh Setup - Choose what to do" |
| 196 | echo "===========================================" |
| 197 | echo " 0) Run ALL steps (1-6)" |
| 198 | echo " 1) Install Oh My Zsh + plugins" |
| 199 | echo " 2) Install Starship prompt" |
| 200 | echo " 3) Download custom configs (~/.alias, .func, .vimrc, etc.)" |
| 201 | echo " 4) Configure ~/.zshrc (Oh My Zsh + Starship)" |
| 202 | echo " 5) Configure global Git identity" |
| 203 | echo " 6) Switch to Zsh (Temporary Sub-shell)" |
| 204 | echo " 7) Quit" |
| 205 | echo "===========================================" |
| 206 | } |
| 207 | |
| 208 | run_choices() { |
| 209 | local input |
| 210 | read -p "Select: " input |
| 211 | input="${input//,/ }" |
| 212 | |
| 213 | local -a to_run=() |
| 214 | local -a to_exclude=() |
| 215 | |
| 216 | for item in $input; do |
| 217 | if [[ "$item" == !* ]]; then |
| 218 | to_exclude+=("${item:1}") |
| 219 | elif [[ "$item" == "0" ]]; then |
| 220 | to_run+=(1 2 3 4 5 6) |
| 221 | else |
| 222 | to_run+=("$item") |
| 223 | fi |
| 224 | done |
| 225 | |
| 226 | if [[ ${#to_run[@]} -gt 0 ]]; then |
| 227 | for choice in "${to_run[@]}"; do |
| 228 | local skip=false |
| 229 | |
| 230 | if [[ ${#to_exclude[@]} -gt 0 ]]; then |
| 231 | for ex in "${to_exclude[@]}"; do |
| 232 | if [[ "$choice" == "$ex" ]]; then |
| 233 | skip=true |
| 234 | break |
| 235 | fi |
| 236 | done |
| 237 | fi |
| 238 | |
| 239 | $skip && continue |
| 240 | |
| 241 | case "$choice" in |
| 242 | 1) install_oh_my_zsh ;; |
| 243 | 2) install_starship ;; |
| 244 | 3) download_configs ;; |
| 245 | 4) configure_zshrc ;; |
| 246 | 5) configure_git_identity ;; |
| 247 | 6) switch_shell ;; |
| 248 | 7) exit 0 ;; |
| 249 | *) warn "Skipping invalid option: $choice" ;; |
| 250 | esac |
| 251 | echo |
| 252 | done |
| 253 | fi |
| 254 | } |
| 255 | |
| 256 | # ============================= |
| 257 | # MAIN |
| 258 | # ============================= |
| 259 | main() { |
| 260 | check_requirements |
| 261 | while true; do |
| 262 | show_menu |
| 263 | run_choices |
| 264 | read -p "Do you want to run more options? (y/n): " again |
| 265 | [[ "$again" =~ ^[Yy]$ ]] || break |
| 266 | done |
| 267 | ok "macOS minimal Zsh configuration complete!" |
| 268 | } |
| 269 | |
| 270 | main "$@" |
| 271 |
| 1 | #!/bin/bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | # ============================= |
| 5 | # COLORS & LOGGING |
| 6 | # ============================= |
| 7 | RED='\033[0;31m' |
| 8 | GREEN='\033[0;32m' |
| 9 | YELLOW='\033[1;33m' |
| 10 | BLUE='\033[0;34m' |
| 11 | NC='\033[0m' |
| 12 | |
| 13 | log() { echo -e "${BLUE}[INFO]${NC} $*"; } |
| 14 | ok() { echo -e "${GREEN}[OK]${NC} $*"; } |
| 15 | warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } |
| 16 | err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } |
| 17 | |
| 18 | # ============================= |
| 19 | # FLAGS & CONFIG |
| 20 | # ============================= |
| 21 | SKIP_PACKAGES=false |
| 22 | SKIP_SHELL_CHANGE=false |
| 23 | INSTALL_HOMEBREW=false |
| 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 | ) |
| 39 | |
| 40 | # ============================= |
| 41 | # OS DETECTION |
| 42 | # ============================= |
| 43 | detect_os() { |
| 44 | if [[ "$OSTYPE" == "darwin"* ]]; then |
| 45 | echo "macos" |
| 46 | elif [ -f /etc/os-release ]; then |
| 47 | . /etc/os-release |
| 48 | echo "$ID" |
| 49 | else |
| 50 | echo "unknown" |
| 51 | fi |
| 52 | } |
| 53 | |
| 54 | # ============================= |
| 55 | # REQUIREMENTS |
| 56 | # ============================= |
| 57 | check_requirements() { |
| 58 | if [[ $EUID -eq 0 ]]; then |
| 59 | err "Do not run as root. The script will request sudo when necessary." |
| 60 | exit 1 |
| 61 | fi |
| 62 | if [[ "$(detect_os)" != "macos" ]] && ! command -v sudo >/dev/null 2>&1; then |
| 63 | err "sudo required on Linux" |
| 64 | exit 1 |
| 65 | fi |
| 66 | } |
| 67 | |
| 68 | # ============================= |
| 69 | # INSTALLATION FUNCTIONS |
| 70 | # ============================= |
| 71 | update_system() { |
| 72 | $SKIP_PACKAGES && return |
| 73 | log "Updating system..." |
| 74 | local os=$(detect_os) |
| 75 | |
| 76 | case "$os" in |
| 77 | macos) |
| 78 | brew update || warn "Homebrew update failed; continuing installer" |
| 79 | ;; |
| 80 | ubuntu|debian) |
| 81 | sudo apt-get update -y |
| 82 | if ! sudo apt-get upgrade -y; then |
| 83 | warn "System upgrade did not complete. This can happen when apt wants to downgrade a package." |
| 84 | warn "Continuing because the Zsh setup does not require OS package upgrades to finish." |
| 85 | fi |
| 86 | ;; |
| 87 | fedora) |
| 88 | sudo dnf upgrade -y || warn "System upgrade failed; continuing installer" |
| 89 | ;; |
| 90 | arch) |
| 91 | sudo pacman -Syu --noconfirm || warn "System upgrade failed; continuing installer" |
| 92 | ;; |
| 93 | *) warn "Auto-update not supported for OS: $os" ;; |
| 94 | esac |
| 95 | ok "System update step finished" |
| 96 | } |
| 97 | |
| 98 | install_packages() { |
| 99 | $SKIP_PACKAGES && return |
| 100 | log "Installing core packages..." |
| 101 | local os=$(detect_os) |
| 102 | |
| 103 | case "$os" in |
| 104 | macos) brew install zsh git vim curl wget unzip xz ;; |
| 105 | ubuntu|debian) sudo apt-get install -y zsh git vim curl wget unzip zip build-essential xz-utils ;; |
| 106 | fedora) sudo dnf install -y zsh git vim curl wget unzip zip @development-tools xz ;; |
| 107 | arch) sudo pacman -S --noconfirm zsh git vim curl wget unzip zip base-devel xz ;; |
| 108 | *) warn "Auto-install not supported for OS: $os. Please install zsh, git, vim, curl manually." ;; |
| 109 | esac |
| 110 | ok "Packages installed" |
| 111 | } |
| 112 | |
| 113 | set_timezone() { |
| 114 | log "Setting timezone to Asia/Singapore..." |
| 115 | local os=$(detect_os) |
| 116 | if [[ "$os" == "macos" ]]; then |
| 117 | sudo systemsetup -settimezone Asia/Singapore >/dev/null |
| 118 | else |
| 119 | sudo timedatectl set-timezone Asia/Singapore |
| 120 | fi |
| 121 | ok "Timezone set to Asia/Singapore" |
| 122 | } |
| 123 | |
| 124 | install_homebrew() { |
| 125 | ! $INSTALL_HOMEBREW && return |
| 126 | command -v brew >/dev/null 2>&1 && ok "Homebrew already installed" && return |
| 127 | log "Installing Homebrew..." |
| 128 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
| 129 | ok "Homebrew installed" |
| 130 | } |
| 131 | |
| 132 | configure_shell() { |
| 133 | $SKIP_SHELL_CHANGE && return |
| 134 | log "Changing default shell to zsh..." |
| 135 | local zsh_path |
| 136 | zsh_path="$(command -v zsh)" |
| 137 | |
| 138 | if ! grep -qx "$zsh_path" /etc/shells; then |
| 139 | echo "$zsh_path" | sudo tee -a /etc/shells >/dev/null |
| 140 | fi |
| 141 | chsh -s "$zsh_path" |
| 142 | ok "Shell changed (requires logout/login to take effect)" |
| 143 | } |
| 144 | |
| 145 | install_oh_my_zsh() { |
| 146 | log "Installing Oh My Zsh and plugins..." |
| 147 | export RUNZSH=no |
| 148 | export CHSH=no |
| 149 | export KEEP_ZSHRC=yes |
| 150 | |
| 151 | if [[ ! -d "$HOME/.oh-my-zsh" ]]; then |
| 152 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" --unattended |
| 153 | else |
| 154 | ok "Oh My Zsh already installed" |
| 155 | fi |
| 156 | |
| 157 | local custom="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}" |
| 158 | local plugin_dir="$custom/plugins" |
| 159 | mkdir -p "$plugin_dir" |
| 160 | |
| 161 | [[ -d "$plugin_dir/zsh-autosuggestions" ]] || git clone https://github.com/zsh-users/zsh-autosuggestions "$plugin_dir/zsh-autosuggestions" |
| 162 | [[ -d "$plugin_dir/zsh-syntax-highlighting" ]] || git clone https://github.com/zsh-users/zsh-syntax-highlighting "$plugin_dir/zsh-syntax-highlighting" |
| 163 | |
| 164 | ok "Oh My Zsh plugins installed to $plugin_dir" |
| 165 | } |
| 166 | |
| 167 | install_starship() { |
| 168 | if command -v starship >/dev/null 2>&1 || [[ -f "$HOME/.local/bin/starship" ]]; then |
| 169 | ok "Starship already installed" |
| 170 | else |
| 171 | log "Installing Starship prompt..." |
| 172 | if command -v brew >/dev/null 2>&1; then |
| 173 | brew install starship |
| 174 | else |
| 175 | mkdir -p "$HOME/.local/bin" |
| 176 | curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin" |
| 177 | fi |
| 178 | ok "Starship installed" |
| 179 | fi |
| 180 | } |
| 181 | |
| 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 |
| 201 | rm -f "$tmp" |
| 202 | warn "Failed to download $remote_file" |
| 203 | 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" |
| 221 | fi |
| 222 | } |
| 223 | |
| 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 | configure_git_identity() { |
| 266 | log "Configuring global Git identity..." |
| 267 | git config --global user.name "Vernon Wee Hong KOH" |
| 268 | git config --global user.email "261549452+weehong@users.noreply.github.com" |
| 269 | ok "Global Git identity configured" |
| 270 | } |
| 271 | |
| 272 | switch_shell() { |
| 273 | log "Starting Zsh session..." |
| 274 | echo -e "${YELLOW}Type 'exit' to return to this installer menu.${NC}" |
| 275 | echo "----------------------------------------" |
| 276 | zsh -l |
| 277 | echo "----------------------------------------" |
| 278 | ok "Returned from Zsh session" |
| 279 | } |
| 280 | |
| 281 | # ============================= |
| 282 | # INTERACTIVE MENU |
| 283 | # ============================= |
| 284 | show_menu() { |
| 285 | echo "===========================================" |
| 286 | echo "Minimal Zsh Installer - Choose what to do" |
| 287 | echo "===========================================" |
| 288 | echo " 0) Run ALL steps (1-11)" |
| 289 | echo " 1) Update system packages" |
| 290 | echo " 2) Install core packages (zsh, git, vim, etc.)" |
| 291 | echo " 3) Set Timezone (Asia/Singapore)" |
| 292 | echo " 4) Install Homebrew" |
| 293 | echo " 5) Configure shell (chsh - sets default shell)" |
| 294 | echo " 6) Install Oh My Zsh + plugins" |
| 295 | echo " 7) Install Starship prompt" |
| 296 | echo " 8) Download custom configs (from OpenGist)" |
| 297 | echo " 9) Configure ~/.zshrc (Oh My Zsh + Starship)" |
| 298 | echo "10) Configure global Git identity" |
| 299 | echo "11) Switch to Zsh (Temporary Sub-shell)" |
| 300 | echo "12) Quit" |
| 301 | echo "===========================================" |
| 302 | } |
| 303 | |
| 304 | run_choices() { |
| 305 | local input |
| 306 | read -p "Select: " input |
| 307 | input="${input//,/ }" |
| 308 | |
| 309 | local -a to_run=() |
| 310 | local -a to_exclude=() |
| 311 | |
| 312 | for item in $input; do |
| 313 | if [[ "$item" == !* ]]; then |
| 314 | to_exclude+=("${item:1}") |
| 315 | elif [[ "$item" == "0" ]]; then |
| 316 | to_run+=(1 2 3 4 5 6 7 8 9 10 11) |
| 317 | else |
| 318 | to_run+=("$item") |
| 319 | fi |
| 320 | done |
| 321 | |
| 322 | for choice in "${to_run[@]}"; do |
| 323 | local skip=false |
| 324 | |
| 325 | for ex in "${to_exclude[@]}"; do |
| 326 | if [[ "$choice" == "$ex" ]]; then |
| 327 | skip=true |
| 328 | break |
| 329 | fi |
| 330 | done |
| 331 | |
| 332 | $skip && continue |
| 333 | |
| 334 | case "$choice" in |
| 335 | 1) update_system ;; |
| 336 | 2) install_packages ;; |
| 337 | 3) set_timezone ;; |
| 338 | 4) install_homebrew ;; |
| 339 | 5) configure_shell ;; |
| 340 | 6) install_oh_my_zsh ;; |
| 341 | 7) install_starship ;; |
| 342 | 8) download_configs ;; |
| 343 | 9) configure_zshrc ;; |
| 344 | 10) configure_git_identity ;; |
| 345 | 11) switch_shell ;; |
| 346 | 12) log "Exiting..."; exit 0 ;; |
| 347 | *) warn "Skipping invalid option: $choice" ;; |
| 348 | esac |
| 349 | echo |
| 350 | done |
| 351 | } |
| 352 | |
| 353 | # ============================= |
| 354 | # MAIN |
| 355 | # ============================= |
| 356 | main() { |
| 357 | check_requirements |
| 358 | while true; do |
| 359 | show_menu |
| 360 | run_choices |
| 361 | read -p "Do you want to run more options? (y/n): " again |
| 362 | [[ "$again" =~ ^[Yy]$ ]] || break |
| 363 | done |
| 364 | ok "Zsh installation/configuration complete!" |
| 365 | } |
| 366 | |
| 367 | main "$@" |
| 368 |
| 1 | #!/bin/bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | # ============================= |
| 5 | # COLORS & LOGGING |
| 6 | # ============================= |
| 7 | RED='\033[0;31m' |
| 8 | GREEN='\033[0;32m' |
| 9 | YELLOW='\033[1;33m' |
| 10 | BLUE='\033[0;34m' |
| 11 | NC='\033[0m' |
| 12 | |
| 13 | log() { echo -e "${BLUE}[INFO]${NC} $*"; } |
| 14 | ok() { echo -e "${GREEN}[OK]${NC} $*"; } |
| 15 | warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } |
| 16 | err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } |
| 17 | |
| 18 | # ============================= |
| 19 | # FLAGS & CONFIG |
| 20 | # ============================= |
| 21 | SKIP_PACKAGES=false |
| 22 | SKIP_SHELL_CHANGE=false |
| 23 | INSTALL_HOMEBREW=false |
| 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 | ) |
| 39 | |
| 40 | # ============================= |
| 41 | # PLATFORM DETECTION |
| 42 | # ============================= |
| 43 | detect_os() { |
| 44 | if [ -f /etc/os-release ]; then |
| 45 | . /etc/os-release |
| 46 | echo "$ID" |
| 47 | else |
| 48 | echo "unknown" |
| 49 | fi |
| 50 | } |
| 51 | |
| 52 | is_wsl() { |
| 53 | grep -qi "microsoft" /proc/version 2>/dev/null |
| 54 | } |
| 55 | |
| 56 | # ============================= |
| 57 | # REQUIREMENTS |
| 58 | # ============================= |
| 59 | check_requirements() { |
| 60 | if [[ $EUID -eq 0 ]]; then |
| 61 | err "Do not run as root. The script will request sudo when necessary." |
| 62 | exit 1 |
| 63 | fi |
| 64 | if ! command -v sudo >/dev/null 2>&1; then |
| 65 | err "sudo required" |
| 66 | exit 1 |
| 67 | fi |
| 68 | if ! is_wsl; then |
| 69 | err "This installer is intended for WSL" |
| 70 | exit 1 |
| 71 | fi |
| 72 | } |
| 73 | |
| 74 | # ============================= |
| 75 | # INSTALLATION FUNCTIONS |
| 76 | # ============================= |
| 77 | update_system() { |
| 78 | $SKIP_PACKAGES && return |
| 79 | log "Updating system..." |
| 80 | local os=$(detect_os) |
| 81 | |
| 82 | case "$os" in |
| 83 | ubuntu|debian) |
| 84 | sudo apt-get update -y |
| 85 | if ! sudo apt-get upgrade -y; then |
| 86 | warn "System upgrade did not complete. This can happen when apt wants to downgrade a package." |
| 87 | warn "Continuing because the Zsh setup does not require OS package upgrades to finish." |
| 88 | fi |
| 89 | ;; |
| 90 | fedora) |
| 91 | sudo dnf upgrade -y || warn "System upgrade failed; continuing installer" |
| 92 | ;; |
| 93 | arch) |
| 94 | sudo pacman -Syu --noconfirm || warn "System upgrade failed; continuing installer" |
| 95 | ;; |
| 96 | *) warn "Auto-update not supported for OS: $os" ;; |
| 97 | esac |
| 98 | ok "System update step finished" |
| 99 | } |
| 100 | |
| 101 | install_packages() { |
| 102 | $SKIP_PACKAGES && return |
| 103 | log "Installing core packages..." |
| 104 | local os=$(detect_os) |
| 105 | |
| 106 | case "$os" in |
| 107 | ubuntu|debian) sudo apt-get install -y zsh git vim curl wget unzip zip build-essential xz-utils ;; |
| 108 | fedora) sudo dnf install -y zsh git vim curl wget unzip zip @development-tools xz ;; |
| 109 | arch) sudo pacman -S --noconfirm zsh git vim curl wget unzip zip base-devel xz ;; |
| 110 | *) warn "Auto-install not supported for OS: $os. Please install zsh, git, vim, curl manually." ;; |
| 111 | esac |
| 112 | ok "Packages installed" |
| 113 | } |
| 114 | |
| 115 | set_timezone() { |
| 116 | log "Checking timezone configuration..." |
| 117 | if command -v timedatectl >/dev/null 2>&1 && timedatectl status >/dev/null 2>&1; then |
| 118 | sudo timedatectl set-timezone Asia/Singapore |
| 119 | ok "Timezone set to Asia/Singapore" |
| 120 | return |
| 121 | fi |
| 122 | |
| 123 | warn "Skipping timezone change: timedatectl is not available in this WSL environment" |
| 124 | } |
| 125 | |
| 126 | install_homebrew() { |
| 127 | ! $INSTALL_HOMEBREW && return |
| 128 | command -v brew >/dev/null 2>&1 && ok "Homebrew already installed" && return |
| 129 | log "Installing Homebrew..." |
| 130 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
| 131 | ok "Homebrew installed" |
| 132 | } |
| 133 | |
| 134 | configure_shell() { |
| 135 | $SKIP_SHELL_CHANGE && return |
| 136 | log "Changing default shell to zsh..." |
| 137 | local zsh_path |
| 138 | zsh_path="$(command -v zsh)" |
| 139 | |
| 140 | if ! grep -qx "$zsh_path" /etc/shells; then |
| 141 | echo "$zsh_path" | sudo tee -a /etc/shells >/dev/null |
| 142 | fi |
| 143 | chsh -s "$zsh_path" |
| 144 | ok "Shell changed (open a new WSL session for it to take effect)" |
| 145 | } |
| 146 | |
| 147 | install_oh_my_zsh() { |
| 148 | log "Installing Oh My Zsh and plugins..." |
| 149 | export RUNZSH=no |
| 150 | export CHSH=no |
| 151 | export KEEP_ZSHRC=yes |
| 152 | |
| 153 | if [[ ! -d "$HOME/.oh-my-zsh" ]]; then |
| 154 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" --unattended |
| 155 | else |
| 156 | ok "Oh My Zsh already installed" |
| 157 | fi |
| 158 | |
| 159 | local custom="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}" |
| 160 | local plugin_dir="$custom/plugins" |
| 161 | mkdir -p "$plugin_dir" |
| 162 | |
| 163 | [[ -d "$plugin_dir/zsh-autosuggestions" ]] || git clone https://github.com/zsh-users/zsh-autosuggestions "$plugin_dir/zsh-autosuggestions" |
| 164 | [[ -d "$plugin_dir/zsh-syntax-highlighting" ]] || git clone https://github.com/zsh-users/zsh-syntax-highlighting "$plugin_dir/zsh-syntax-highlighting" |
| 165 | |
| 166 | ok "Oh My Zsh plugins installed to $plugin_dir" |
| 167 | } |
| 168 | |
| 169 | install_starship() { |
| 170 | if command -v starship >/dev/null 2>&1 || [[ -f "$HOME/.local/bin/starship" ]]; then |
| 171 | ok "Starship already installed" |
| 172 | else |
| 173 | log "Installing Starship prompt..." |
| 174 | if command -v brew >/dev/null 2>&1; then |
| 175 | brew install starship |
| 176 | else |
| 177 | mkdir -p "$HOME/.local/bin" |
| 178 | curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin" |
| 179 | fi |
| 180 | ok "Starship installed" |
| 181 | fi |
| 182 | } |
| 183 | |
| 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 |
| 203 | rm -f "$tmp" |
| 204 | warn "Failed to download $remote_file" |
| 205 | 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" |
| 223 | fi |
| 224 | } |
| 225 | |
| 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 | configure_git_identity() { |
| 268 | log "Configuring global Git identity..." |
| 269 | git config --global user.name "Vernon Wee Hong KOH" |
| 270 | git config --global user.email "261549452+weehong@users.noreply.github.com" |
| 271 | ok "Global Git identity configured" |
| 272 | } |
| 273 | |
| 274 | switch_shell() { |
| 275 | log "Starting Zsh session..." |
| 276 | echo -e "${YELLOW}Type 'exit' to return to this installer menu.${NC}" |
| 277 | echo "----------------------------------------" |
| 278 | zsh -l |
| 279 | echo "----------------------------------------" |
| 280 | ok "Returned from Zsh session" |
| 281 | } |
| 282 | |
| 283 | # ============================= |
| 284 | # INTERACTIVE MENU |
| 285 | # ============================= |
| 286 | show_menu() { |
| 287 | echo "===========================================" |
| 288 | echo "WSL Minimal Zsh Installer - Choose what to do" |
| 289 | echo "===========================================" |
| 290 | echo " 0) Run ALL steps (1-11)" |
| 291 | echo " 1) Update system packages" |
| 292 | echo " 2) Install core packages (zsh, git, vim, etc.)" |
| 293 | echo " 3) Set Timezone (best effort)" |
| 294 | echo " 4) Install Homebrew" |
| 295 | echo " 5) Configure shell (chsh - sets default shell)" |
| 296 | echo " 6) Install Oh My Zsh + plugins" |
| 297 | echo " 7) Install Starship prompt" |
| 298 | echo " 8) Download custom configs (from OpenGist)" |
| 299 | echo " 9) Configure ~/.zshrc (Oh My Zsh + Starship)" |
| 300 | echo "10) Configure global Git identity" |
| 301 | echo "11) Switch to Zsh (Temporary Sub-shell)" |
| 302 | echo "12) Quit" |
| 303 | echo "===========================================" |
| 304 | } |
| 305 | |
| 306 | run_choices() { |
| 307 | local input |
| 308 | read -p "Select: " input |
| 309 | input="${input//,/ }" |
| 310 | |
| 311 | local -a to_run=() |
| 312 | local -a to_exclude=() |
| 313 | |
| 314 | for item in $input; do |
| 315 | if [[ "$item" == !* ]]; then |
| 316 | to_exclude+=("${item:1}") |
| 317 | elif [[ "$item" == "0" ]]; then |
| 318 | to_run+=(1 2 3 4 5 6 7 8 9 10 11) |
| 319 | else |
| 320 | to_run+=("$item") |
| 321 | fi |
| 322 | done |
| 323 | |
| 324 | for choice in "${to_run[@]}"; do |
| 325 | local skip=false |
| 326 | |
| 327 | for ex in "${to_exclude[@]}"; do |
| 328 | if [[ "$choice" == "$ex" ]]; then |
| 329 | skip=true |
| 330 | break |
| 331 | fi |
| 332 | done |
| 333 | |
| 334 | $skip && continue |
| 335 | |
| 336 | case "$choice" in |
| 337 | 1) update_system ;; |
| 338 | 2) install_packages ;; |
| 339 | 3) set_timezone ;; |
| 340 | 4) install_homebrew ;; |
| 341 | 5) configure_shell ;; |
| 342 | 6) install_oh_my_zsh ;; |
| 343 | 7) install_starship ;; |
| 344 | 8) download_configs ;; |
| 345 | 9) configure_zshrc ;; |
| 346 | 10) configure_git_identity ;; |
| 347 | 11) switch_shell ;; |
| 348 | 12) log "Exiting..."; exit 0 ;; |
| 349 | *) warn "Skipping invalid option: $choice" ;; |
| 350 | esac |
| 351 | echo |
| 352 | done |
| 353 | } |
| 354 | |
| 355 | # ============================= |
| 356 | # MAIN |
| 357 | # ============================= |
| 358 | main() { |
| 359 | check_requirements |
| 360 | while true; do |
| 361 | show_menu |
| 362 | run_choices |
| 363 | read -p "Do you want to run more options? (y/n): " again |
| 364 | [[ "$again" =~ ^[Yy]$ ]] || break |
| 365 | done |
| 366 | ok "WSL Zsh installation/configuration complete!" |
| 367 | } |
| 368 | |
| 369 | main "$@" |
| 370 |
| 1 | # ============================================================================= |
| 2 | # 1. HELPER FUNCTIONS & PATH |
| 3 | # ============================================================================= |
| 4 | source_if_readable() { |
| 5 | local file="$1" |
| 6 | if [[ -f "$file" && -r "$file" ]]; then |
| 7 | source "$file" |
| 8 | fi |
| 9 | } |
| 10 | |
| 11 | # Ensure local bin is in PATH early (catches manual Starship installations) |
| 12 | export PATH="$HOME/.local/bin:$PATH" |
| 13 | |
| 14 | # ============================================================================= |
| 15 | # 2. OH MY ZSH FRAMEWORK |
| 16 | # ============================================================================= |
| 17 | export ZSH="${ZSH:-$HOME/.oh-my-zsh}" |
| 18 | ZSH_THEME="" |
| 19 | |
| 20 | plugins=( |
| 21 | git |
| 22 | zsh-autosuggestions |
| 23 | zsh-syntax-highlighting |
| 24 | ) |
| 25 | |
| 26 | source_if_readable "$ZSH/oh-my-zsh.sh" |
| 27 | |
| 28 | # ============================================================================= |
| 29 | # 3. THIRD-PARTY INITIALIZATION & CUSTOM CONFIGS |
| 30 | # ============================================================================= |
| 31 | # Load external initializers (SDKMAN, NVM, etc.) |
| 32 | source_if_readable "$HOME/.sourcerc" |
| 33 | |
| 34 | # Custom Functions (must load before Path Management) |
| 35 | source_if_readable "$HOME/.func" |
| 36 | |
| 37 | # Path Management (Relies on append_path from .func) |
| 38 | source_if_readable "$HOME/.pathrc" |
| 39 | |
| 40 | # Aliases (Loaded late so they override framework/system defaults) |
| 41 | source_if_readable "$HOME/.alias" |
| 42 | |
| 43 | # ============================================================================= |
| 44 | # 4. STARSHIP PROMPT |
| 45 | # ============================================================================= |
| 46 | if [[ -z "${STARSHIP_CONFIG:-}" && -f "$HOME/.config/starship.toml" ]]; then |
| 47 | export STARSHIP_CONFIG="$HOME/.config/starship.toml" |
| 48 | fi |
| 49 | |
| 50 | # Starship owns the prompt. It must initialize after Oh My Zsh. |
| 51 | if command -v starship >/dev/null 2>&1; then |
| 52 | eval "$(starship init zsh)" |
| 53 | elif [[ -x "$HOME/.local/bin/starship" ]]; then |
| 54 | eval "$("$HOME/.local/bin/starship" init zsh)" |
| 55 | fi |
| 56 |