最後活躍 1 day ago

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

README.md 原始檔案

Zsh Setup

Cross-platform Zsh installers and managed dotfiles for Oh My Zsh and Starship.

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)"

The platform installers share config.sh, which owns the managed-file list and download logic. Both configuration menu options install the canonical zshrc file. Existing files are replaced only after a non-empty download succeeds; failures return a nonzero status. Updates are atomic per file, not across the whole configuration set.

When run from a checkout, installers load the adjacent config.sh. The curl-based entry points fetch it from the gist. GIST_RAW_BASE overrides the download base URL for both the helper and managed files.

Repository layout

Files Purpose
zsh_ubuntu.sh, zsh_wsl.sh, zsh_macos.sh Platform setup and interactive menus
config.sh Shared configuration installer; also runnable on its own
zshrc, alias, func, pathrc, sourcerc, vimrc Managed shell and editor dotfiles
starship.toml Prompt configuration
ai-commit*.md, ai-commit*.txt Commit agents and prompts
test_*.py, test_plan_build.zsh Isolated regression tests

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 default to per-module commits. In an interactive terminal, the function asks whether to split; declining requests one combined commit. Without a terminal, or with --yes, it splits automatically. Use --single to explicitly request one commit, or --modules to split at any size; these options are mutually exclusive. The count uses the full staged set before diff filtering or truncation, including lock/minified files and both paths of a rename. Exactly 30 paths retains the single-commit default:

aicommit --modules opencode
aicommit --modules --yes claude
aicommit --single opencode

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.md
  • ARCHITECT.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.

Development checks

Run from the repository root with Bash, Zsh, Python 3, Git, and curl installed:

for file in *.sh; do bash -n "$file" || exit; done
for file in alias func pathrc sourcerc zshrc; do zsh -n "$file" || exit; done
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -p 'test_*.py'
zsh -f test_plan_build.zsh
git diff --check

Tests use temporary homes and local fixtures rather than installing packages or invoking AI services.

ai-commit-plan.md 原始檔案

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.

ai-commit-prompt.txt 原始檔案
1# Git Commit Message Generator Prompt
2
3## Role
4You are an Automated Git Commit Engine.
5
6## Objective
7Analyze 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
12The user will provide the output of:
13```bash
14git diff --cached
15```
16
17---
18
19## Strict Rules (Zero Tolerance)
20
21### Mandatory Module Splitting
22Changes affecting more than 30 staged paths MUST be modularized into
23multiple commits. Do not generate or execute a single combined commit for
24them. Count the full staged set, including lock/minified files omitted from
25the diff preview and both paths of a rename, not just the visible diff.
26If this input has more than 30 staged paths and is not explicitly identified
27as one module in a multi-commit plan, stop and request `aicommit --modules`.
28This refusal takes precedence over the output and execution instructions below.
29When the input says "commit X of Y, split by module" with Y greater than 1,
30generate and execute only that module's commit. The threshold applies to
31the 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
62Identify the **largest cluster of related changes** (the "Primary Scope").
63
64**Priority Order for Type Selection:**
651. `ci` - Pipeline, CI/CD configuration changes
662. `feat` - New features
673. `fix` - Bug fixes
684. `refactor` - Code restructuring
695. `build` - Build system, dependencies
706. `test` - Test additions/modifications
717. `chore` - Maintenance tasks
728. `docs` - Documentation only (use ONLY if no code changes exist)
73
74### Step 2: Classify Changes
75Categorize 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
86Structure the message exactly like this:
87```text
88type(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
100You must output **ONLY** a single bash code block containing the complete git command. Do not provide any conversational filler, explanations, or greeting.
101
102Once 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
109git 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
124git 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
135git 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
146git 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
ai-commit-split-prompt.txt 原始檔案
1# Git Commit Split Planner Prompt
2
3## Role
4You are an Automated Git Commit Planner.
5
6## Objective
7Split 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
9messages and you do NOT run any commands.
10
11---
12
13## Input
141. The staged file list (`git diff --cached --name-status -M`)
152. A diffstat (`git diff --cached --numstat -M`: added, deleted, path)
163. The staged diff (lock files and minified files are omitted; it may be truncated)
17
18---
19
20## What Is a Module
21A module is one cohesive, independently reviewable unit of change that maps
22to 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
391. 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.
412. A rename line (`R<score> old new`) lists two paths. Put BOTH in the same
42 commit.
433. Never split a single file across commits.
444. Lock files go in the same commit as the manifest that changed them
45 (e.g. `package.json` with `package-lock.json`).
465. 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.
496. Each commit should have ONE type and ONE scope. If a group would need two
50 types (e.g. `feat` and `docs`), split it.
517. 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.
598. 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
67Output 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
ai-commit.md 原始檔案

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.

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