Files
ISEP/scripts/wiki-panorama.sh
T
Leo c2638668e3 ISEP 0.1.0:環境設定收成一個 plugin,本機與雲端共用一份
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>
2026-08-20 11:41:46 +08:00

351 lines
16 KiB
Bash
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.
#!/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 真正的 wikisystem-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/fetchblobless + 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