# gitea-cli 擴充實作計畫 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把 `gitea-cli` 從單一 `publish.sh` 擴充成 10 支腳本的 Gitea 操作工具集;所有腳本共用 `scripts/lib/common.sh`。 **Architecture:** 多支獨立 bash 腳本 + 單一共用 lib。每支腳本 `source` lib、呼叫 `load_config`,以 `gitea_api` / `git_*_with_token` 等 function 操作;token 只在 push/clone 當下透過一次性 credential helper 注入,永遠不落地到 `.git/config`。 **Tech Stack:** bash、curl、jq、git、自架 Gitea(API v1) **測試策略:** 依 spec「無自動化測試,手動驗收」。每個 task 的驗證步驟包含:`bash -n` 語法檢查、可離線跑的 unit-like 驗證、需要時標註「需要 live Gitea」的 smoke test。 --- ## 前置條件 在開始 Task 1 之前確認以下: - [ ] 已讀過 `docs/superpowers/specs/2026-04-21-gitea-cli-expansion-design.md` - [ ] `/Users/timmy/Projects/Personal/gitea-cli/` 為 cwd - [ ] `config.env` 已存在且 `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` 皆已填值(需要 live smoke test 時會用到) - [ ] 系統上已安裝 `curl`、`git`、`jq`;`column` 指令可用 - 檢查:`command -v curl git jq column`;缺任何一個停下來先裝 - [ ] 已 `git status`,工作區乾淨 ## 關鍵不變量(每個 task 都要守) 1. Token 只能出現在:`config.env`、呼叫過程的環境變數、`gitea_api` / `git_*_with_token` 函式內部 2. Token **絕不**進入:`.git/config`、remote URL、任何 log(stdout/stderr) 3. `origin` remote 永遠是乾淨 URL(push/clone 完成後) 4. `publish.sh` 既有呼叫方式(`publish.sh [repo] [public|private]`)行為完全不變 5. 每支腳本都要 `set -euo pipefail`;用到 `mktemp` 時要 `trap` 清 --- ## Task 1:建立 `scripts/lib/common.sh` **Files:** - Create: `scripts/lib/common.sh` **Context:** 所有後續腳本的基礎。把重複的 config 載入、API 呼叫、一次性 token 注入、確認提示、log 都集中在這裡。後續任務會 `source` 它。 - [ ] **Step 1: 建立檔案並寫入 lib 內容** ```bash #!/usr/bin/env bash # gitea-cli 共用 lib。 # 使用方式(呼叫端): # SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source "${SCRIPT_DIR}/lib/common.sh" # load_config # # 重要:所有 API 呼叫走 gitea_api;所有 push/clone 走 git_push_with_token / git_clone_with_token。 # token 絕不允許出現在 .git/config、remote URL、log 中。 set -euo pipefail _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" _SKILL_DIR="$(cd "${_LIB_DIR}/../.." && pwd)" CONFIG_FILE="${_SKILL_DIR}/config.env" GITEA_API_RESP_FILE="" _cleanup_common() { [[ -n "${GITEA_API_RESP_FILE:-}" && -f "${GITEA_API_RESP_FILE}" ]] && rm -f "${GITEA_API_RESP_FILE}" } trap _cleanup_common EXIT # === logging(全部寫 stderr;stdout 留給結構化輸出)=== log_info() { printf '▶ %s\n' "$*" >&2; } log_ok() { printf '✓ %s\n' "$*" >&2; } log_warn() { printf '⚠ %s\n' "$*" >&2; } log_err() { printf '❌ %s\n' "$*" >&2; } log_done() { printf '✅ %s\n' "$*" >&2; } # === 必要指令檢查 === require_cmd() { local missing=() for c in "$@"; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done if [[ ${#missing[@]} -gt 0 ]]; then log_err "缺少指令:${missing[*]}" exit 1 fi } # === config 載入 === load_config() { require_cmd curl git if [[ ! -f "$CONFIG_FILE" ]]; then log_err "找不到設定檔:${CONFIG_FILE}" { echo "" echo "請先建立:" echo " cp ${_SKILL_DIR}/config.example.env ${CONFIG_FILE}" echo "" echo "然後編輯 ${CONFIG_FILE} 填入 GITEA_URL / GITEA_USER / GITEA_TOKEN" } >&2 exit 1 fi # shellcheck disable=SC1090 source "$CONFIG_FILE" : "${GITEA_URL:?GITEA_URL 未設定(請編輯 ${CONFIG_FILE})}" : "${GITEA_USER:?GITEA_USER 未設定(請編輯 ${CONFIG_FILE})}" : "${GITEA_TOKEN:?GITEA_TOKEN 未設定(請編輯 ${CONFIG_FILE})}" if [[ "$GITEA_TOKEN" == "請貼上你的_access_token" || -z "$GITEA_TOKEN" ]]; then log_err "GITEA_TOKEN 仍是預設值,請編輯 ${CONFIG_FILE}" exit 1 fi GITEA_URL="${GITEA_URL%/}" export GITEA_URL GITEA_USER GITEA_TOKEN } # === Gitea API 呼叫 === # gitea_api [json-body] # 回傳後讀:$HTTP_CODE(狀態碼字串)、$GITEA_API_RESP_FILE(body 檔路徑) gitea_api() { local method="$1" local path="$2" local body="${3:-}" local url="${GITEA_URL}/api/v1${path}" GITEA_API_RESP_FILE="$(mktemp)" local -a curl_args=( -sS -o "$GITEA_API_RESP_FILE" -w '%{http_code}' -X "$method" -H "Authorization: token ${GITEA_TOKEN}" ) if [[ -n "$body" ]]; then curl_args+=(-H "Content-Type: application/json" -d "$body") fi HTTP_CODE="$(curl "${curl_args[@]}" "$url")" export HTTP_CODE GITEA_API_RESP_FILE } # === 一次性帶 token 的 git push / clone === _git_with_token() { git \ -c 'credential.helper=' \ -c "credential.helper=!f() { echo username=${GITEA_USER}; echo password=${GITEA_TOKEN}; }; f" \ "$@" } git_push_with_token() { local branch="$1" _git_with_token push -u origin "$branch" } git_push_tag_with_token() { local tag="$1" _git_with_token push origin "$tag" } git_clone_with_token() { local clean_url="$1" local target_dir="$2" _git_with_token clone "$clean_url" "$target_dir" # 再確保 origin 是乾淨 URL(clone 本來就是用 clean_url,這裡是防呆) git -C "$target_dir" remote set-url origin "$clean_url" } # === 從 cwd origin 推斷 repo 名稱 === # 成功時 echo "owner/repo" 並回傳 0;失敗回傳非 0 infer_repo_from_cwd() { local url if ! url="$(git remote get-url origin 2>/dev/null)"; then return 1 fi # 支援 http(s)://host[:port]/owner/repo[.git] local path="${url#*://}" path="${path#*/}" path="${path%.git}" [[ -z "$path" ]] && return 1 echo "$path" } # === 確認提示 === # confirm_destructive # 若 GITEA_YES=1 則直接通過;否則 y/N 互動 confirm_destructive() { local prompt="$1" if [[ "${GITEA_YES:-0}" == "1" ]]; then return 0 fi if [[ ! -r /dev/tty ]]; then log_err "需要確認但無 TTY;請加 -y / --yes 或設定 GITEA_YES=1" exit 1 fi local ans printf '%s [y/N] ' "$prompt" >&2 read -r ans < /dev/tty case "$ans" in y|Y|yes|YES) return 0 ;; *) log_err "取消"; exit 1 ;; esac } # confirm_exact_match # 要求使用者重新輸入完整字串才通過;GITEA_YES=1 可略過 confirm_exact_match() { local expected="$1" if [[ "${GITEA_YES:-0}" == "1" ]]; then return 0 fi if [[ ! -r /dev/tty ]]; then log_err "需要確認但無 TTY;請加 -y / --yes 或設定 GITEA_YES=1" exit 1 fi local ans printf '請完整輸入 "%s" 以確認:' "$expected" >&2 read -r ans < /dev/tty if [[ "$ans" == "$expected" ]]; then return 0 fi log_err "輸入不符合,取消" exit 1 } ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/lib/common.sh` Expected: 無輸出、exit 0 - [ ] **Step 3: Smoke test —— lib 可被 source 且 function 存在** Run: ```bash bash -c ' set -euo pipefail source scripts/lib/common.sh for fn in log_info log_ok log_warn log_err log_done require_cmd load_config gitea_api git_push_with_token git_push_tag_with_token git_clone_with_token infer_repo_from_cwd confirm_destructive confirm_exact_match; do declare -F "$fn" >/dev/null || { echo "MISSING: $fn"; exit 1; } done echo OK ' ``` Expected: `OK` - [ ] **Step 4: Smoke test —— log function 確實寫 stderr** Run: `bash -c 'source scripts/lib/common.sh; log_info hi' 2>/tmp/err 1>/tmp/out && [[ -z "$(cat /tmp/out)" ]] && grep -q '▶ hi' /tmp/err && echo OK` Expected: `OK` - [ ] **Step 5: Commit** ```bash git add scripts/lib/common.sh git commit -m "feat(lib): add shared common.sh for gitea-cli scripts" ``` --- ## Task 2:重構 `publish.sh` 使用 common.sh(外部行為不變) **Files:** - Modify: `scripts/publish.sh`(整個重寫,但對外行為與既有版本相同) **Context:** 把原本 `publish.sh` 裡的 config 載入、API 呼叫、credential helper 改成用 common.sh 的對應 function。此階段**不**新增 `-m` 選項;-m 在 Task 3 加。 - [ ] **Step 1: 重寫 `scripts/publish.sh`** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config REPO_NAME="${1:-$(basename "$PWD")}" VISIBILITY="${2:-public}" case "$VISIBILITY" in public) PRIVATE=false ;; private) PRIVATE=true ;; *) log_err "可見性只能是 public 或 private,收到:${VISIBILITY}" exit 1 ;; esac CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${REPO_NAME}.git" WEB_URL="${GITEA_URL}/${GITEA_USER}/${REPO_NAME}" log_info "建立 Gitea repo:${GITEA_USER}/${REPO_NAME} (${VISIBILITY})" gitea_api POST "/user/repos" \ "{\"name\":\"${REPO_NAME}\",\"private\":${PRIVATE},\"auto_init\":false,\"default_branch\":\"main\"}" case "$HTTP_CODE" in 201) log_ok "repo 已建立" ;; 409) log_warn "repo 已存在,略過建立步驟" ;; 401|403) log_err "Gitea 授權失敗 (HTTP ${HTTP_CODE})" { echo "請確認 ${CONFIG_FILE} 的 GITEA_TOKEN 是否正確、權限是否包含 write:repository" cat "$GITEA_API_RESP_FILE" echo "" } >&2 exit 1 ;; *) log_err "建立 repo 失敗 (HTTP ${HTTP_CODE})" { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2 exit 1 ;; esac if [[ ! -d .git ]]; then log_info "git init" git init -b main >/dev/null fi if ! git rev-parse --verify HEAD >/dev/null 2>&1; then git add -A if git diff --cached --quiet; then log_info "工作目錄沒有檔案,建立空的 initial commit" git commit --allow-empty -m "Initial commit" >/dev/null else log_info "建立 initial commit" git commit -m "Initial commit" >/dev/null fi else git add -A if ! git diff --cached --quiet; then log_info "提交未提交的變更" git commit -m "Update" >/dev/null fi fi BRANCH="$(git rev-parse --abbrev-ref HEAD)" if git remote get-url origin >/dev/null 2>&1; then git remote set-url origin "$CLEAN_URL" else git remote add origin "$CLEAN_URL" fi log_info "推送到 origin (${CLEAN_URL}) branch=${BRANCH}" git_push_with_token "$BRANCH" log_done "完成" echo " Branch: ${BRANCH}" >&2 # stdout:最終結構化輸出 echo "$WEB_URL" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/publish.sh` Expected: 無輸出、exit 0 - [ ] **Step 3: Smoke test(離線)—— `--help` 式錯誤輸入** Run: `bash scripts/publish.sh foo bogus 2>&1 | grep -q '可見性只能是 public 或 private' && echo OK` Expected: `OK` - [ ] **Step 4: 需要 live Gitea 的手動驗收**(可延到 Task 14 統一做) 在一個 tmp 目錄試推: ```bash mkdir -p /tmp/gitea-cli-smoke-$(date +%s) && cd /tmp/gitea-cli-smoke-* && echo hi > hi.txt URL="$(bash /path/to/gitea-cli/scripts/publish.sh gitea-cli-smoke-$(date +%s) private)" echo "publish.sh stdout: $URL" grep -q "${GITEA_TOKEN:-NEVER}" .git/config && { echo LEAK; exit 1; } || echo "no token leak" git remote get-url origin # 應為乾淨 URL ``` Expected: stdout 印 web URL;`.git/config` 不含 token;remote origin 為乾淨 URL。 - [ ] **Step 5: Commit** ```bash git add scripts/publish.sh git commit -m "refactor(publish): use shared common.sh lib" ``` --- ## Task 3:`publish.sh` 新增 `-m "msg"` 選項 **Files:** - Modify: `scripts/publish.sh`(換成支援 `-m` 的 arg parsing) **Context:** 讓使用者可指定 commit 訊息取代預設的 `Initial commit` / `Update`。既有位置參數必須繼續運作。 - [ ] **Step 1: 替換 arg parsing 與 commit 訊息邏輯** 把 Task 2 的 `REPO_NAME="${1:-...}" / VISIBILITY="${2:-...}"` 段落換成以下 while 迴圈,並把寫 commit 的兩處改用 `${COMMIT_MSG:-...}`: ```bash REPO_NAME="" VISIBILITY="public" COMMIT_MSG="" while [[ $# -gt 0 ]]; do case "$1" in -m) [[ $# -ge 2 ]] || { log_err "-m 需要一個訊息參數"; exit 1; } COMMIT_MSG="$2" shift 2 ;; public|private) VISIBILITY="$1" shift ;; -h|--help) cat >&2 <<'EOF' Usage: publish.sh [repo] [public|private] [-m "commit message"] EOF exit 0 ;; -*) log_err "未知參數:$1" exit 1 ;; *) if [[ -z "$REPO_NAME" ]]; then REPO_NAME="$1" else log_err "多餘參數:$1" exit 1 fi shift ;; esac done REPO_NAME="${REPO_NAME:-$(basename "$PWD")}" ``` 然後把兩處 commit 呼叫改成: ```bash # 第一處(沒任何 commit 時) git commit --allow-empty -m "${COMMIT_MSG:-Initial commit}" >/dev/null # ...另一分支 git commit -m "${COMMIT_MSG:-Initial commit}" >/dev/null # 第二處(已有 commit、有新變更時) git commit -m "${COMMIT_MSG:-Update}" >/dev/null ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/publish.sh` Expected: 無輸出 - [ ] **Step 3: 既有呼叫方式不變** Run: `bash scripts/publish.sh -h 2>&1 | grep -q 'Usage: publish.sh' && echo OK` Expected: `OK` Run: `bash scripts/publish.sh foo bogus 2>&1 | grep -q '可見性只能是 public 或 private' && echo OK` Expected: `OK` - [ ] **Step 4: Commit** ```bash git add scripts/publish.sh git commit -m "feat(publish): add -m option for custom commit message" ``` --- ## Task 4:建立 `scripts/list.sh` **Files:** - Create: `scripts/list.sh` **Context:** 列出 `GITEA_USER` 名下所有 repo,支援可見性篩選與三種輸出格式。會自動翻頁。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config require_cmd jq column FILTER="" FORMAT="table" usage() { cat >&2 <<'EOF' Usage: list.sh [--private|--public] [--format table|tsv|name] --private 只列出 private repo --public 只列出 public repo --format FORMAT table (default) | tsv | name EOF exit 1 } while [[ $# -gt 0 ]]; do case "$1" in --private) FILTER="private"; shift ;; --public) FILTER="public"; shift ;; --format) [[ $# -ge 2 ]] || { log_err "--format 需要一個值"; exit 1; } FORMAT="$2"; shift 2 ;; -h|--help) usage ;; *) log_err "未知參數:$1"; usage ;; esac done case "$FORMAT" in table|tsv|name) ;; *) log_err "--format 只能是 table / tsv / name"; exit 1 ;; esac PAGE=1 LIMIT=50 ALL_ITEMS="$(mktemp)" trap 'rm -f "$ALL_ITEMS"' EXIT while :; do gitea_api GET "/user/repos?limit=${LIMIT}&page=${PAGE}" if [[ "$HTTP_CODE" != "200" ]]; then log_err "GET /user/repos 失敗 (HTTP ${HTTP_CODE})" cat "$GITEA_API_RESP_FILE" >&2 exit 1 fi jq -c '.[]' < "$GITEA_API_RESP_FILE" >> "$ALL_ITEMS" COUNT="$(jq 'length' < "$GITEA_API_RESP_FILE")" [[ "$COUNT" -lt "$LIMIT" ]] && break PAGE=$((PAGE + 1)) done filter_jq="." case "$FILTER" in private) filter_jq='select(.private == true)' ;; public) filter_jq='select(.private == false)' ;; esac TOTAL="$(jq -s "map($filter_jq) | length" < "$ALL_ITEMS")" log_info "抓到 ${TOTAL} 個 repo" case "$FORMAT" in name) jq -r "$filter_jq | .name" < "$ALL_ITEMS" ;; tsv) jq -r "$filter_jq | [.name, (if .private then \"private\" else \"public\" end), (.description // \"\")] | @tsv" < "$ALL_ITEMS" ;; table) { printf 'NAME\tVISIBILITY\tDESCRIPTION\n' jq -r "$filter_jq | [.name, (if .private then \"private\" else \"public\" end), (.description // \"\")] | @tsv" < "$ALL_ITEMS" } | column -t -s $'\t' ;; esac ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/list.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test(參數錯誤路徑)** Run: `bash scripts/list.sh --format bogus 2>&1 | grep -q "--format 只能是" && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** Run: `bash scripts/list.sh --format name | head -5` Expected: 印出 ≤5 個 repo 名稱(若帳號沒有任何 repo 就空白,也算成功) Run: `bash scripts/list.sh --format tsv | head -3` Expected: 每行三欄用 tab 分隔 - [ ] **Step 5: Commit** ```bash git add scripts/list.sh git commit -m "feat: add list.sh to list repos with filter & format options" ``` --- ## Task 5:建立 `scripts/clone.sh` **Files:** - Create: `scripts/clone.sh` **Context:** clone 指定 repo 到本地,預設目標資料夾為 `./`,可指定替代路徑;clone 完成後 origin 仍是乾淨 URL。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config usage() { cat >&2 <<'EOF' Usage: clone.sh [target-dir] GITEA_USER 名下的 repo 名稱 [target-dir] clone 目的地(預設 ./) EOF exit 1 } [[ $# -ge 1 && $# -le 2 ]] || usage case "$1" in -h|--help) usage ;; esac REPO="$1" TARGET="${2:-./${REPO}}" if [[ -e "$TARGET" ]]; then # 空目錄 OK;其他情況拒絕 if [[ -d "$TARGET" ]]; then if [[ -n "$(ls -A "$TARGET")" ]]; then log_err "目標目錄已存在且非空:${TARGET}" exit 1 fi else log_err "目標路徑已存在且不是目錄:${TARGET}" exit 1 fi fi CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${REPO}.git" log_info "clone ${GITEA_USER}/${REPO} → ${TARGET}" git_clone_with_token "$CLEAN_URL" "$TARGET" ABS="$(cd "$TARGET" && pwd)" log_done "完成" echo "$ABS" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/clone.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test(參數錯誤)** Run: `bash scripts/clone.sh 2>&1 | grep -q 'Usage: clone.sh' && echo OK` Expected: `OK` Run: `mkdir -p /tmp/ccc && echo x > /tmp/ccc/y; bash scripts/clone.sh anyname /tmp/ccc 2>&1 | grep -q '目標目錄已存在且非空' && echo OK; rm -rf /tmp/ccc` Expected: `OK` - [ ] **Step 4: Live smoke test** 先用 `list.sh --format name | head -1` 取一個自己的 repo 名稱 ``: ```bash R="$(bash scripts/list.sh --format name | head -1)" DST="$(mktemp -d)/${R}" ABS="$(bash scripts/clone.sh "$R" "$DST")" echo "abs: $ABS" git -C "$DST" remote get-url origin | grep -q "${GITEA_TOKEN:-NEVER}" && echo LEAK || echo "no token leak" rm -rf "$(dirname "$DST")" ``` Expected: `abs: ...`;`no token leak` - [ ] **Step 5: Commit** ```bash git add scripts/clone.sh git commit -m "feat: add clone.sh that clones without leaking token to origin" ``` --- ## Task 6:建立 `scripts/rename.sh` **Files:** - Create: `scripts/rename.sh` **Context:** 改 repo 名稱;若 cwd 的 origin 正好指向舊 repo,順便 `set-url` 到新位置。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config usage() { cat >&2 <<'EOF' Usage: rename.sh EOF exit 1 } [[ $# -eq 2 ]] || usage OLD="$1" NEW="$2" log_info "改名 ${GITEA_USER}/${OLD} → ${GITEA_USER}/${NEW}" gitea_api PATCH "/repos/${GITEA_USER}/${OLD}" "{\"name\":\"${NEW}\"}" case "$HTTP_CODE" in 200|201) log_ok "改名完成" ;; 404) log_err "找不到 repo:${GITEA_USER}/${OLD}"; exit 1 ;; 422) log_err "新名稱可能已被使用或無效"; cat "$GITEA_API_RESP_FILE" >&2; echo "" >&2; exit 1 ;; *) log_err "改名失敗 (HTTP ${HTTP_CODE})" { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2 exit 1 ;; esac # 若 cwd 的 origin 指到舊 repo,自動更新 if CURR="$(infer_repo_from_cwd 2>/dev/null)"; then if [[ "$CURR" == "${GITEA_USER}/${OLD}" ]]; then NEW_CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${NEW}.git" git remote set-url origin "$NEW_CLEAN_URL" log_ok "cwd 的 origin 已更新為 ${NEW_CLEAN_URL}" fi fi NEW_WEB_URL="${GITEA_URL}/${GITEA_USER}/${NEW}" log_done "完成" echo "$NEW_WEB_URL" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/rename.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test** Run: `bash scripts/rename.sh 2>&1 | grep -q 'Usage: rename.sh' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** 先用 `publish.sh` 在 tmp 目錄建一個測試 repo,再改名回來: ```bash D="$(mktemp -d)"; cd "$D"; echo a > a.txt NAME="gcli-rename-test-$(date +%s)" bash /path/to/gitea-cli/scripts/publish.sh "$NAME" private >/dev/null bash /path/to/gitea-cli/scripts/rename.sh "$NAME" "${NAME}-renamed" git remote get-url origin # 應是 ...-renamed.git bash /path/to/gitea-cli/scripts/rename.sh "${NAME}-renamed" "${NAME}-final" cd / && rm -rf "$D" # 清 gitea 側測試 repo:等 delete.sh 做完後再清,或手動從 Gitea UI 刪 ``` Expected: stdout 為新的 web URL;origin remote 被順手更新到 `-renamed.git` - [ ] **Step 5: Commit** ```bash git add scripts/rename.sh git commit -m "feat: add rename.sh that also updates local origin if applicable" ``` --- ## Task 7:建立 `scripts/visibility.sh` **Files:** - Create: `scripts/visibility.sh` **Context:** 切換 repo public / private。API 就是 `PATCH {"private": bool}`。不需要二次確認(非破壞性)。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config usage() { cat >&2 <<'EOF' Usage: visibility.sh EOF exit 1 } [[ $# -eq 2 ]] || usage REPO="$1" VIS="$2" case "$VIS" in public) PRIVATE=false ;; private) PRIVATE=true ;; *) log_err "可見性只能是 public 或 private"; exit 1 ;; esac log_info "切換 ${GITEA_USER}/${REPO} → ${VIS}" gitea_api PATCH "/repos/${GITEA_USER}/${REPO}" "{\"private\":${PRIVATE}}" case "$HTTP_CODE" in 200|201) log_ok "已更新" ;; 404) log_err "找不到 repo:${GITEA_USER}/${REPO}"; exit 1 ;; *) log_err "更新失敗 (HTTP ${HTTP_CODE})" { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2 exit 1 ;; esac log_done "完成" echo "${GITEA_URL}/${GITEA_USER}/${REPO}" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/visibility.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test** Run: `bash scripts/visibility.sh foo bogus 2>&1 | grep -q '可見性只能是' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** 用 Task 6 的測試 repo 切一輪 public/private 回去。 - [ ] **Step 5: Commit** ```bash git add scripts/visibility.sh git commit -m "feat: add visibility.sh to toggle public/private" ``` --- ## Task 8:建立 `scripts/meta.sh` **Files:** - Create: `scripts/meta.sh` **Context:** 一支腳本處理 description 與 topics。Topics API 是獨立 endpoint 且是覆蓋式,要注意順序:先打 description(若指定),再打 topics(若指定)。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config require_cmd jq usage() { cat >&2 <<'EOF' Usage: meta.sh [--desc "description"] [--topics "a,b,c"] --desc 設定 repo description --topics 覆蓋式設定 topic list(逗號分隔) EOF exit 1 } [[ $# -ge 1 ]] || usage REPO="" DESC="" HAS_DESC=0 TOPICS_CSV="" HAS_TOPICS=0 while [[ $# -gt 0 ]]; do case "$1" in --desc) [[ $# -ge 2 ]] || { log_err "--desc 需要一個值"; exit 1; } DESC="$2"; HAS_DESC=1; shift 2 ;; --topics) [[ $# -ge 2 ]] || { log_err "--topics 需要一個值"; exit 1; } TOPICS_CSV="$2"; HAS_TOPICS=1; shift 2 ;; -h|--help) usage ;; -*) log_err "未知參數:$1"; usage ;; *) if [[ -z "$REPO" ]]; then REPO="$1"; else log_err "多餘參數:$1"; usage; fi shift ;; esac done [[ -n "$REPO" ]] || usage [[ $HAS_DESC -eq 1 || $HAS_TOPICS -eq 1 ]] || { log_err "必須至少提供 --desc 或 --topics"; usage; } if [[ $HAS_DESC -eq 1 ]]; then ESCAPED_DESC="$(printf '%s' "$DESC" | jq -Rs .)" log_info "更新 description" gitea_api PATCH "/repos/${GITEA_USER}/${REPO}" "{\"description\":${ESCAPED_DESC}}" case "$HTTP_CODE" in 200|201) log_ok "description 已更新" ;; 404) log_err "找不到 repo:${GITEA_USER}/${REPO}"; exit 1 ;; *) log_err "更新 description 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;; esac fi if [[ $HAS_TOPICS -eq 1 ]]; then TOPICS_JSON="$(printf '%s' "$TOPICS_CSV" | jq -R 'split(",") | map(select(length > 0))')" log_info "覆蓋 topics:${TOPICS_JSON}" gitea_api PUT "/repos/${GITEA_USER}/${REPO}/topics" "{\"topics\":${TOPICS_JSON}}" case "$HTTP_CODE" in 200|204) log_ok "topics 已更新" ;; 404) log_err "找不到 repo:${GITEA_USER}/${REPO}"; exit 1 ;; 422) log_err "topics 格式錯誤"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;; *) log_err "更新 topics 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;; esac fi log_done "完成" echo "${GITEA_URL}/${GITEA_USER}/${REPO}" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/meta.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test** Run: `bash scripts/meta.sh 2>&1 | grep -q 'Usage: meta.sh' && echo OK` Expected: `OK` Run: `bash scripts/meta.sh foo 2>&1 | grep -q '至少提供' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** ```bash bash scripts/meta.sh --desc "smoke test $(date)" --topics "test,cli,smoke" ``` Expected: 兩個 API 都成功;上 Gitea UI 可看到。 - [ ] **Step 5: Commit** ```bash git add scripts/meta.sh git commit -m "feat: add meta.sh for description and topics" ``` --- ## Task 9:建立 `scripts/archive.sh` **Files:** - Create: `scripts/archive.sh` **Context:** archive / unarchive。破壞性中等(可逆但影響閱讀/push);預設需要二次確認,可用 `-y` 或 `GITEA_YES=1` 略過。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config usage() { cat >&2 <<'EOF' Usage: archive.sh [--unarchive] [-y|--yes] EOF exit 1 } [[ $# -ge 1 ]] || usage REPO="" ACTION="archive" while [[ $# -gt 0 ]]; do case "$1" in --unarchive) ACTION="unarchive"; shift ;; -y|--yes) GITEA_YES=1; shift ;; -h|--help) usage ;; -*) log_err "未知參數:$1"; usage ;; *) if [[ -z "$REPO" ]]; then REPO="$1"; else log_err "多餘參數:$1"; usage; fi shift ;; esac done [[ -n "$REPO" ]] || usage if [[ "$ACTION" == "archive" ]]; then confirm_destructive "確定要 archive ${GITEA_USER}/${REPO}?" BODY='{"archived":true}' MSG="archived" else confirm_destructive "確定要 unarchive ${GITEA_USER}/${REPO}?" BODY='{"archived":false}' MSG="unarchived" fi log_info "${ACTION} ${GITEA_USER}/${REPO}" gitea_api PATCH "/repos/${GITEA_USER}/${REPO}" "$BODY" case "$HTTP_CODE" in 200|201) log_ok "$MSG" ;; 404) log_err "找不到 repo:${GITEA_USER}/${REPO}"; exit 1 ;; *) log_err "${ACTION} 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;; esac log_done "完成" echo "${GITEA_URL}/${GITEA_USER}/${REPO}" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/archive.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test(確認機制)** Run: `echo n | GITEA_YES=0 bash scripts/archive.sh some-repo 2>&1 | grep -q '取消' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** ```bash GITEA_YES=1 bash scripts/archive.sh GITEA_YES=1 bash scripts/archive.sh --unarchive ``` Expected: 兩次都回 `archived` / `unarchived`。 - [ ] **Step 5: Commit** ```bash git add scripts/archive.sh git commit -m "feat: add archive.sh with confirmation / -y bypass" ``` --- ## Task 10:建立 `scripts/delete.sh` **Files:** - Create: `scripts/delete.sh` **Context:** 最高破壞性:互動模式必須**重新輸入完整 repo 名稱**才執行;`-y` / `GITEA_YES=1` 可略過。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config usage() { cat >&2 <<'EOF' Usage: delete.sh [-y|--yes] 互動模式:會要求重新輸入完整 repo 名稱才執行。 -y / --yes 或 GITEA_YES=1:直接執行,不需輸入。 EOF exit 1 } [[ $# -ge 1 ]] || usage REPO="" while [[ $# -gt 0 ]]; do case "$1" in -y|--yes) GITEA_YES=1; shift ;; -h|--help) usage ;; -*) log_err "未知參數:$1"; usage ;; *) if [[ -z "$REPO" ]]; then REPO="$1"; else log_err "多餘參數:$1"; usage; fi shift ;; esac done [[ -n "$REPO" ]] || usage log_warn "即將刪除 ${GITEA_USER}/${REPO}(此操作不可復原)" confirm_exact_match "$REPO" log_info "刪除 ${GITEA_USER}/${REPO}" gitea_api DELETE "/repos/${GITEA_USER}/${REPO}" case "$HTTP_CODE" in 204) log_ok "已刪除" ;; 404) log_err "找不到 repo:${GITEA_USER}/${REPO}"; exit 1 ;; *) log_err "刪除失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;; esac log_done "完成" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/delete.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test(輸入錯誤名稱應被拒)** Run: `echo "wrong-name" | bash scripts/delete.sh real-name &1 || true` > 注意:此測試因 `< /dev/tty` 需要真實 TTY,建議手動跑而非自動化。改用 GITEA_YES=0 的環境測試: Run: `GITEA_YES=0 bash scripts/delete.sh some-repo &1 | grep -q '需要確認但無 TTY' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** 用 Task 6 建的測試 repo: ```bash # 互動模式(需手動輸入) bash scripts/delete.sh # 或一鍵: GITEA_YES=1 bash scripts/delete.sh ``` Expected: 回 `已刪除`;Gitea UI 上 repo 消失。 - [ ] **Step 5: Commit** ```bash git add scripts/delete.sh git commit -m "feat: add delete.sh with exact-name confirmation" ``` --- ## Task 11:建立 `scripts/tag.sh` **Files:** - Create: `scripts/tag.sh` **Context:** 純 local git tag + push。不呼叫 Gitea API。若在 cwd 的 git repo 裡跑,會推該 repo 的 origin;origin 必須已經設好(由之前的 publish/clone)。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config usage() { cat >&2 <<'EOF' Usage: tag.sh [-m "message"] 建立 annotated tag 並推到 origin。 需要在 cwd 已有 git repo 且 origin 已指到 Gitea。 EOF exit 1 } [[ $# -ge 1 ]] || usage TAG="" MSG="" while [[ $# -gt 0 ]]; do case "$1" in -m) [[ $# -ge 2 ]] || { log_err "-m 需要一個值"; exit 1; }; MSG="$2"; shift 2 ;; -h|--help) usage ;; -*) log_err "未知參數:$1"; usage ;; *) if [[ -z "$TAG" ]]; then TAG="$1"; else log_err "多餘參數:$1"; usage; fi shift ;; esac done [[ -n "$TAG" ]] || usage MSG="${MSG:-Tag $TAG}" if ! git rev-parse --git-dir >/dev/null 2>&1; then log_err "cwd 不是 git repo" exit 1 fi if git rev-parse "$TAG" >/dev/null 2>&1; then log_err "tag 已存在:${TAG}" exit 1 fi log_info "建立 tag ${TAG}" git tag -a "$TAG" -m "$MSG" log_info "推送 tag 到 origin" git_push_tag_with_token "$TAG" log_done "完成" echo "$TAG" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/tag.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test** Run: `cd /tmp && bash /path/to/gitea-cli/scripts/tag.sh v0 2>&1 | grep -q '不是 git repo' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** 在已 publish 過的 tmp repo 裡: ```bash cd bash /path/to/gitea-cli/scripts/tag.sh "test-$(date +%s)" -m "smoke" ``` Expected: tag 推上去;`grep -q $GITEA_TOKEN .git/config` 為空(`echo $?` 為 1 表示沒找到)。 - [ ] **Step 5: Commit** ```bash git add scripts/tag.sh git commit -m "feat: add tag.sh for annotated local tag + push" ``` --- ## Task 12:建立 `scripts/release.sh` **Files:** - Create: `scripts/release.sh` **Context:** 兩段 API:先建 release、再逐個上傳 asset。asset 失敗不中止其他 asset,但最終 exit 非零並報告。Repo 預設從 cwd origin 推斷,可用 `--repo` override。 - [ ] **Step 1: 建立檔案** ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config require_cmd jq usage() { cat >&2 <<'EOF' Usage: release.sh [--name NAME] [--notes TEXT] [--target BRANCH] [--draft] [--prerelease] [--asset PATH]... [--repo NAME] tag_name;若 tag 不存在 Gitea 會在 target 自動建 --name release 標題(預設同 tag) --notes release 說明 --target target_commitish;預設 HEAD 所在分支 --draft 建成 draft --prerelease 標記 prerelease --asset PATH 要上傳的附件(可重複) --repo NAME 指定 repo;省略則從 cwd origin 推斷 EOF exit 1 } [[ $# -ge 1 ]] || usage TAG="" NAME="" NOTES="" TARGET="" DRAFT=false PRERELEASE=false ASSETS=() REPO_OVERRIDE="" while [[ $# -gt 0 ]]; do case "$1" in --name) [[ $# -ge 2 ]] || { log_err "--name 需要值"; exit 1; }; NAME="$2"; shift 2 ;; --notes) [[ $# -ge 2 ]] || { log_err "--notes 需要值"; exit 1; }; NOTES="$2"; shift 2 ;; --target) [[ $# -ge 2 ]] || { log_err "--target 需要值"; exit 1; }; TARGET="$2"; shift 2 ;; --draft) DRAFT=true; shift ;; --prerelease) PRERELEASE=true; shift ;; --asset) [[ $# -ge 2 ]] || { log_err "--asset 需要值"; exit 1; }; ASSETS+=("$2"); shift 2 ;; --repo) [[ $# -ge 2 ]] || { log_err "--repo 需要值"; exit 1; }; REPO_OVERRIDE="$2"; shift 2 ;; -h|--help) usage ;; -*) log_err "未知參數:$1"; usage ;; *) if [[ -z "$TAG" ]]; then TAG="$1"; else log_err "多餘參數:$1"; usage; fi shift ;; esac done [[ -n "$TAG" ]] || usage NAME="${NAME:-$TAG}" if [[ -n "$REPO_OVERRIDE" ]]; then OWNER_REPO="${GITEA_USER}/${REPO_OVERRIDE}" else if ! OWNER_REPO="$(infer_repo_from_cwd)"; then log_err "無法從 cwd 推斷 repo;請加 --repo" exit 1 fi fi if [[ -z "$TARGET" ]]; then if git rev-parse --git-dir >/dev/null 2>&1; then TARGET="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" fi fi # 驗證 asset 路徑 for a in "${ASSETS[@]}"; do if [[ ! -f "$a" ]]; then log_err "找不到 asset 檔案:$a" exit 1 fi done # 組 release body BODY="$(jq -n \ --arg tag "$TAG" \ --arg name "$NAME" \ --arg body "$NOTES" \ --arg target "$TARGET" \ --argjson draft "$DRAFT" \ --argjson prerelease "$PRERELEASE" \ '{tag_name:$tag, name:$name, body:$body, draft:$draft, prerelease:$prerelease} + (if $target == "" then {} else {target_commitish:$target} end)')" log_info "建立 release ${OWNER_REPO} / ${TAG}" gitea_api POST "/repos/${OWNER_REPO}/releases" "$BODY" case "$HTTP_CODE" in 201) log_ok "release 已建立" ;; 404) log_err "找不到 repo:${OWNER_REPO}"; exit 1 ;; *) log_err "建立 release 失敗 (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;; esac RELEASE_ID="$(jq -r '.id' < "$GITEA_API_RESP_FILE")" RELEASE_URL="$(jq -r '.html_url' < "$GITEA_API_RESP_FILE")" # stdout 先印 release URL echo "$RELEASE_URL" # 上傳 asset FAILED=() for a in "${ASSETS[@]}"; do local_name="$(basename "$a")" log_info "上傳 asset:${local_name}" UP_RESP="$(mktemp)" UP_CODE="$(curl -sS -o "$UP_RESP" -w '%{http_code}' \ -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ -F "attachment=@${a};filename=${local_name}" \ "${GITEA_URL}/api/v1/repos/${OWNER_REPO}/releases/${RELEASE_ID}/assets?name=${local_name}")" case "$UP_CODE" in 201) ASSET_URL="$(jq -r '.browser_download_url' < "$UP_RESP")" log_ok "$local_name" echo "$ASSET_URL" ;; *) log_err "上傳 ${local_name} 失敗 (HTTP ${UP_CODE})" cat "$UP_RESP" >&2 echo "" >&2 FAILED+=("$local_name") ;; esac rm -f "$UP_RESP" done if [[ ${#FAILED[@]} -gt 0 ]]; then log_err "部分 asset 上傳失敗:${FAILED[*]}" exit 1 fi log_done "完成" ``` - [ ] **Step 2: 語法檢查** Run: `bash -n scripts/release.sh` Expected: 無輸出 - [ ] **Step 3: 離線 smoke test** Run: `bash scripts/release.sh 2>&1 | grep -q 'Usage: release.sh' && echo OK` Expected: `OK` Run: `cd /tmp && bash /path/to/gitea-cli/scripts/release.sh v0 2>&1 | grep -q '無法從 cwd 推斷 repo' && echo OK` Expected: `OK` - [ ] **Step 4: Live smoke test** ```bash cd echo "hello" > /tmp/asset1.txt bash /path/to/gitea-cli/scripts/release.sh "v0.1.0-$(date +%s)" \ --name "Smoke test" --notes "hi" \ --asset /tmp/asset1.txt ``` Expected: stdout 依序為 release URL 與 asset URL;Gitea UI 上看得到 release 與 asset。 - [ ] **Step 5: Commit** ```bash git add scripts/release.sh git commit -m "feat: add release.sh for release creation with asset upload" ``` --- ## Task 13:更新 docs(SKILL.md / README.md / CLAUDE.md) **Files:** - Modify: `SKILL.md` - Modify: `README.md` - Modify: `CLAUDE.md` **Context:** 把 docs 對齊擴充後的內容:skill description 擴寫、README 新增腳本總覽、CLAUDE.md 補共用 lib 的邊界說明與新腳本骨架模板。 - [ ] **Step 1: 更新 `SKILL.md`** 把整個檔案內容覆寫為: ```markdown --- name: gitea-cli description: 對自架 Gitea 做常見操作:建 repo 並推送、列出 repo、clone、改名、切換公開私有、archive、刪除、設定 description/topics、建 release 並上傳附件、打 tag。當使用者提到 Gitea 相關的 push / clone / list / rename / release / archive / delete / tag 等需求時使用。需先在 config.env 設定 Gitea URL、使用者名稱、access token。 --- # gitea-cli 把自架 Gitea 常見操作封裝成 bash 腳本。Token 絕不落地到 `.git/config`。 ## 使用前準備(只需做一次) 1. 到自己的 Gitea 產生 access token(權限至少 `write:repository`) 2. 在本 skill 目錄下: ```bash cp config.example.env config.env # 編輯 config.env 填入 GITEA_URL / GITEA_USER / GITEA_TOKEN ``` 3. `config.env` 已被 `.gitignore` 排除。 ## 常見情境 → 對應腳本 `` 代表本 skill 所在路徑(clone 下來的位置或 `~/.claude/skills/gitea-cli` symlink)。 | 要做什麼 | 指令 | |---|---| | 建 repo 並把目前專案推上去 | `bash /scripts/publish.sh [repo] [public\|private] [-m "msg"]` | | 列出自己所有 repo | `bash /scripts/list.sh [--private\|--public] [--format table\|tsv\|name]` | | clone 某個 repo 到本地 | `bash /scripts/clone.sh [target-dir]` | | 改 repo 名稱 | `bash /scripts/rename.sh ` | | 切換 public/private | `bash /scripts/visibility.sh ` | | 設定 description / topics | `bash /scripts/meta.sh [--desc "..."] [--topics "a,b,c"]` | | archive / unarchive | `bash /scripts/archive.sh [--unarchive] [-y]` | | 刪除 repo | `bash /scripts/delete.sh [-y]` | | 打 tag 並推上去 | `bash /scripts/tag.sh [-m "msg"]` | | 建 release(含 asset 上傳) | `bash /scripts/release.sh [--name ...] [--notes ...] [--asset PATH]... [--repo NAME]` | ## 注意 - `config.env` 絕對不要 commit - `origin` remote 永遠是乾淨 URL;token 只在 push/clone 當下透過一次性 credential helper 注入 - 破壞性操作(`archive.sh` / `delete.sh`)預設需要互動確認;自動化場景請帶 `-y` / `--yes` 或設 `GITEA_YES=1` - 401/403 → 檢查 `config.env` 的 token ## 回覆原則 - 執行前先確認 cwd 就是要操作的目標 - 執行後簡述結果(URL / branch / 刪除/建立的項目) ``` - [ ] **Step 2: 更新 `README.md`** 在檔案最後加上「腳本總覽」表格。找到目前 README 的「使用方式」段落(講 `publish.sh` 的那段),在其後插入: ```markdown ## 所有可用腳本 | 腳本 | 用法 | |---|---| | `publish.sh` | `publish.sh [repo] [public\|private] [-m "msg"]` — 建 repo + 推目前目錄 | | `list.sh` | `list.sh [--private\|--public] [--format table\|tsv\|name]` — 列出自己的 repo | | `clone.sh` | `clone.sh [target-dir]` — clone 到本地,origin 保持乾淨 URL | | `rename.sh` | `rename.sh ` — 改名;若 cwd origin 指到舊 repo 自動更新 | | `visibility.sh` | `visibility.sh ` — 切換可見性 | | `meta.sh` | `meta.sh [--desc ...] [--topics a,b,c]` — 設定 description / topics | | `archive.sh` | `archive.sh [--unarchive] [-y]` — archive / unarchive | | `delete.sh` | `delete.sh [-y]` — 刪除(互動模式需重新輸入 repo 名) | | `tag.sh` | `tag.sh [-m "msg"]` — 建 annotated tag 並 push origin | | `release.sh` | `release.sh [--name ...] [--notes ...] [--asset PATH]... [--repo NAME]` — 建 release + 上傳 asset | 所有呼叫 API 的腳本共用 `scripts/lib/common.sh`,該 lib 負責 config 載入、API 呼叫、一次性 credential helper 注入、互動確認。 ``` 並在「安全設計」段落(`## 安全設計`)末端加一句: ```markdown - `clone.sh` 完成後 origin 也是乾淨 URL,再次 `git pull` / `git push` 要重新帶 credential(跟 `publish.sh` 一致)。 ``` - [ ] **Step 3: 更新 `CLAUDE.md`** 把整個檔案替換為: ```markdown # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## 專案性質 這是一個 **Claude Code skill**(根目錄的 `SKILL.md` 就是 skill 定義檔)。本身不是一般應用程式,產物是一組 bash 腳本(`scripts/*.sh`),針對自架 Gitea 做常見操作。`publish.sh` 是最早的腳本,其他腳本是逐步擴充而來。 ## 常用指令 在本 repo 裡沒有測試/build/lint;開發流程就是:改 `scripts/*.sh` → `bash -n` 語法檢查 → 在另一個 tmp 目錄實際跑一次看看。所有腳本共用 `scripts/lib/common.sh`。 實際使用腳本時(在任何專案目錄下): ```bash bash /path/to/gitea-cli/scripts/publish.sh [repo] [public|private] [-m "msg"] bash /path/to/gitea-cli/scripts/list.sh [--private|--public] [--format table|tsv|name] # ... 其他腳本見 SKILL.md / README.md ``` 首次使用前要 `cp config.example.env config.env` 並填入 `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN`。 ## 架構與關鍵不變量 ### 安全模型:token 絕不落地 **修改任何腳本時都要守住**: 1. `origin` remote 永遠是**乾淨 URL**,不含 token 2. Token 只能出現在 `config.env`、呼叫過程的環境變數、以及 `scripts/lib/common.sh` 裡的 `gitea_api` / `git_*_with_token` 函式內 3. Token 絕不進入 `.git/config`、remote URL、任何 log(stdout/stderr) 4. `config.env` 被 `.gitignore` 排除 ### 共用 lib `scripts/lib/common.sh` 所有 API / push / clone 都走 lib,腳本本身不寫 curl 也不直接操作 credential helper。lib 提供: - `load_config`:讀 `config.env`,檢查三個變數都非空非預設 - `gitea_api [json-body]`:單一 API 呼叫入口;回傳後讀 `$HTTP_CODE` 與 `$GITEA_API_RESP_FILE` - `git_push_with_token ` / `git_push_tag_with_token ` / `git_clone_with_token `:一次性帶 token 的 git 操作 - `infer_repo_from_cwd`:從 `git remote get-url origin` 推斷 `owner/repo` - `confirm_destructive ` / `confirm_exact_match `:破壞性操作互動;`GITEA_YES=1` 或腳本處理的 `-y` 可略過 - `log_info` / `log_ok` / `log_warn` / `log_err` / `log_done`:統一前綴、**全部寫 stderr** **什麼不放進 lib**(刻意留在各腳本): - 各腳本自己的 arg 解析(每支不同) - JSON body 組字串(放在呼叫處最直觀;複雜的用 jq 在呼叫端組) - `publish.sh` 的三種 git 狀態分支(publish 獨有) ### stdout / stderr 分流 - **stderr**:`log_*` 進度訊息、錯誤 - **stdout**:最終結構化輸出 - `publish.sh` / `rename.sh` / `visibility.sh` / `archive.sh` / `meta.sh` → repo web URL - `list.sh` → repo 清單 - `clone.sh` → clone 到的絕對路徑 - `release.sh` → release URL + 每個 asset URL - `tag.sh` → tag 名 - `delete.sh` → 空 使用者可以 `URL=$(publish.sh)` 拿結果。 ### 新腳本骨架(照抄) 寫新腳本時以這個為起點: ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" load_config # require_cmd jq # 若會用到 usage() { cat >&2 <<'EOF' Usage: