Files
gitea-cli/docs/superpowers/plans/2026-04-21-gitea-cli-expansion.md
timmy faee15bc78 Initial commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:04:09 +08:00

1823 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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、自架 GiteaAPI 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、任何 logstdout/stderr
3. `origin` remote 永遠是乾淨 URLpush/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全部寫 stderrstdout 留給結構化輸出)===
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 <METHOD> <path-from-/api/v1> [json-body]
# 回傳後讀:$HTTP_CODE狀態碼字串、$GITEA_API_RESP_FILEbody 檔路徑)
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 是乾淨 URLclone 本來就是用 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 <prompt>
# 若 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 <expected>
# 要求使用者重新輸入完整字串才通過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` 不含 tokenremote 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 到本地,預設目標資料夾為 `./<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 <repo> [target-dir]
<repo> GITEA_USER 名下的 repo 名稱
[target-dir] clone 目的地(預設 ./<repo>
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 名稱 `<R>`
```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 <old-name> <new-name>
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 URLorigin 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 <repo> <public|private>
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 <repo> [--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 <your-test-repo> --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 <repo> [--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 <your-test-repo>
GITEA_YES=1 bash scripts/archive.sh <your-test-repo> --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 <repo> [-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 </dev/tty 2>&1 || true`
> 注意:此測試因 `< /dev/tty` 需要真實 TTY建議手動跑而非自動化。改用 GITEA_YES=0 的環境測試:
Run: `GITEA_YES=0 bash scripts/delete.sh some-repo </dev/null 2>&1 | grep -q '需要確認但無 TTY' && echo OK`
Expected: `OK`
- [ ] **Step 4: Live smoke test**
用 Task 6 建的測試 repo
```bash
# 互動模式(需手動輸入)
bash scripts/delete.sh <your-test-repo>
# 或一鍵:
GITEA_YES=1 bash scripts/delete.sh <your-test-repo>
```
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 的 originorigin 必須已經設好(由之前的 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 <tag> [-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 <your-published-test-repo>
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 <tag> [--name NAME] [--notes TEXT] [--target BRANCH]
[--draft] [--prerelease] [--asset PATH]... [--repo NAME]
<tag> 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 <your-published-test-repo>
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 URLGitea 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更新 docsSKILL.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-path>` 代表本 skill 所在路徑clone 下來的位置或 `~/.claude/skills/gitea-cli` symlink
| 要做什麼 | 指令 |
|---|---|
| 建 repo 並把目前專案推上去 | `bash <skill-path>/scripts/publish.sh [repo] [public\|private] [-m "msg"]` |
| 列出自己所有 repo | `bash <skill-path>/scripts/list.sh [--private\|--public] [--format table\|tsv\|name]` |
| clone 某個 repo 到本地 | `bash <skill-path>/scripts/clone.sh <repo> [target-dir]` |
| 改 repo 名稱 | `bash <skill-path>/scripts/rename.sh <old> <new>` |
| 切換 public/private | `bash <skill-path>/scripts/visibility.sh <repo> <public\|private>` |
| 設定 description / topics | `bash <skill-path>/scripts/meta.sh <repo> [--desc "..."] [--topics "a,b,c"]` |
| archive / unarchive | `bash <skill-path>/scripts/archive.sh <repo> [--unarchive] [-y]` |
| 刪除 repo | `bash <skill-path>/scripts/delete.sh <repo> [-y]` |
| 打 tag 並推上去 | `bash <skill-path>/scripts/tag.sh <tag> [-m "msg"]` |
| 建 release含 asset 上傳) | `bash <skill-path>/scripts/release.sh <tag> [--name ...] [--notes ...] [--asset PATH]... [--repo NAME]` |
## 注意
- `config.env` 絕對不要 commit
- `origin` remote 永遠是乾淨 URLtoken 只在 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 <repo> [target-dir]` — clone 到本地origin 保持乾淨 URL |
| `rename.sh` | `rename.sh <old> <new>` — 改名;若 cwd origin 指到舊 repo 自動更新 |
| `visibility.sh` | `visibility.sh <repo> <public\|private>` — 切換可見性 |
| `meta.sh` | `meta.sh <repo> [--desc ...] [--topics a,b,c]` — 設定 description / topics |
| `archive.sh` | `archive.sh <repo> [--unarchive] [-y]` — archive / unarchive |
| `delete.sh` | `delete.sh <repo> [-y]` — 刪除(互動模式需重新輸入 repo 名) |
| `tag.sh` | `tag.sh <tag> [-m "msg"]` — 建 annotated tag 並 push origin |
| `release.sh` | `release.sh <tag> [--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 裡沒有測試buildlint開發流程就是`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、任何 logstdout/stderr
4. `config.env``.gitignore` 排除
### 共用 lib `scripts/lib/common.sh`
所有 API / push / clone 都走 lib腳本本身不寫 curl 也不直接操作 credential helper。lib 提供:
- `load_config`:讀 `config.env`,檢查三個變數都非空非預設
- `gitea_api <METHOD> <path> [json-body]`:單一 API 呼叫入口;回傳後讀 `$HTTP_CODE``$GITEA_API_RESP_FILE`
- `git_push_with_token <branch>` / `git_push_tag_with_token <tag>` / `git_clone_with_token <clean-url> <target>`:一次性帶 token 的 git 操作
- `infer_repo_from_cwd`:從 `git remote get-url origin` 推斷 `owner/repo`
- `confirm_destructive <prompt>` / `confirm_exact_match <expected>`:破壞性操作互動;`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: <script>.sh ...
EOF
exit 1
}
# arg parsing
# ...
log_info "..."
gitea_api METHOD "/path" "$BODY"
case "$HTTP_CODE" in
2??) log_ok "..." ;;
404) log_err "..."; exit 1 ;;
*) log_err "... (HTTP ${HTTP_CODE})"; { cat "$GITEA_API_RESP_FILE"; echo ""; } >&2; exit 1 ;;
esac
log_done "完成"
echo "<結構化結果>" # stdout
```
### 相容性限制
使用 **Gitea API v1**Forgejo 相容GitHub / GitLab **不相容**。要擴充到其他平台要重寫 API 呼叫處。
## 寫入偏好
- 腳本訊息與使用者文件皆為繁體中文(台灣用語)
- 沿用既有風格:▶ 進行中、✓ 成功、⚠ 警告、❌ 失敗、✅ 完成
- `SKILL.md` 是給 Claude 讀的 skill metadata + 簡短說明;`README.md` 是給人看的完整文件。修改功能時兩份都要同步
- 範例路徑用通用形式(`/path/to/gitea-cli`),不要硬編碼個人資訊
```
- [ ] **Step 4: 語法 / 拼寫檢查**
Run: `grep -E '(TODO|TBD|XXX|FIXME)' SKILL.md README.md CLAUDE.md && echo FOUND || echo clean`
Expected: `clean`
- [ ] **Step 5: Commit**
```bash
git add SKILL.md README.md CLAUDE.md
git commit -m "docs: update SKILL.md / README.md / CLAUDE.md for full gitea-cli"
```
---
## Task 14最終手動驗收
**Files:** 無修改,純驗證。
**Context:** 對應 spec「測試策略」段。做完此 task 後,實作階段才算完成。
- [ ] **Step 1: 基礎檢查——所有腳本語法正確**
Run: `for f in scripts/*.sh scripts/lib/*.sh; do bash -n "$f" && echo OK: "$f" || echo FAIL: "$f"; done`
Expected: 每行都 `OK:`
- [ ] **Step 2: 既有 `publish.sh` 用法回歸測試**
在 tmp 目錄跑三種 git 狀態:
```bash
# A. 全新目錄
D="$(mktemp -d)"; cd "$D"
NA="gcli-reg-a-$(date +%s)"
bash /path/to/gitea-cli/scripts/publish.sh "$NA" private
# B. 空目錄但已有 .git無 commit
cd "$(mktemp -d)"; git init -b main
NB="gcli-reg-b-$(date +%s)"
bash /path/to/gitea-cli/scripts/publish.sh "$NB" private
# C. 有 commit 且有未提交變更
cd "$(mktemp -d)"; git init -b main; echo 1 > 1.txt; git add .; git commit -m "x"
echo 2 > 2.txt
NC="gcli-reg-c-$(date +%s)"
bash /path/to/gitea-cli/scripts/publish.sh "$NC" private
```
Expected: 三次都成功Gitea UI 上有三個 repo每次 stdout 為對應 web URL。
- [ ] **Step 3: Token 不落地驗收**
接續 Step 2 的任一個目錄:
```bash
# 應該完全找不到 token
grep -r -F "$GITEA_TOKEN" .git/ 2>/dev/null && echo LEAK || echo "no token leak"
# origin 應為乾淨 URL不含 @ 或 token 字樣)
git remote get-url origin
```
Expected: `no token leak`origin URL 不含 `:token@` 或 token 字串
- [ ] **Step 4: `-m` 選項驗收**
```bash
D="$(mktemp -d)"; cd "$D"; echo x > a.txt
NM="gcli-m-$(date +%s)"
bash /path/to/gitea-cli/scripts/publish.sh "$NM" private -m "custom message"
git log -1 --pretty=%s # 應為 "custom message"
```
Expected: 最新 commit message 為 `custom message`
- [ ] **Step 5: 破壞性操作確認機制**
```bash
# delete.sh 無 TTY + 無 -y 應拒絕
bash /path/to/gitea-cli/scripts/delete.sh "$NA" </dev/null 2>&1 | grep -q '需要確認但無 TTY' && echo OK
# archive.sh 同理
bash /path/to/gitea-cli/scripts/archive.sh "$NB" </dev/null 2>&1 | grep -q '需要確認但無 TTY' && echo OK
```
Expected: 兩次都 `OK`
- [ ] **Step 6: 清測試 repo**
```bash
for N in "$NA" "$NB" "$NC" "$NM"; do
GITEA_YES=1 bash /path/to/gitea-cli/scripts/delete.sh "$N"
done
```
Expected: 四次都印 `已刪除`
- [ ] **Step 7: 檢查所有 grep TODO/TBD/FIXME 類待辦**
Run: `grep -rE '(TODO|TBD|XXX|FIXME)' scripts/ SKILL.md README.md CLAUDE.md && echo FOUND || echo clean`
Expected: `clean`
- [ ] **Step 8: Commit 驗收筆記(選用)**
若上述驗收全過,可打空 commit 做里程碑:
```bash
git commit --allow-empty -m "chore: manual acceptance passed per spec test strategy"
```
---
## Task 15選用清 git 歷史為單一 `Initial commit`
**Files:** 整個 `.git/` 目錄
**Context:** 使用者先前表明**最終要 re-init 整個 repo**,不想要中途 commit 歷史。此 task 把整個 repo 保留為最終檔案,但歷史重置。
> ⚠ 執行前再次確認使用者要這麼做。此動作會丟失實作過程中的所有 commits。
- [ ] **Step 1: 確認工作區乾淨**
Run: `git status`
Expected: `nothing to commit, working tree clean`
- [ ] **Step 2: 刪除 `.git/` 並重新 init**
```bash
rm -rf .git
git init -b main
git config user.email "timmy@automodules.com"
git config user.name "timmy"
```
- [ ] **Step 3: 明確 stage 所有檔案(不用 git add .**
```bash
git add .gitignore CLAUDE.md README.md SKILL.md config.example.env
git add scripts/publish.sh scripts/list.sh scripts/clone.sh scripts/rename.sh scripts/visibility.sh scripts/meta.sh scripts/archive.sh scripts/delete.sh scripts/tag.sh scripts/release.sh
git add scripts/lib/common.sh
git add docs/superpowers/specs/2026-04-21-gitea-cli-expansion-design.md
git add docs/superpowers/plans/2026-04-21-gitea-cli-expansion.md
```
- [ ] **Step 4: 驗證 `config.env` 與任何 token 都沒被 stage**
```bash
git diff --cached --name-only
git diff --cached | grep -E '^\+.*GITEA_TOKEN=' | grep -v '請貼上你的_access_token' && echo TOKEN_LEAK || echo clean
```
Expected: 檔名列表不含 `config.env`;第二行印 `clean`
- [ ] **Step 5: 做單一 `Initial commit`**
```bash
git commit -m "Initial commit"
git log --oneline # 應只有一行
```
Expected: 只有一個 commit
---
## Self-Review 結果
以下對照 spec`docs/superpowers/specs/2026-04-21-gitea-cli-expansion-design.md`)做自檢:
**Spec 覆蓋:**
- 共用 lib`common.sh`)→ Task 1 ✅
- 重構 `publish.sh` + `-m`→ Task 2 + 3 ✅
- `list.sh` ✅ Task 4
- `clone.sh` ✅ Task 5
- `rename.sh` ✅ Task 6
- `visibility.sh` ✅ Task 7
- `meta.sh` ✅ Task 8
- `archive.sh` ✅ Task 9
- `delete.sh` ✅ Task 10
- `tag.sh` ✅ Task 11
- `release.sh` ✅ Task 12
- 文件更新SKILL / README / CLAUDE✅ Task 13
- Spec 的「測試策略」四項驗收 ✅ Task 14
- Spec 的「非目標」—— 無相關 task正確的缺席
**Placeholder 掃描:**
-`TODO` / `TBD` / `fill in details` / `Add appropriate error handling` 等。所有 step 都有具體 code 或指令。
**Type 一致性:**
- lib function 名稱在各 script 引用處一致(`gitea_api` / `git_push_with_token` / `git_push_tag_with_token` / `git_clone_with_token` / `infer_repo_from_cwd` / `confirm_destructive` / `confirm_exact_match` / `log_*` / `require_cmd` / `load_config`
- 全域變數 `HTTP_CODE` / `GITEA_API_RESP_FILE` / `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` / `CONFIG_FILE` / `GITEA_YES` 命名一致