c2638668e3
leo 2026-08-20:「同一個 plugin 你用,薄殼也用,保證兩邊同步」
「我要你幫雲端做薄殼,永遠都有問題,你要做的就是這組設定
你自己可以 dogfooding」
搬進來:41 支 hook(51 條註冊)/7 支 command/2 支 skill/23 支腳本。
不搬 .env、wiki、docs——那些是知識不是環境。
51 條 hook 路徑全部從 $CLAUDE_PROJECT_DIR/.claude/hooks/ 改成 ${CLAUDE_PLUGIN_ROOT}/hooks/,
零漏網。那正是薄殼一直壞掉的根:雲端 cwd 不是真身,寫死路徑就斷。
尚未驗證:Claude Code 能不能從私有 Gitea repo 裝 marketplace(要憑證)。
下一步就是在本機實際裝一次,通了才動雲端 bootstrap.sh。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
16 KiB
Bash
351 lines
16 KiB
Bash
#!/bin/bash
|
||
# wiki-panorama.sh — 產生「各 repo 的 wiki 有哪些檔」的全景 index(`system-dev/wiki/PANORAMA.md`)
|
||
#
|
||
# 解的問題(leo 2026-08-12):
|
||
# 「我在 AR-Mira 看到所有 Gitea Repo 的 wiki,又可以看到所有票現況,
|
||
# 有一個總圖用 md 一次看到全景。」
|
||
# 開場總圖(Arcrun 工作流 `global_index`)的「票」那段已經好用,
|
||
# 但「知識」那段接的是 KBDB 藏書地圖,而那張表 10 個庫有 8 個是空的(Leo/Arcrun#87 在修)
|
||
# ⇒ **各 repo 真正的 wiki(system-dev/wiki/*.md)一份都沒進總圖。** 這支補的就是那一半。
|
||
#
|
||
# 為什麼是本機腳本、不是加進雲端的 `global_index` 工作流:
|
||
# 工作流跑在 Cloudflare 上,拿 repo 樹只有一條路=Gitea 的 contents API 列檔,
|
||
# 而 principles 紅線寫死「**讀 repo 走 git clone/fetch,不走 API 列檔**」。
|
||
# ⇒ 這件事的正解是在「有 git 的地方」算,算完存成 repo 裡的一份 md,開場 hook 直接印。
|
||
# 票留在雲端現算(狀態必須即時),知識走這條(可以稍舊,且 D71 已寫明兩者不對稱)。
|
||
#
|
||
# 產出**一份檔、兩個用途**(中間用 `<!-- panorama:inject-end -->` 切開):
|
||
# ① 標記以上=**開場注入**的摘要(hook 只印到這裡)。控制在幾 kB,不撐爆 context。
|
||
# ② 標記以下=**給 grep 用的完整清單**(每個 repo 每一張卡的名字)。
|
||
# 「某件事有沒有記過」就 `grep -i <關鍵字> system-dev/wiki/PANORAMA.md`。
|
||
#
|
||
# 紅線:
|
||
# - **不輪詢**。這支只在人/本機發起時跑(改完 wiki 順手跑一次、或開場 hook 提醒你過期了)。
|
||
# - **只讀 git**:clone/fetch,blobless + sparse,只抓 wiki 目錄。不打任何列檔 API。
|
||
# - **只產 index 不搬內容**:一檔一行(檔名+最後更新日+大小+一句話),不倒全文。
|
||
#
|
||
# 用法:
|
||
# bash scripts/wiki-panorama.sh # 算出來印到 stdout(不寫檔)
|
||
# bash scripts/wiki-panorama.sh --write # 同時寫進 system-dev/wiki/PANORAMA.md
|
||
# bash scripts/wiki-panorama.sh --no-fetch # 用快取算,完全不碰網路
|
||
# bash scripts/wiki-panorama.sh --only mira # 只處理某幾個 repo(除錯用)
|
||
#
|
||
# 快取:$WIKI_PANORAMA_CACHE,預設 ~/.cache/inkstone-wiki-panorama(不落在 repo 裡)
|
||
#
|
||
# 配套:開場注入要靠 `.claude/hooks/session-start-recall.sh` 的 push 2/5。
|
||
# 還沒套的話跑:`git apply scripts/patches/session-start-recall--wiki-panorama.patch`
|
||
|
||
set -euo pipefail
|
||
|
||
ROOT=$(git rev-parse --show-toplevel)
|
||
ROSTER="$ROOT/system-dev/wiki/.panorama-repos.txt"
|
||
OUT="$ROOT/system-dev/wiki/PANORAMA.md"
|
||
CACHE="${WIKI_PANORAMA_CACHE:-$HOME/.cache/inkstone-wiki-panorama}"
|
||
WIKI_DIRS="system-dev/wiki .claude/wiki" # 第二個是舊慣例,順手收
|
||
GITEA_TOTAL_REPOS=24 # 總管 2026-08-12 用 Gitea API 實查的總數
|
||
GITEA_TOTAL_ASOF=2026-08-12
|
||
|
||
DO_WRITE=0
|
||
DO_FETCH=1
|
||
ONLY=""
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--write) DO_WRITE=1 ;;
|
||
--no-fetch) DO_FETCH=0 ;;
|
||
--only) shift; ONLY="${ONLY} $1" ;;
|
||
-h|--help) sed -n '1,40p' "$0"; exit 0 ;;
|
||
*) echo "不認得的參數:$1" >&2; exit 2 ;;
|
||
esac
|
||
shift
|
||
done
|
||
|
||
[ -f "$ROSTER" ] || { echo "找不到 roster:$ROSTER" >&2; exit 1; }
|
||
|
||
# ── Gitea base(含憑證,**絕不可印出來**)────────────────────────────────
|
||
REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || true)
|
||
[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea remote,無法取 repo。" >&2; exit 1; }
|
||
BASE=${REMOTE%/InkStoneCo.git}
|
||
BASE=${BASE%/InkStoneCo}
|
||
SELF=$(basename "$REMOTE" .git)
|
||
SECRET=$(printf '%s' "$REMOTE" | sed -nE 's|.*//[^:]+:([^@]+)@.*|\1|p')
|
||
# 任何要外流的字串都先過這關(錯誤訊息可能夾帶 clone URL)
|
||
scrub() { if [ -n "$SECRET" ]; then sed "s|$SECRET|***|g"; else cat; fi; }
|
||
|
||
mkdir -p "$CACHE"
|
||
# 自己這個 repo 一律讀工作副本 ⇒ 快取裡若留著一份舊的自己,是誤導來源(會被人拿去讀)
|
||
if [ -d "$CACHE/$SELF" ]; then rm -rf "$CACHE/$SELF"; fi
|
||
|
||
REPOS=$(grep -vE '^[[:space:]]*(#|$)' "$ROSTER" | tr -d '\r')
|
||
if [ -n "$ONLY" ]; then
|
||
REPOS=$(printf '%s\n' "$REPOS" | grep -Fx -f <(printf '%s\n' "$ONLY" | tr ' ' '\n' | grep -v '^$'))
|
||
fi
|
||
# Gitea 網址大小寫不敏感 ⇒ 同一個 repo 用兩種拼法會被算成兩個。去重。
|
||
REPOS=$(printf '%s\n' "$REPOS" | awk '{k=tolower($0)} !seen[k]++')
|
||
|
||
STATUS_TSV=$(mktemp)
|
||
trap 'rm -f "$STATUS_TSV"' EXIT
|
||
|
||
for repo in $REPOS; do
|
||
# 自己這個 repo 讀工作副本,不繞一圈回 Gitea——
|
||
# 剛寫完還沒推的 wiki 也要看得見(人就站在這裡改)
|
||
if [ "$repo" = "$SELF" ]; then
|
||
printf '%s\tok\t%s\n' "$repo" "$ROOT" >> "$STATUS_TSV"; continue
|
||
fi
|
||
dst="$CACHE/$repo"
|
||
if [ -d "$dst/.git" ]; then
|
||
if [ "$DO_FETCH" = 1 ]; then
|
||
br=$(git -C "$dst" symbolic-ref --short HEAD 2>/dev/null || echo main)
|
||
git -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true
|
||
git -C "$dst" reset --quiet --hard FETCH_HEAD 2>/dev/null || true
|
||
fi
|
||
else
|
||
if [ "$DO_FETCH" = 0 ]; then
|
||
printf '%s\tno-cache\t\n' "$repo" >> "$STATUS_TSV"; continue
|
||
fi
|
||
# blobless + sparse:只下載 wiki 目錄的內容,但保留完整 commit 歷史
|
||
#(要歷史才算得出「每個檔最後更新是哪天」——淺 clone 會讓所有檔同一天)
|
||
if ! err=$(git clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then
|
||
printf '%s\tclone-failed\t%s\n' "$repo" "$(printf '%s' "$err" | tr '\n' ' ')" >> "$STATUS_TSV"
|
||
rm -rf "$dst"; continue
|
||
fi
|
||
git -C "$dst" sparse-checkout set $WIKI_DIRS >/dev/null 2>&1 || true
|
||
fi
|
||
printf '%s\tok\t%s\n' "$repo" "$dst" >> "$STATUS_TSV"
|
||
done
|
||
|
||
# ── 掃檔 + 排版(python3:BSD/GNU 工具差異多,交給它比較穩)────────────
|
||
MD=$(WIKI_DIRS="$WIKI_DIRS" SELF="$SELF" CACHE="$CACHE" \
|
||
TOTAL="$GITEA_TOTAL_REPOS" ASOF="$GITEA_TOTAL_ASOF" \
|
||
python3 - "$STATUS_TSV" <<'PY'
|
||
import os, re, subprocess, sys, datetime
|
||
|
||
status_tsv = sys.argv[1]
|
||
wiki_dirs = os.environ["WIKI_DIRS"].split()
|
||
SELF = os.environ["SELF"]
|
||
CACHE = os.environ["CACHE"].replace(os.path.expanduser("~"), "~")
|
||
TOTAL = int(os.environ["TOTAL"])
|
||
ASOF = os.environ["ASOF"]
|
||
OUT_NAME = "PANORAMA.md" # 產生物自己不列進全景
|
||
# 每個 repo 裝 system-dev-template 就會有的骨架檔。全部都有 ⇒ 逐一列出來只是雜訊,
|
||
# 對其他 repo 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔」。
|
||
SKELETON = {"INDEX", "TAXONOMY", "decisions-summary", "mistakes", "principles", "status"}
|
||
|
||
def run(cwd, *args):
|
||
try:
|
||
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=60).stdout
|
||
except Exception:
|
||
return ""
|
||
|
||
def dates_for(repo_dir, wdir):
|
||
"""一次 git log 走完整個目錄,取每個檔第一次出現(=最後一次被改)的日期。"""
|
||
out = run(repo_dir, "git", "log", "--date=short", "--format=@%ad", "--name-only", "--", wdir)
|
||
seen, cur = {}, None
|
||
for line in out.splitlines():
|
||
if line.startswith("@"):
|
||
cur = line[1:].strip()
|
||
elif line.strip() and cur:
|
||
seen.setdefault(line.strip(), cur)
|
||
return seen
|
||
|
||
WIDE = re.compile(r'[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]')
|
||
def width(s): return sum(2 if WIDE.match(c) else 1 for c in s)
|
||
def clip(s, w):
|
||
out, acc = "", 0
|
||
for c in s:
|
||
cw = 2 if WIDE.match(c) else 1
|
||
if acc + cw > w: return out.rstrip(" 、,·-—") + "…"
|
||
out, acc = out + c, acc + cw
|
||
return out
|
||
|
||
def clean(s):
|
||
s = re.sub(r'<!--.*?-->', '', s)
|
||
s = re.sub(r'\[\[([^\]]+)\]\]', r'\1', s)
|
||
s = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', s)
|
||
s = s.replace('**', '').replace('`', '').replace('~~', '')
|
||
s = re.sub(r'^[>#\-\*\s]+', '', s)
|
||
return re.sub(r'\s+', ' ', s).strip()
|
||
|
||
SKIP = ('---', '===', '|', '```', '<!--', '<')
|
||
def title_and_hook(path):
|
||
try:
|
||
text = open(path, encoding="utf-8", errors="replace").read(9000)
|
||
except OSError:
|
||
return "", ""
|
||
lines = text.splitlines()
|
||
title, rest = "", lines
|
||
for i, ln in enumerate(lines):
|
||
if ln.startswith("# "):
|
||
title, rest = clean(ln), lines[i+1:]
|
||
break
|
||
hook = ""
|
||
for ln in rest[:60]:
|
||
s = ln.strip()
|
||
if not s or s.startswith(SKIP) or s.startswith("#"):
|
||
continue
|
||
c = clean(s)
|
||
if len(c) < 4:
|
||
continue
|
||
# hook 跟標題講同一件事就不重複佔位,往下找一句真的有增量的
|
||
if title and (c[:10] in title or title[:10] in c):
|
||
continue
|
||
hook = c
|
||
break
|
||
return title, hook
|
||
|
||
def one_line(title, hook, fallback):
|
||
"""一檔一行的那句話:標題優先,標題太短就補一句 hook。"""
|
||
t = re.sub(r'^[\W_]*', '', title or "") or fallback
|
||
if hook and width(t) < 46:
|
||
t = t + "|" + hook
|
||
return clip(t, 78)
|
||
|
||
# ── 掃 ────────────────────────────────────────────────────────────────
|
||
repos, rows, cards, misses = [], {}, {}, []
|
||
for line in open(status_tsv, encoding="utf-8"):
|
||
parts = (line.rstrip("\n") + "\t\t").split("\t")
|
||
repo, st, dirpath = parts[0], parts[1], parts[2]
|
||
if not repo:
|
||
continue
|
||
repos.append(repo)
|
||
if st != "ok":
|
||
misses.append((repo, {"clone-failed": "clone 失敗",
|
||
"no-cache": "沒有快取且指定了 --no-fetch"}.get(st, st)))
|
||
continue
|
||
found = False
|
||
for wdir in wiki_dirs:
|
||
full = os.path.join(dirpath, wdir)
|
||
if not os.path.isdir(full):
|
||
continue
|
||
dmap = dates_for(dirpath, wdir)
|
||
for name in sorted(os.listdir(full)):
|
||
p = os.path.join(full, name)
|
||
if not (name.endswith(".md") and os.path.isfile(p)) or name == OUT_NAME:
|
||
continue
|
||
rel = f"{wdir}/{name}"
|
||
t, h = title_and_hook(p)
|
||
rows.setdefault(repo, []).append(
|
||
dict(rel=rel, name=name, stem=name[:-3], date=dmap.get(rel, "?"),
|
||
size=os.path.getsize(p), line=one_line(t, h, name[:-3])))
|
||
found = True
|
||
cdir = os.path.join(full, "cards")
|
||
if os.path.isdir(cdir):
|
||
for bucket in sorted(os.listdir(cdir)):
|
||
bp = os.path.join(cdir, bucket)
|
||
if not os.path.isdir(bp):
|
||
continue
|
||
for n in sorted(os.listdir(bp)):
|
||
if n.endswith(".md") and not n.startswith("00-INDEX"):
|
||
cards.setdefault(repo, []).append(n[:-3]); found = True
|
||
if not found:
|
||
misses.append((repo, "沒有 wiki"))
|
||
|
||
def kb_of(n): return max(1, round(n / 1024))
|
||
def newest(rs): return max([r["date"] for r in rs if r["date"] != "?"] or ["?"])
|
||
|
||
today = datetime.date.today().isoformat()
|
||
n_files = sum(len(v) for v in rows.values())
|
||
n_cards = sum(len(v) for v in cards.values())
|
||
have = [r for r in repos if r in rows or r in cards]
|
||
nowiki = [r for r, why in misses if why == "沒有 wiki"]
|
||
broken = [(r, why) for r, why in misses if why != "沒有 wiki"]
|
||
|
||
o = []; w = o.append
|
||
|
||
# ── 標記以上:開場注入的那一段 ────────────────────────────────────────
|
||
w("# 全景:各 repo 的 wiki 裡有哪些檔(index,不是內容)")
|
||
w("")
|
||
w(f"> **{today} 產生**(`bash scripts/wiki-panorama.sh --write`,人發起、不輪詢) · "
|
||
f"來源=`git clone/fetch` Gitea `Leo/*` 各 repo 的預設分支,不走 API 列檔。")
|
||
w(f"> {SELF} 這段讀的是**本機工作副本**(剛寫完還沒推的也算數)。**改這個檔沒用**,要改去改那個 repo 的 wiki。")
|
||
w(">")
|
||
w("> 🔴 **這裡沒有內容,只有「有這件事、在哪個 repo 的哪個檔」。**")
|
||
w(f"> - 「某件事 wiki 記過沒有」→ `grep -i <關鍵字> system-dev/wiki/{OUT_NAME}`"
|
||
f"(本檔下半部有全部 {n_cards} 張卡的名字)")
|
||
w(f"> - 要讀內容 → 本機有那個 repo 就直接讀;沒有就看快取 `{CACHE}/<repo>/system-dev/wiki/`")
|
||
w("")
|
||
w(f"**{n_files} 份主檔 + {n_cards} 張卡,散在 {len(have)} 個 repo**"
|
||
f"(點名 {len(repos)} 個 repo,其中 {len(nowiki)} 個掃過確定沒有 wiki)")
|
||
w("")
|
||
|
||
# 自己這個 repo:逐檔一行(這是你最可能真的去讀的那份)
|
||
if SELF in rows:
|
||
rs = rows[SELF]
|
||
head = f"## {SELF}(你現在站的地方)— {len(rs)} 份主檔"
|
||
if cards.get(SELF): head += f"、{len(cards[SELF])} 張卡"
|
||
w(head)
|
||
for r in rs:
|
||
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
|
||
if cards.get(SELF):
|
||
w(f"- `system-dev/wiki/cards/` {len(cards[SELF])} 張:" + "、".join(sorted(cards[SELF])))
|
||
w("")
|
||
|
||
# 其他 repo:一 repo 一行。骨架六檔每個 repo 都有,逐一列是雜訊——
|
||
# 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔 + 卡數」。
|
||
others = [r for r in have if r != SELF]
|
||
if others:
|
||
w("## 其他 repo — 一 repo 一行")
|
||
w("")
|
||
w("> 每個 repo 都有 `INDEX`/`TAXONOMY`/`decisions-summary`/`mistakes`/`principles`/`status` "
|
||
"這套骨架(裝 system-dev-template 就有),所以只標**新鮮度、最肥的那份、多出來的檔**。")
|
||
for repo in others:
|
||
rs = rows.get(repo, [])
|
||
extra = [r["name"] for r in rs if r["stem"] not in SKELETON]
|
||
big = max(rs, key=lambda r: r["size"]) if rs else None
|
||
seg = [f"**{repo}** — {len(rs)} 份"]
|
||
if cards.get(repo): seg.append(f"{len(cards[repo])} 張卡")
|
||
if rs: seg.append(f"最近改 {newest(rs)}")
|
||
if big: seg.append(f"最肥 `{big['name']}` {kb_of(big['size'])}kB")
|
||
if extra: seg.append("多出來的:" + "、".join(f"`{e}`" for e in extra))
|
||
w("- " + " · ".join(seg))
|
||
w("")
|
||
|
||
w("## 涵蓋範圍——**沒掃到的也要看得見**")
|
||
w("")
|
||
if nowiki:
|
||
w(f"- **掃過、確定沒有 wiki 的 {len(nowiki)} 個**:" + "、".join(f"`{r}`" for r in nowiki))
|
||
if broken:
|
||
w("- ⚠️ **這次沒抓到的**:" + "、".join(f"`{r}`({why})" for r, why in broken))
|
||
w(f"- 名單=`system-dev/wiki/.panorama-repos.txt`({len(repos)} 個,逐一 `git ls-remote` 驗過存在)。")
|
||
gap = TOTAL - len(repos)
|
||
if gap > 0:
|
||
w(f"- ⚠️ **本圖點不到「名單上沒有的 repo」**:git 只能驗名字、不能枚舉。"
|
||
f"Gitea `Leo/*` 在 {ASOF} 實查是 **{TOTAL} 個**,名單 {len(repos)} 個 ⇒ **還有 {gap} 個沒被點名**。")
|
||
w(" 補法(人發起,一次呼叫,不排程)——拿到完整清單、把缺的名字加進名單再重跑:")
|
||
w(" ```")
|
||
w(" TOKEN=$(git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\\1|')")
|
||
w(" curl -s -H \"Authorization: token $TOKEN\" \\")
|
||
w(" 'https://git.uncle6.me/api/v1/orgs/Leo/repos?limit=100' | python3 -c \\")
|
||
w(" 'import sys,json;[print(r[\"name\"]) for r in json.load(sys.stdin)]'")
|
||
w(" ```")
|
||
w("")
|
||
|
||
# ── 標記以下:不進注入,給 grep ────────────────────────────────────────
|
||
w("<!-- panorama:inject-end —— 開場注入只印到這一行為止。以下是給 grep 的完整清單 -->")
|
||
w("")
|
||
w("## 完整清單(不進開場注入,給 `grep` 用)")
|
||
w("")
|
||
for repo in have:
|
||
rs = rows.get(repo, [])
|
||
if repo != SELF and rs:
|
||
w(f"### {repo} — 主檔")
|
||
for r in rs:
|
||
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
|
||
w("")
|
||
if cards.get(repo):
|
||
w(f"### {repo} — cards({len(cards[repo])} 張)")
|
||
for c in sorted(cards[repo]):
|
||
w(f"- {c}")
|
||
w("")
|
||
print("\n".join(o))
|
||
PY
|
||
)
|
||
|
||
if [ "$DO_WRITE" = 1 ]; then
|
||
printf '%s\n' "$MD" > "$OUT"
|
||
TOTAL_B=$(printf '%s\n' "$MD" | wc -c | tr -d ' ')
|
||
INJ_B=$(printf '%s\n' "$MD" | awk '/panorama:inject-end/{exit} {print}' | wc -c | tr -d ' ')
|
||
echo "已寫入 $OUT(全檔 ${TOTAL_B} bytes,其中開場注入的那段 ${INJ_B} bytes)" >&2
|
||
else
|
||
printf '%s\n' "$MD"
|
||
fi
|