@seanyao/roll 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +201 -0
  2. package/bin/roll +1375 -0
  3. package/conventions/config.yaml +15 -0
  4. package/conventions/global/.cursor-rules +31 -0
  5. package/conventions/global/AGENTS.md +100 -0
  6. package/conventions/global/CLAUDE.md +32 -0
  7. package/conventions/global/GEMINI.md +28 -0
  8. package/conventions/templates/backend-service/.cursor-rules +17 -0
  9. package/conventions/templates/backend-service/AGENTS.md +88 -0
  10. package/conventions/templates/backend-service/CLAUDE.md +18 -0
  11. package/conventions/templates/backend-service/GEMINI.md +16 -0
  12. package/conventions/templates/cli/.cursor-rules +17 -0
  13. package/conventions/templates/cli/AGENTS.md +66 -0
  14. package/conventions/templates/cli/CLAUDE.md +18 -0
  15. package/conventions/templates/cli/GEMINI.md +16 -0
  16. package/conventions/templates/frontend-only/.cursor-rules +16 -0
  17. package/conventions/templates/frontend-only/AGENTS.md +71 -0
  18. package/conventions/templates/frontend-only/CLAUDE.md +16 -0
  19. package/conventions/templates/frontend-only/GEMINI.md +14 -0
  20. package/conventions/templates/fullstack/.cursor-rules +17 -0
  21. package/conventions/templates/fullstack/AGENTS.md +87 -0
  22. package/conventions/templates/fullstack/CLAUDE.md +17 -0
  23. package/conventions/templates/fullstack/GEMINI.md +15 -0
  24. package/package.json +33 -0
  25. package/skills/roll-.changelog/SKILL.md +79 -0
  26. package/skills/roll-.clarify/SKILL.md +59 -0
  27. package/skills/roll-.echo/SKILL.md +113 -0
  28. package/skills/roll-.qa/SKILL.md +204 -0
  29. package/skills/roll-.review/SKILL.md +105 -0
  30. package/skills/roll-build/SKILL.md +559 -0
  31. package/skills/roll-debug/SKILL.md +428 -0
  32. package/skills/roll-design/ENGINEERING_CHECKLIST.md +256 -0
  33. package/skills/roll-design/SKILL.md +276 -0
  34. package/skills/roll-fix/SKILL.md +442 -0
  35. package/skills/roll-jot/SKILL.md +50 -0
  36. package/skills/roll-research/SKILL.md +307 -0
  37. package/skills/roll-research/references/schema.json +162 -0
  38. package/skills/roll-research/scripts/md_to_pdf.py +289 -0
  39. package/skills/roll-sentinel/SKILL.md +355 -0
  40. package/skills/roll-spar/SKILL.md +287 -0
  41. package/template/.env.example +47 -0
  42. package/template/.github/workflows/ci.yml +32 -0
  43. package/template/.github/workflows/sentinel.yml +26 -0
  44. package/template/AGENTS.md +80 -0
  45. package/template/BACKLOG.md +42 -0
  46. package/template/package.json +43 -0
  47. package/tools/roll-fetch/SKILL.md +182 -0
  48. package/tools/roll-fetch/package.json +15 -0
  49. package/tools/roll-fetch/smart-web-fetch.js +558 -0
  50. package/tools/roll-probe/SKILL.md +84 -0
package/bin/roll ADDED
@@ -0,0 +1,1375 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Roll — AI Agent Convention Manager
5
+ # Single source of truth for how all AI coding agents behave.
6
+
7
+ VERSION="0.5.0"
8
+ ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
+ ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
+ ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
11
+ ROLL_TEMPLATES="${ROLL_HOME}/conventions/templates"
12
+
13
+ # Find package root (resolve symlinks so it works from ~/.local/bin/roll or npm global bin)
14
+ _source="${BASH_SOURCE[0]}"
15
+ while [[ -L "$_source" ]]; do
16
+ _dir="$(cd "$(dirname "$_source")" && pwd)"
17
+ _source="$(readlink "$_source")"
18
+ [[ "$_source" != /* ]] && _source="$_dir/$_source"
19
+ done
20
+ SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd)"
21
+ ROLL_PKG_DIR="$(dirname "$SCRIPT_DIR")"
22
+ ROLL_PKG_CONVENTIONS="${ROLL_PKG_DIR}/conventions"
23
+
24
+ # Colors
25
+ RED=$'\033[0;31m'
26
+ GREEN=$'\033[0;32m'
27
+ YELLOW=$'\033[0;33m'
28
+ CYAN=$'\033[0;36m'
29
+ BOLD=$'\033[1m'
30
+ NC=$'\033[0m'
31
+
32
+ # Respect NO_COLOR
33
+ if [[ -n "${NO_COLOR:-}" ]]; then
34
+ RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC=''
35
+ fi
36
+
37
+ info() { echo -e "${CYAN}[roll]${NC} $*"; }
38
+ ok() { echo -e "${GREEN}[roll]${NC} $*"; }
39
+ warn() { echo -e "${YELLOW}[roll]${NC} $*"; }
40
+ err() { echo -e "${RED}[roll]${NC} $*" >&2; }
41
+
42
+ # Tracks merge actions across a single init run; reset before each batch
43
+ _ROLL_MERGE_SUMMARY=()
44
+
45
+ canonical_dir() {
46
+ local path="$1"
47
+ [[ -d "$path" ]] || return 1
48
+ (cd "$path" >/dev/null 2>&1 && pwd -P)
49
+ }
50
+
51
+ # Return a human-readable name for an AI tool dir.
52
+ # Handles nested paths like ~/.openclaw/workspace → "openclaw".
53
+ ai_tool_name() {
54
+ local dir="$1"
55
+ local bn
56
+ bn="$(basename "$dir" | sed 's/^\.//')"
57
+ if [[ "$bn" == "workspace" ]]; then
58
+ bn="$(basename "$(dirname "$dir")" | sed 's/^\.//')"
59
+ fi
60
+ echo "$bn"
61
+ }
62
+
63
+ lower_name() {
64
+ echo "$1" | tr '[:upper:]' '[:lower:]'
65
+ }
66
+
67
+ # ─── Helper: read config value ───────────────────────────────────────────────
68
+ config_get() {
69
+ local key="$1"
70
+ local default="${2:-}"
71
+ if [[ -f "$ROLL_CONFIG" ]]; then
72
+ local val
73
+ val=$(grep -E "^${key}:" "$ROLL_CONFIG" 2>/dev/null | head -1 | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//')
74
+ if [[ -n "$val" ]]; then
75
+ echo "${val/#\~/$HOME}"
76
+ return
77
+ fi
78
+ fi
79
+ echo "${default/#\~/$HOME}"
80
+ }
81
+
82
+ # ─── Helpers: read ai_* entries from config ──────────────────────────────────
83
+ # Returns one "dir|config_file|convention_src" line per ai_* key in config.yaml
84
+ _get_ai_tools() {
85
+ grep -E "^ai_[a-z]+:" "$ROLL_CONFIG" 2>/dev/null | sed 's/^[^:]*:[[:space:]]*//' | while IFS= read -r entry; do
86
+ echo "${entry/#\~/$HOME}"
87
+ done
88
+ }
89
+
90
+ # Extract fields from a "<dir>|<config>|<src>" entry
91
+ _ai_dir() { echo "$1" | cut -d'|' -f1; }
92
+ _ai_config() { echo "$1" | cut -d'|' -f2; }
93
+ _ai_src() { echo "$1" | cut -d'|' -f3; }
94
+
95
+ # ─── Helper: safe copy with overwrite prompt ─────────────────────────────────
96
+ safe_copy() {
97
+ local src="$1"
98
+ local dst="$2"
99
+ local force="${3:-false}"
100
+
101
+ if [[ ! -f "$src" ]]; then
102
+ return
103
+ fi
104
+
105
+ local dst_dir
106
+ dst_dir="$(dirname "$dst")"
107
+ mkdir -p "$dst_dir"
108
+
109
+ if [[ -f "$dst" ]] && [[ "$force" != "true" ]]; then
110
+ if diff -q "$src" "$dst" &>/dev/null; then
111
+ return # identical, skip silently
112
+ fi
113
+ echo ""
114
+ warn "File exists and differs: ${dst/#$HOME/~} 文件已存在且内容不同: ${dst/#$HOME/~}"
115
+ echo -e " ${BOLD}Overwrite?${NC} [y/N/d(iff)] "
116
+ read -r answer
117
+ case "$answer" in
118
+ d|D|diff)
119
+ diff --color=auto "$dst" "$src" || true
120
+ echo ""
121
+ echo -e " ${BOLD}Overwrite?${NC} [y/N] "
122
+ read -r answer2
123
+ [[ "$answer2" =~ ^[Yy]$ ]] || { info "Skipped: ${dst/#$HOME/\~} 已跳过: ${dst/#$HOME/\~}"; return; }
124
+ ;;
125
+ y|Y) ;;
126
+ *) info "Skipped: ${dst/#$HOME/~} 已跳过: ${dst/#$HOME/~}"; return ;;
127
+ esac
128
+ fi
129
+
130
+ cp "$src" "$dst"
131
+ ok "Wrote: ${dst/#$HOME/~} 已写入: ${dst/#$HOME/~}"
132
+ }
133
+
134
+ # ─── Internal: pull skills from repo → ~/.roll/skills ──────────────────────
135
+ _pull_skills() {
136
+ if [[ ! -d "$ROLL_PKG_DIR/skills" ]]; then
137
+ err "Skills source not found at: $ROLL_PKG_DIR/skills 技能源目录未找到: $ROLL_PKG_DIR/skills"
138
+ return 1
139
+ fi
140
+
141
+ mkdir -p "$ROLL_HOME/skills"
142
+
143
+ # Copy/update skills from repo → ~/.roll/skills/
144
+ for skill_dir in "$ROLL_PKG_DIR"/skills/*/; do
145
+ if [[ -d "$skill_dir" ]]; then
146
+ local skill_name
147
+ skill_name="$(basename "$skill_dir")"
148
+ mkdir -p "$ROLL_HOME/skills/$skill_name"
149
+ for f in "$skill_dir"*; do
150
+ [[ -f "$f" ]] && cp "$f" "$ROLL_HOME/skills/$skill_name/$(basename "$f")"
151
+ done
152
+ fi
153
+ done
154
+
155
+ # Prune skills that no longer exist in repo.
156
+ # ~/.roll/skills/ is roll's controlled namespace — safe to clean up.
157
+ for installed_dir in "$ROLL_HOME/skills"/*/; do
158
+ [[ -d "$installed_dir" ]] || continue
159
+ local installed_name
160
+ installed_name="$(basename "$installed_dir")"
161
+ if [[ ! -d "$ROLL_PKG_DIR/skills/$installed_name" ]]; then
162
+ rm -rf "$installed_dir"
163
+ info "Removed stale skill: $installed_name 已删除过时技能: $installed_name"
164
+ fi
165
+ done
166
+ }
167
+
168
+ # ─── Internal: pull conventions from repo → ~/.roll/conventions ────────────
169
+ _pull_conventions() {
170
+ local force="${1:-false}"
171
+
172
+ if [[ ! -d "$ROLL_PKG_CONVENTIONS" ]]; then
173
+ err "Convention source not found at: $ROLL_PKG_CONVENTIONS 约定源文件未找到: $ROLL_PKG_CONVENTIONS"
174
+ return 1
175
+ fi
176
+
177
+ mkdir -p "$ROLL_GLOBAL"
178
+ mkdir -p "$ROLL_TEMPLATES"/{fullstack,frontend-only,backend-service,cli}
179
+
180
+ info "Copying global conventions... 正在复制全局约定..."
181
+ for f in "$ROLL_PKG_CONVENTIONS"/global/*; do
182
+ [[ -f "$f" ]] && safe_copy "$f" "$ROLL_GLOBAL/$(basename "$f")" "$force"
183
+ done
184
+ for f in "$ROLL_PKG_CONVENTIONS"/global/.*; do
185
+ [[ -f "$f" ]] && [[ "$(basename "$f")" != "." ]] && [[ "$(basename "$f")" != ".." ]] && \
186
+ safe_copy "$f" "$ROLL_GLOBAL/$(basename "$f")" "$force"
187
+ done
188
+
189
+ info "Copying project templates... 正在复制项目模板..."
190
+ for tpl_dir in "$ROLL_PKG_CONVENTIONS"/templates/*/; do
191
+ local tpl_name
192
+ tpl_name="$(basename "$tpl_dir")"
193
+ for f in "$tpl_dir"*; do
194
+ [[ -f "$f" ]] && safe_copy "$f" "$ROLL_TEMPLATES/$tpl_name/$(basename "$f")" "$force"
195
+ done
196
+ for f in "$tpl_dir".*; do
197
+ [[ -f "$f" ]] && [[ "$(basename "$f")" != "." ]] && [[ "$(basename "$f")" != ".." ]] && \
198
+ safe_copy "$f" "$ROLL_TEMPLATES/$tpl_name/$(basename "$f")" "$force"
199
+ done
200
+ done
201
+ }
202
+
203
+ # ─── Internal: install local cache from repo source ───────────────────────────
204
+ _install_local() {
205
+ local force="${1:-false}"
206
+
207
+ if [[ ! -d "$ROLL_PKG_CONVENTIONS" ]]; then
208
+ err "Convention source not found at: $ROLL_PKG_CONVENTIONS 约定源文件未找到: $ROLL_PKG_CONVENTIONS"
209
+ err "Run this from the roll repo, or symlink bin/roll to PATH. 请在 roll 仓库目录下运行,或将 bin/roll 软链接到 PATH。"
210
+ exit 1
211
+ fi
212
+
213
+ _pull_conventions "$force"
214
+ _pull_skills
215
+
216
+ # Recreate config if it has no ai_* entries (covers legacy sync_* format and blank/broken configs)
217
+ if [[ -f "$ROLL_CONFIG" ]] && ! grep -qE "^ai_[a-z]+:" "$ROLL_CONFIG" 2>/dev/null; then
218
+ warn "Config has no ai_* entries — recreating with defaults (backup saved) 配置无 ai_* 条目,将重建(已备份)"
219
+ cp "$ROLL_CONFIG" "${ROLL_CONFIG}.bak"
220
+ info "Backup saved: ~/.roll/config.yaml.bak 备份已保存: ~/.roll/config.yaml.bak"
221
+ rm "$ROLL_CONFIG"
222
+ fi
223
+
224
+ # Create config if it doesn't exist
225
+ if [[ ! -f "$ROLL_CONFIG" ]]; then
226
+ info "Creating default config... 正在创建默认配置..."
227
+ cat > "$ROLL_CONFIG" << 'YAML'
228
+ # Roll Configuration
229
+ # Edit this file, then run `roll sync` to apply.
230
+
231
+ # AI tools — each entry controls both convention sync and skill linking
232
+ # Format: <name>: <dir>|<config_file>|<convention_src>
233
+ ai_claude: ~/.claude|CLAUDE.md|CLAUDE.md
234
+ ai_gemini: ~/.gemini|GEMINI.md|GEMINI.md
235
+ ai_kimi: ~/.kimi|AGENTS.md|AGENTS.md
236
+ ai_codex: ~/.codex|AGENTS.md|AGENTS.md
237
+ ai_cursor: ~/.cursor|.cursor-rules|.cursor-rules
238
+ ai_openclaw: ~/.openclaw/workspace|AGENTS.md|AGENTS.md
239
+
240
+ # User preferences
241
+ default_language: zh
242
+ default_project_type: fullstack
243
+ editor: ${EDITOR:-vim}
244
+ YAML
245
+ ok "Created: ~/.roll/config.yaml 已创建: ~/.roll/config.yaml"
246
+ fi
247
+
248
+ }
249
+
250
+ # ─── Internal: create or repair per-skill symlinks (non-destructive) ─────────
251
+ _link_skills() {
252
+ local force="${1:-false}"
253
+ local wk_skills_real pkg_skills_real
254
+ wk_skills_real="$(canonical_dir "$ROLL_HOME/skills" 2>/dev/null || true)"
255
+ pkg_skills_real="$(canonical_dir "$ROLL_PKG_DIR/skills" 2>/dev/null || true)"
256
+
257
+ while IFS= read -r entry; do
258
+ local ai_dir
259
+ ai_dir="$(_ai_dir "$entry")"
260
+ [[ -d "$ai_dir" ]] || continue
261
+
262
+ local ai_name ai_dir_real skills_dir
263
+ ai_name="$(ai_tool_name "$ai_dir")"
264
+ ai_dir_real="$(canonical_dir "$ai_dir" 2>/dev/null || true)"
265
+ skills_dir="$ai_dir/skills"
266
+
267
+ if [[ -n "$ai_dir_real" && \
268
+ ( "$ai_dir_real" == "$ROLL_PKG_DIR" || "$ai_dir_real" == "$ROLL_PKG_DIR"/* ) ]]; then
269
+ warn "Skipped ~/${ai_name} (resolves to repo — refusing to manage skills inside roll worktree) 已跳过 ~/${ai_name}(解析到仓库目录 — 拒绝在 roll worktree 内管理技能)"
270
+ continue
271
+ fi
272
+
273
+ # Guard: resolve ALL symlink chains — block writing anywhere inside the repo
274
+ local skills_real
275
+ skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
276
+ if [[ -n "$skills_real" && -n "$pkg_skills_real" && \
277
+ ( "$skills_real" == "$pkg_skills_real" || "$skills_real" == "$pkg_skills_real"/* ) ]]; then
278
+ warn "Skipped ~/${ai_name}/skills (resolves to repo — check if ~/$(lower_name "$ai_name") symlinks to roll repo) 已跳过 ~/${ai_name}/skills(解析到仓库 — 检查 ~/$(lower_name "$ai_name") 是否软链接到 roll 仓库)"
279
+ continue
280
+ fi
281
+
282
+ # Handle legacy whole-dir symlink
283
+ if [[ -L "$skills_dir" ]]; then
284
+ local skills_target
285
+ skills_target="$(readlink "$skills_dir")"
286
+ if [[ -n "$skills_real" && "$skills_real" == "$wk_skills_real" ]]; then
287
+ continue # Whole-dir symlink to WK skills — still functional
288
+ fi
289
+ # Dangling or legacy whole-dir symlink — remove and recreate as per-skill links
290
+ if [[ -z "$skills_real" ]] || \
291
+ [[ "$skills_target" == *"cybernetix"* ]] || \
292
+ [[ "$skills_target" == *"wukong"* ]]; then
293
+ info "Removing legacy symlink ~/${ai_name}/skills -> ${skills_target/#$HOME/~} 正在移除遗留软链接 ~/${ai_name}/skills -> ${skills_target/#$HOME/~}"
294
+ rm "$skills_dir"
295
+ else
296
+ warn "Skipped ~/${ai_name}/skills -> ${skills_target/#$HOME/~} (unknown symlink target) 已跳过 ~/${ai_name}/skills -> ${skills_target/#$HOME/~}(未知软链接目标)"
297
+ continue
298
+ fi
299
+ fi
300
+
301
+ mkdir -p "$skills_dir"
302
+ skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
303
+ if [[ -n "$skills_real" && -n "$pkg_skills_real" && \
304
+ ( "$skills_real" == "$pkg_skills_real" || "$skills_real" == "$pkg_skills_real"/* ) ]]; then
305
+ warn "Skipped ~/${ai_name}/skills (created path resolves to repo — refusing to write) 已跳过 ~/${ai_name}/skills(创建路径解析到仓库 — 拒绝写入)"
306
+ continue
307
+ fi
308
+ local linked=0 repaired=0 pruned=0
309
+
310
+ # Prune stale roll-* symlinks pointing to skills no longer in ~/.roll/skills/
311
+ for link in "$skills_dir"/roll-*; do
312
+ [[ -L "$link" ]] || continue
313
+ local link_target
314
+ link_target="$(readlink "$link")"
315
+ # Only remove symlinks we own (pointing into our skills dir)
316
+ if [[ "$link_target" == "$ROLL_HOME/skills/"* ]] && [[ ! -d "$link" ]]; then
317
+ rm "$link"
318
+ pruned=$((pruned + 1))
319
+ fi
320
+ done
321
+
322
+ for skill_dir in "$ROLL_HOME/skills"/*/; do
323
+ [[ -d "$skill_dir" ]] || continue
324
+ local skill_name
325
+ skill_name="$(basename "$skill_dir")"
326
+ local skill_link="$skills_dir/$skill_name"
327
+
328
+ if [[ -L "$skill_link" ]]; then
329
+ local current_target
330
+ current_target="$(readlink "$skill_link")"
331
+ if [[ "$current_target" != "$skill_dir" ]]; then
332
+ ln -sf "$skill_dir" "$skill_link"
333
+ repaired=$((repaired + 1))
334
+ fi
335
+ # correct symlink: skip silently
336
+ elif [[ ! -e "$skill_link" ]]; then
337
+ ln -s "$skill_dir" "$skill_link"
338
+ linked=$((linked + 1))
339
+ fi
340
+ # real file/dir at that path: skip — never touch user content
341
+ done
342
+ if [[ $((linked + repaired + pruned)) -gt 0 ]]; then
343
+ ok "Skills linked in ~/${ai_name}/skills (+${linked} new, ~${repaired} repaired, -${pruned} pruned) 已在 ~/${ai_name}/skills 中创建软链接(新增 +${linked},修复 ~${repaired},清理 -${pruned})"
344
+ fi
345
+ done < <(_get_ai_tools)
346
+ }
347
+
348
+ # ─── Internal: sync conventions via @include — never overwrites user files ─────
349
+ # Writes WK content to {ai_dir}/roll.md, appends @roll.md to main config.
350
+ _sync_convention_for_tool() {
351
+ local src="$1" # source: ~/.roll/conventions/global/CLAUDE.md
352
+ local main_dst="$2" # target: ~/.claude/CLAUDE.md
353
+ local force="$3"
354
+
355
+ [[ -f "$src" ]] || return
356
+ local dst_dir
357
+ dst_dir="$(dirname "$main_dst")"
358
+
359
+ # Only proceed if the AI tool directory already exists (except Claude, always create)
360
+ if [[ "$dst_dir" != "$HOME/.claude" ]] && [[ ! -d "$dst_dir" ]]; then
361
+ return
362
+ fi
363
+ mkdir -p "$dst_dir"
364
+
365
+ # Write/update roll.md — this is our file, always safe to overwrite
366
+ local wk_file="$dst_dir/roll.md"
367
+ if [[ "$force" == "true" ]] || ! diff -q "$src" "$wk_file" &>/dev/null 2>&1; then
368
+ cp "$src" "$wk_file"
369
+ ok "Wrote: ${wk_file/#$HOME/~} 已写入: ${wk_file/#$HOME/~}"
370
+ fi
371
+
372
+ # Append @roll.md include to main config — never overwrite existing content
373
+ if [[ ! -f "$main_dst" ]]; then
374
+ printf '@roll.md\n' > "$main_dst"
375
+ ok "Created: ${main_dst/#$HOME/~} 已创建: ${main_dst/#$HOME/~}"
376
+ elif ! grep -qF "@roll.md" "$main_dst" 2>/dev/null; then
377
+ printf '\n@roll.md\n' >> "$main_dst"
378
+ ok "Appended @roll.md to: ${main_dst/#$HOME/~} 已将 @roll.md 追加至: ${main_dst/#$HOME/~}"
379
+ else
380
+ ok "Already included: ${main_dst/#$HOME/~} 已包含: ${main_dst/#$HOME/~}"
381
+ fi
382
+ }
383
+
384
+ _sync_conventions() {
385
+ local force="${1:-false}"
386
+
387
+ while IFS= read -r entry; do
388
+ local ai_dir config_file src_file
389
+ ai_dir="$(_ai_dir "$entry")"
390
+ config_file="$(_ai_config "$entry")"
391
+ src_file="$(_ai_src "$entry")"
392
+ _sync_convention_for_tool "$ROLL_GLOBAL/$src_file" "$ai_dir/$config_file" "$force"
393
+ done < <(_get_ai_tools)
394
+ }
395
+
396
+ # ─── Internal: sync skills (pull + link) ──────────────────────────────────────
397
+ _sync_skills() {
398
+ local force="${1:-false}"
399
+ info "Updating skills... 正在更新技能..."
400
+ _pull_skills
401
+ ok "Skills updated in ~/.roll/skills 技能已更新至 ~/.roll/skills"
402
+ info "Creating skill symlinks for AI tools... 正在为 AI 工具创建技能软链接..."
403
+ _link_skills "$force"
404
+ }
405
+
406
+ # ═══════════════════════════════════════════════════════════════════════════════
407
+ # COMMAND: setup [--force]
408
+ # Initialize ~/.roll/ and sync everything to AI tools in one step
409
+ # ═══════════════════════════════════════════════════════════════════════════════
410
+ cmd_setup() {
411
+ local force=false
412
+ while [[ $# -gt 0 ]]; do
413
+ case "$1" in
414
+ --force|-f) force=true; shift ;;
415
+ *) err "Unknown argument: $1 未知参数: $1"; exit 1 ;;
416
+ esac
417
+ done
418
+
419
+ info "Setting up Roll on this machine... 正在初始化 Roll..."
420
+ echo ""
421
+
422
+ _install_local "$force"
423
+ echo ""
424
+ info "Syncing to AI tools... 正在同步到 AI 工具..."
425
+ _sync_conventions "$force"
426
+ echo ""
427
+ _sync_skills "$force"
428
+
429
+ echo ""
430
+ ok "Setup complete. 初始化完成。"
431
+
432
+ # Offer git hook install if not already installed (interactive terminals only)
433
+ local hook_dst="$HOME/.config/git/hooks/prepare-commit-msg"
434
+ local hooks_path
435
+ hooks_path=$(git config --global core.hooksPath 2>/dev/null || true)
436
+ if [[ -t 0 ]] && { [[ ! -f "$hook_dst" ]] || [[ "$hooks_path" != "$HOME/.config/git/hooks" ]]; }; then
437
+ echo ""
438
+ echo -n " Install global git hook (tags commits with AI client)? [y/N] "
439
+ read -r _hook_ans
440
+ if [[ "${_hook_ans:-N}" =~ ^[Yy]$ ]]; then
441
+ cmd_hook install
442
+ fi
443
+ fi
444
+
445
+ echo ""
446
+ info "Next: run ${BOLD}roll init${NC} inside a project to initialize it. 下一步:在项目目录运行 roll init"
447
+ }
448
+
449
+ # ═══════════════════════════════════════════════════════════════════════════════
450
+ # COMMAND: sync [--force]
451
+ # Full pipeline: repo → ~/.roll/ → AI tool config paths
452
+ # Pulls latest conventions from repo, then distributes to all AI tools.
453
+ # ═══════════════════════════════════════════════════════════════════════════════
454
+ cmd_sync() {
455
+ if [[ ! -d "$ROLL_HOME" ]]; then
456
+ err "~/.roll/ not found. Run 'roll setup' first. ~/.roll/ 不存在,请先运行 'roll setup'。"
457
+ exit 1
458
+ fi
459
+
460
+ local force=false
461
+ while [[ $# -gt 0 ]]; do
462
+ case "$1" in
463
+ --force|-f) force=true; shift ;;
464
+ *) err "Unknown sync argument: $1 未知同步参数: $1"; exit 1 ;;
465
+ esac
466
+ done
467
+
468
+ info "Syncing from repo to AI tools... 正在从仓库同步到 AI 工具..."
469
+ echo ""
470
+ _pull_conventions "$force"
471
+ echo ""
472
+ _sync_conventions "$force"
473
+ echo ""
474
+ _sync_skills "$force"
475
+ echo ""
476
+ ok "Sync complete. 同步完成。"
477
+ }
478
+
479
+ # ─── Helper: merge global AGENTS.md into project (no type prompt) ────────────
480
+ # Fresh project: copies global AGENTS.md.
481
+ # Existing AGENTS.md: appends any ## sections missing from global.
482
+ _merge_global_to_project() {
483
+ local project_dir="$1"
484
+ local src="$ROLL_GLOBAL/AGENTS.md"
485
+ local dst="$project_dir/AGENTS.md"
486
+
487
+ [[ -f "$src" ]] || { warn "Global AGENTS.md not found at ${src/#$HOME/~}"; return; }
488
+
489
+ if [[ ! -f "$dst" ]]; then
490
+ cp "$src" "$dst"
491
+ ok "Created: AGENTS.md"
492
+ _ROLL_MERGE_SUMMARY+=("created|AGENTS.md")
493
+ return
494
+ fi
495
+
496
+ if diff -q "$src" "$dst" &>/dev/null; then
497
+ _ROLL_MERGE_SUMMARY+=("unchanged|AGENTS.md")
498
+ return
499
+ fi
500
+
501
+ # Section-merge: append any ## sections from global missing in project
502
+ local added=0 cur_h="" cur_b=""
503
+ while IFS= read -r line; do
504
+ if [[ "$line" =~ ^##\ ]]; then
505
+ if [[ -n "$cur_h" ]] && ! grep -qF "$cur_h" "$dst" 2>/dev/null; then
506
+ printf '\n%s\n%s' "$cur_h" "$cur_b" >> "$dst"
507
+ added=$((added + 1))
508
+ fi
509
+ cur_h="$line"; cur_b=""
510
+ elif [[ -n "$cur_h" ]]; then
511
+ cur_b+="$line"$'\n'
512
+ fi
513
+ done < "$src"
514
+ if [[ -n "$cur_h" ]] && ! grep -qF "$cur_h" "$dst" 2>/dev/null; then
515
+ printf '\n%s\n%s' "$cur_h" "$cur_b" >> "$dst"
516
+ added=$((added + 1))
517
+ fi
518
+
519
+ if [[ $added -gt 0 ]]; then
520
+ ok "Merged: AGENTS.md ($added new sections)"
521
+ _ROLL_MERGE_SUMMARY+=("merged|AGENTS.md")
522
+ else
523
+ _ROLL_MERGE_SUMMARY+=("unchanged|AGENTS.md")
524
+ fi
525
+ }
526
+
527
+ # ═══════════════════════════════════════════════════════════════════════════════
528
+ # COMMAND: init
529
+ # Initialize or re-merge a project. Always operates on the current directory.
530
+ # Fresh project: creates AGENTS.md + BACKLOG.md + docs/features/
531
+ # Existing AGENTS.md: re-merges global conventions (section-level, non-destructive)
532
+ # ═══════════════════════════════════════════════════════════════════════════════
533
+ cmd_init() {
534
+ if [[ ! -d "$ROLL_TEMPLATES" ]]; then
535
+ err "No templates found. Run 'roll setup' first. 未找到模板,请先运行 'roll setup'。"
536
+ exit 1
537
+ fi
538
+
539
+ local project_dir
540
+ project_dir="$(pwd)"
541
+ local has_agents=false
542
+ _ROLL_MERGE_SUMMARY=()
543
+
544
+ if [[ -f "$project_dir/AGENTS.md" ]]; then
545
+ has_agents=true
546
+ info "Re-merging global conventions... 正在重新合并全局约定..."
547
+ fi
548
+
549
+ _merge_global_to_project "$project_dir"
550
+ _write_backlog "$project_dir/BACKLOG.md"
551
+ _ensure_features_dir "$project_dir/docs/features"
552
+ print_merge_summary
553
+ echo ""
554
+
555
+ if [[ "$has_agents" == "true" ]]; then
556
+ ok "Done. 完成。"
557
+ else
558
+ ok "Initialized. 已初始化。"
559
+ fi
560
+ }
561
+
562
+ # ─── Helper: merge global preamble + template into output ────────────────────
563
+ merge_convention() {
564
+ local filename="$1"
565
+ local tpl_dir="$2"
566
+ local out_dir="$3"
567
+ local display="${4:-$filename}"
568
+ local global_file="$ROLL_GLOBAL/$filename"
569
+ local tpl_file="$tpl_dir/$filename"
570
+ local out_file="$out_dir/$filename"
571
+
572
+ if [[ ! -f "$tpl_file" ]]; then
573
+ return
574
+ fi
575
+
576
+ local merged
577
+ merged="$(cat "$global_file" 2>/dev/null || true)"
578
+ merged+=$'\n\n'
579
+ merged+="---"
580
+ merged+=$'\n\n'
581
+ merged+="$(cat "$tpl_file")"
582
+
583
+ if [[ -f "$out_file" ]]; then
584
+ if diff -q <(echo "$merged") "$out_file" &>/dev/null; then
585
+ _ROLL_MERGE_SUMMARY+=("unchanged|$display")
586
+ return # identical, nothing to do
587
+ fi
588
+ warn "File exists: $display 文件已存在: $display"
589
+ echo -n " [o] Overwrite [k] Keep [M] Merge (default): "
590
+ read -r answer
591
+ answer="${answer:-M}"
592
+ case "$answer" in
593
+ o|O)
594
+ echo "$merged" > "$out_file"
595
+ ok "Overwritten: $display 已覆盖: $display"
596
+ _ROLL_MERGE_SUMMARY+=("overwritten|$display")
597
+ return
598
+ ;;
599
+ k|K)
600
+ info "Kept: $display 已保留: $display"
601
+ _ROLL_MERGE_SUMMARY+=("kept|$display")
602
+ return
603
+ ;;
604
+ m|M|"")
605
+ # Merge: for each ## section in template:
606
+ # - if missing in output → append
607
+ # - if present but content differs → show diff, prompt [u/k]
608
+ # - if present and identical → skip
609
+
610
+ # Helper: extract a section's body (lines after heading until next ## or EOF)
611
+ # Usage: _extract_section "$file" "$heading"
612
+ _extract_section() {
613
+ local file="$1"
614
+ local heading="$2"
615
+ local in_section=false
616
+ local body=""
617
+ while IFS= read -r ln; do
618
+ if [[ "$ln" == "$heading" ]]; then
619
+ in_section=true
620
+ continue
621
+ fi
622
+ if [[ "$in_section" == "true" ]]; then
623
+ if [[ "$ln" =~ ^##\ ]]; then
624
+ break
625
+ fi
626
+ body+="$ln"$'\n'
627
+ fi
628
+ done < "$file"
629
+ printf '%s' "$body"
630
+ }
631
+
632
+ local current_heading=""
633
+ local current_body=""
634
+
635
+ # _process_section: check/append/update a completed section
636
+ _process_section() {
637
+ local heading="$1"
638
+ local body="$2"
639
+ local out="$3"
640
+ if ! grep -qF "$heading" "$out" 2>/dev/null; then
641
+ # Section missing → append
642
+ printf '\n%s\n%s' "$heading" "$body" >> "$out"
643
+ else
644
+ # Section exists → compare content
645
+ local existing
646
+ existing="$(_extract_section "$out" "$heading")"
647
+ if [[ "$body" != "$existing" ]]; then
648
+ echo ""
649
+ warn "Section \"$heading\" exists but content differs:"
650
+ diff <(printf '%s' "$existing") <(printf '%s' "$body") || true
651
+ echo -n " [u] update with template [k] keep mine (default): "
652
+ local sec_ans
653
+ read -r sec_ans
654
+ sec_ans="${sec_ans:-k}"
655
+ if [[ "$sec_ans" == "u" || "$sec_ans" == "U" ]]; then
656
+ # Replace existing section with template section
657
+ local tmp_out
658
+ tmp_out="$(mktemp)"
659
+ local skip=false
660
+ while IFS= read -r fline; do
661
+ if [[ "$fline" == "$heading" ]]; then
662
+ skip=true
663
+ printf '%s\n%s' "$heading" "$body" >> "$tmp_out"
664
+ continue
665
+ fi
666
+ if [[ "$skip" == "true" ]]; then
667
+ if [[ "$fline" =~ ^##\ ]]; then
668
+ skip=false
669
+ printf '%s\n' "$fline" >> "$tmp_out"
670
+ fi
671
+ continue
672
+ fi
673
+ printf '%s\n' "$fline" >> "$tmp_out"
674
+ done < "$out"
675
+ mv "$tmp_out" "$out"
676
+ fi
677
+ fi
678
+ fi
679
+ }
680
+
681
+ # Parse template sections, reading from file (not herestring) to keep stdin free
682
+ # for interactive user prompts inside _process_section
683
+ local tpl_sections=()
684
+ local tpl_bodies=()
685
+ local cur_h="" cur_b=""
686
+ while IFS= read -r line; do
687
+ if [[ "$line" =~ ^##\ ]]; then
688
+ if [[ -n "$cur_h" ]]; then
689
+ tpl_sections+=("$cur_h")
690
+ tpl_bodies+=("$cur_b")
691
+ fi
692
+ cur_h="$line"
693
+ cur_b=""
694
+ elif [[ -n "$cur_h" ]]; then
695
+ cur_b+="$line"$'\n'
696
+ fi
697
+ done < "$tpl_file"
698
+ # Capture last section
699
+ if [[ -n "$cur_h" ]]; then
700
+ tpl_sections+=("$cur_h")
701
+ tpl_bodies+=("$cur_b")
702
+ fi
703
+
704
+ # Now process sections (stdin is free for user input)
705
+ local i
706
+ for (( i=0; i<${#tpl_sections[@]}; i++ )); do
707
+ _process_section "${tpl_sections[$i]}" "${tpl_bodies[$i]}" "$out_file"
708
+ done
709
+
710
+ ok "Merged: $display 已合并: $display"
711
+ _ROLL_MERGE_SUMMARY+=("merged|$display")
712
+ return
713
+ ;;
714
+ esac
715
+ fi
716
+
717
+ echo "$merged" > "$out_file"
718
+ ok "Created: $display 已创建: $display"
719
+ _ROLL_MERGE_SUMMARY+=("created|$display")
720
+ }
721
+
722
+ # ─── Helper: print a tidy summary of merge actions ───────────────────────────
723
+ print_merge_summary() {
724
+ if [[ ${#_ROLL_MERGE_SUMMARY[@]} -eq 0 ]]; then
725
+ return
726
+ fi
727
+ echo ""
728
+ echo " ┌─ 操作摘要 Summary ──────────────────────────────────┐"
729
+ for entry in "${_ROLL_MERGE_SUMMARY[@]}"; do
730
+ local action="${entry%%|*}"
731
+ local file="${entry##*|}"
732
+ case "$action" in
733
+ merged) printf " │ ${GREEN}✦ merged${NC} %-30s│\n" "$file" ;;
734
+ created) printf " │ ${GREEN}+ created${NC} %-30s│\n" "$file" ;;
735
+ overwritten) printf " │ ${YELLOW}↺ overwritten${NC} %-30s│\n" "$file" ;;
736
+ kept) printf " │ ${CYAN}· kept${NC} %-30s│\n" "$file" ;;
737
+ unchanged) printf " │ unchanged %-30s│\n" "$file" ;;
738
+ esac
739
+ done
740
+ echo " └─────────────────────────────────────────────────────┘"
741
+ }
742
+
743
+ # ─── Helper: auto-detect project type by scanning project files ──────────────
744
+ scan_project_type_from_files() {
745
+ local dir="${1:-.}"
746
+ local has_frontend=false
747
+ local has_backend=false
748
+ local has_cli=false
749
+
750
+ # Frontend signals
751
+ if [[ -f "$dir/package.json" ]]; then
752
+ grep -qiE '"react"|"vue"|"next"|"nuxt"|"vite"|"svelte"' "$dir/package.json" 2>/dev/null \
753
+ && has_frontend=true
754
+ fi
755
+ [[ -d "$dir/src" || -d "$dir/app" || -d "$dir/pages" || -d "$dir/components" ]] \
756
+ && has_frontend=true
757
+
758
+ # Backend/API signals
759
+ [[ -d "$dir/server" || -d "$dir/api" || -d "$dir/backend" ]] && has_backend=true
760
+ [[ -f "$dir/go.mod" || -f "$dir/main.go" || -f "$dir/main.py" || -f "$dir/app.py" \
761
+ || -f "$dir/Cargo.toml" || -f "$dir/requirements.txt" || -f "$dir/pyproject.toml" ]] \
762
+ && has_backend=true
763
+ # DB/ORM/server-side deps in package.json → backend signal
764
+ if [[ -f "$dir/package.json" ]]; then
765
+ grep -qiE '"prisma"|"@prisma/client"|"typeorm"|"sequelize"|"mongoose"|"drizzle-orm"|"@neondatabase/serverless"|"pg"|"mysql2"|"mongodb"|"redis"|"ioredis"|"express"|"fastify"|"koa"|"hapi"|"@hapi/hapi"|"apollo-server"|"graphql-yoga"|"trpc"' "$dir/package.json" 2>/dev/null \
766
+ && has_backend=true
767
+ fi
768
+ # Prisma schema file is a definitive backend signal
769
+ [[ -f "$dir/prisma/schema.prisma" ]] && has_backend=true
770
+
771
+ # CLI signals (bin/ with executables, or cmd/ layout common in Go CLIs)
772
+ [[ -d "$dir/bin" || -d "$dir/cmd" ]] && has_cli=true
773
+
774
+ # Determine type
775
+ if $has_frontend && $has_backend; then
776
+ echo "fullstack"
777
+ elif $has_frontend && ! $has_backend; then
778
+ echo "frontend-only"
779
+ elif $has_cli && ! $has_frontend; then
780
+ echo "cli"
781
+ elif $has_backend && ! $has_frontend; then
782
+ echo "backend-service"
783
+ else
784
+ echo "unknown"
785
+ fi
786
+ }
787
+
788
+ # ─── Helper: true when cwd has no existing source code ───────────────────────
789
+ is_fresh_project() {
790
+ local dir="${1:-.}"
791
+ ! ( [[ -f "$dir/package.json" ]] || [[ -f "$dir/go.mod" ]] || \
792
+ [[ -f "$dir/Cargo.toml" ]] || [[ -f "$dir/requirements.txt" ]] || \
793
+ [[ -f "$dir/pyproject.toml" ]] || \
794
+ [[ -d "$dir/src" ]] || [[ -d "$dir/api" ]] || [[ -d "$dir/app" ]] )
795
+ }
796
+
797
+ # ─── Helper: make a scaffold dir + .gitkeep ───────────────────────────────────
798
+ _mkscaffold() { mkdir -p "$1"; touch "$1/.gitkeep"; }
799
+
800
+ # ─── Helper: write starter BACKLOG.md (no-op if exists) ──────────────────────
801
+ _write_backlog() {
802
+ if [[ -f "$1" ]]; then
803
+ _WK_MERGE_SUMMARY+=("unchanged|BACKLOG.md")
804
+ return
805
+ fi
806
+ cat > "$1" << 'EOF'
807
+ # Project Backlog
808
+
809
+ ## Epic: Initial Setup
810
+ | Story | Description | Status |
811
+ |-------|-------------|--------|
812
+
813
+ ## Bug Fixes
814
+ | ID | Problem | Status |
815
+ |----|---------|--------|
816
+ EOF
817
+ ok "Created: BACKLOG.md"
818
+ _WK_MERGE_SUMMARY+=("created|BACKLOG.md")
819
+ }
820
+
821
+ _ensure_features_dir() {
822
+ if [[ -d "$1" ]]; then
823
+ _WK_MERGE_SUMMARY+=("unchanged|docs/features/")
824
+ return
825
+ fi
826
+
827
+ mkdir -p "$1"
828
+ ok "Created: docs/features/"
829
+ _WK_MERGE_SUMMARY+=("created|docs/features/")
830
+ }
831
+
832
+ # ─── Helper: write starter .gitignore (no-op if exists) ──────────────────────
833
+ _write_gitignore() {
834
+ [[ -f "$1" ]] && return
835
+ cat > "$1" << 'EOF'
836
+ node_modules/
837
+ dist/
838
+ build/
839
+ .env
840
+ *.local
841
+ .DS_Store
842
+ *.log
843
+ EOF
844
+ }
845
+
846
+ # ─── Helper: write starter .env.example (no-op if exists) ────────────────────
847
+ _write_env_example() {
848
+ [[ -f "$1" ]] && return
849
+ cat > "$1" << 'EOF'
850
+ # Environment Variables — copy to .env and fill in values
851
+
852
+ # Application
853
+ # NODE_ENV=development
854
+ # PORT=3000
855
+
856
+ # Database
857
+ # DATABASE_URL=postgresql://user:pass@localhost:5432/db
858
+
859
+ # Auth
860
+ # JWT_SECRET=your-secret-key
861
+ EOF
862
+ }
863
+
864
+ # ─── Helper: detect project type from existing AGENTS.md ─────────────────────
865
+ detect_project_type() {
866
+ local agents_file="$1/AGENTS.md"
867
+ [[ -f "$agents_file" ]] || { echo "unknown"; return; }
868
+
869
+ local content
870
+ content="$(cat "$agents_file")"
871
+
872
+ if echo "$content" | grep -qi "Fullstack Web"; then
873
+ echo "fullstack"
874
+ elif echo "$content" | grep -qi "Backend Service"; then
875
+ echo "backend-service"
876
+ elif echo "$content" | grep -qi "Frontend Only"; then
877
+ echo "frontend-only"
878
+ elif echo "$content" | grep -qi "CLI Tool"; then
879
+ echo "cli"
880
+ else
881
+ # AGENTS.md exists but has no type marker — fall back to file-based scan
882
+ scan_project_type_from_files "$1"
883
+ fi
884
+ }
885
+
886
+ # ─── Helper: detect AI tools from existing convention files ──────────────────
887
+ detect_tools() {
888
+ local dir="$1"
889
+ local tools=""
890
+ [[ -f "$dir/.claude/CLAUDE.md" ]] && tools+="claude,"
891
+ [[ -f "$dir/GEMINI.md" ]] && tools+="gemini,"
892
+ [[ -f "$dir/.cursor-rules" ]] && tools+="cursor,"
893
+ echo "${tools%,}"
894
+ }
895
+
896
+ # ─── Helper: refresh a single project ────────────────────────────────────────
897
+ refresh_project() {
898
+ local project_dir="$1"
899
+ local project_name
900
+ project_name="$(basename "$project_dir")"
901
+
902
+ local ptype
903
+ ptype="$(detect_project_type "$project_dir")"
904
+
905
+ if [[ "$ptype" == "unknown" ]]; then
906
+ warn " ${BOLD}$project_name${NC}: cannot detect project type — skipping 无法检测项目类型 — 已跳过"
907
+ return 1
908
+ fi
909
+
910
+ local tools
911
+ tools="$(detect_tools "$project_dir")"
912
+
913
+ local tpl_dir="$ROLL_TEMPLATES/$ptype"
914
+ if [[ ! -d "$tpl_dir" ]]; then
915
+ warn " ${BOLD}$project_name${NC}: template '$ptype' not found — skipping 模板 '$ptype' 未找到 — 已跳过"
916
+ return 1
917
+ fi
918
+
919
+ info " ${BOLD}$project_name${NC} ($ptype | ${tools:-AGENTS.md only}) 正在处理: ${BOLD}$project_name${NC}"
920
+
921
+ merge_convention "AGENTS.md" "$tpl_dir" "$project_dir" " AGENTS.md"
922
+
923
+ if echo "$tools" | grep -q "claude"; then
924
+ mkdir -p "$project_dir/.claude"
925
+ merge_convention "CLAUDE.md" "$tpl_dir" "$project_dir/.claude" " .claude/CLAUDE.md"
926
+ fi
927
+ if echo "$tools" | grep -q "gemini"; then
928
+ merge_convention "GEMINI.md" "$tpl_dir" "$project_dir" " GEMINI.md"
929
+ fi
930
+ if echo "$tools" | grep -q "cursor"; then
931
+ merge_convention ".cursor-rules" "$tpl_dir" "$project_dir" " .cursor-rules"
932
+ fi
933
+
934
+ return 0
935
+ }
936
+
937
+ # ═══════════════════════════════════════════════════════════════════════════════
938
+ # COMMAND: hooks <subcommand>
939
+ # Manage the global git hook (opt-in — modifies git global config)
940
+ # ═══════════════════════════════════════════════════════════════════════════════
941
+ cmd_hook() {
942
+ local subcmd="${1:-install}"
943
+
944
+ case "$subcmd" in
945
+ install)
946
+ local hooks_dir="$HOME/.config/git/hooks"
947
+ local hook_src="$ROLL_PKG_DIR/hooks/prepare-commit-msg"
948
+ local hook_dst="$hooks_dir/prepare-commit-msg"
949
+
950
+ if [[ ! -f "$hook_src" ]]; then
951
+ err "Hook source not found: $hook_src Hook 源文件未找到: $hook_src"
952
+ exit 1
953
+ fi
954
+
955
+ local current_hooks_path
956
+ current_hooks_path=$(git config --global core.hooksPath 2>/dev/null || echo "(not set)")
957
+
958
+ echo ""
959
+ warn "This will modify your global git configuration: 此操作将修改您的全局 git 配置:"
960
+ echo " git config --global core.hooksPath $hooks_dir"
961
+ echo ""
962
+ echo " Current value: $current_hooks_path 当前值: $current_hooks_path"
963
+ echo ""
964
+ if [[ "$current_hooks_path" != "(not set)" ]] && [[ "$current_hooks_path" != "$hooks_dir" ]]; then
965
+ warn "WARNING: Hooks at '$current_hooks_path' will stop running after this change. 警告:此更改后 '$current_hooks_path' 处的 hooks 将不再运行。"
966
+ echo ""
967
+ fi
968
+ echo -n " Proceed? [y/N] "
969
+ read -r answer
970
+ [[ "$answer" =~ ^[Yy]$ ]] || { info "Aborted. 已取消。"; return; }
971
+
972
+ mkdir -p "$hooks_dir"
973
+ cp "$hook_src" "$hook_dst"
974
+ chmod +x "$hook_dst"
975
+ git config --global core.hooksPath "$hooks_dir"
976
+ ok "Global git hook installed (AI client auto-detected on every commit) 全局 git hook 已安装(每次提交自动检测 AI 客户端)"
977
+ ;;
978
+ remove)
979
+ git config --global --unset core.hooksPath 2>/dev/null && \
980
+ ok "Removed core.hooksPath from global git config 已从全局 git 配置中移除 core.hooksPath" || \
981
+ info "core.hooksPath was not set core.hooksPath 未设置"
982
+ ;;
983
+ *)
984
+ err "Unknown hooks subcommand: $subcmd 未知 hooks 子命令: $subcmd"
985
+ echo "Usage: roll hook [install|remove] 用法: roll hook [install|remove]"
986
+ exit 1
987
+ ;;
988
+ esac
989
+ }
990
+
991
+ # ═══════════════════════════════════════════════════════════════════════════════
992
+ # COMMAND: reset
993
+ # Re-copy conventions and skills from repo source, force-sync everything
994
+ # ═══════════════════════════════════════════════════════════════════════════════
995
+ cmd_reset() {
996
+ warn "This will overwrite ~/.roll/conventions/ and skills with repo defaults. 此操作将用仓库默认值覆盖 ~/.roll/conventions/ 和技能。"
997
+ echo -n " Continue? [y/N] "
998
+ read -r answer
999
+ [[ "$answer" =~ ^[Yy]$ ]] || { info "Aborted. 已取消。"; return; }
1000
+
1001
+ echo ""
1002
+ info "Resetting and force-syncing... 正在重置并强制同步..."
1003
+ cmd_setup --force
1004
+ }
1005
+
1006
+ # ═══════════════════════════════════════════════════════════════════════════════
1007
+ # COMMAND: update
1008
+ # Update roll to latest version (npm update or git pull), then re-sync
1009
+ # ═══════════════════════════════════════════════════════════════════════════════
1010
+ _is_git_install() {
1011
+ git -C "$ROLL_PKG_DIR" rev-parse --git-dir &>/dev/null 2>&1
1012
+ }
1013
+
1014
+ cmd_update() {
1015
+ info "Current version: roll v${VERSION} 当前版本: roll v${VERSION}"
1016
+ echo ""
1017
+
1018
+ if _is_git_install; then
1019
+ info "Git install detected — pulling latest... 检测到 Git 安装,正在拉取最新代码..."
1020
+ git -C "$ROLL_PKG_DIR" pull
1021
+ else
1022
+ info "npm install detected — updating package... 检测到 npm 安装,正在更新包..."
1023
+ npm update -g @seanyao/roll
1024
+ fi
1025
+
1026
+ echo ""
1027
+ info "Re-syncing to AI tools... 正在重新同步到 AI 工具..."
1028
+ cmd_sync
1029
+ }
1030
+
1031
+ # ═══════════════════════════════════════════════════════════════════════════════
1032
+ # COMMAND: clean
1033
+ # Remove legacy ~/.cybernetix/ and ~/.wukong/ remnants after migration
1034
+ # ═══════════════════════════════════════════════════════════════════════════════
1035
+ cmd_clean() {
1036
+ local found=false
1037
+
1038
+ echo -e "${BOLD}Roll Legacy Cleanup 清理旧版遗留文件${NC}"
1039
+ echo ""
1040
+
1041
+ # Detect what exists
1042
+ for legacy_dir in "$HOME/.cybernetix" "$HOME/.wukong"; do
1043
+ [[ -d "$legacy_dir" ]] && found=true
1044
+ done
1045
+ for legacy_bin in "$HOME/.local/bin/cybernetix" "$HOME/.local/bin/wukong"; do
1046
+ { [[ -L "$legacy_bin" ]] || [[ -f "$legacy_bin" ]]; } && found=true
1047
+ done
1048
+ # Old skill symlinks
1049
+ local ai_dirs=("$HOME/.claude" "$HOME/.gemini" "$HOME/.kimi" "$HOME/.codex" "$HOME/.cursor")
1050
+ for ai_dir in "${ai_dirs[@]}"; do
1051
+ local skills_dir="$ai_dir/skills"
1052
+ [[ -d "$skills_dir" ]] || continue
1053
+ if find "$skills_dir" -maxdepth 1 -type l \( -name "wk-*" -o -name "cnx-*" \) 2>/dev/null | grep -q .; then
1054
+ found=true
1055
+ fi
1056
+ done
1057
+
1058
+ if [[ "$found" == "false" ]]; then
1059
+ ok "Nothing to clean — no legacy files found. 未发现遗留文件。"
1060
+ return
1061
+ fi
1062
+
1063
+ # Show what will be removed
1064
+ info "Found legacy items to remove: 发现以下遗留文件:"
1065
+ echo ""
1066
+ for d in "$HOME/.cybernetix" "$HOME/.wukong"; do
1067
+ [[ -d "$d" ]] && echo -e " ${RED}✕${NC} ${d/#$HOME/~}/"
1068
+ done
1069
+ for b in "$HOME/.local/bin/cybernetix" "$HOME/.local/bin/wukong"; do
1070
+ { [[ -L "$b" ]] || [[ -f "$b" ]]; } && echo -e " ${RED}✕${NC} ${b/#$HOME/~}"
1071
+ done
1072
+ for ai_dir in "${ai_dirs[@]}"; do
1073
+ local skills_dir="$ai_dir/skills"
1074
+ [[ -d "$skills_dir" ]] || continue
1075
+ local old_links
1076
+ old_links=$(find "$skills_dir" -maxdepth 1 -type l \( -name "wk-*" -o -name "cnx-*" \) 2>/dev/null || true)
1077
+ if [[ -n "$old_links" ]]; then
1078
+ local count
1079
+ count=$(echo "$old_links" | wc -l | tr -d ' ')
1080
+ echo -e " ${RED}✕${NC} ${skills_dir/#$HOME/~}/ ($count legacy symlinks)"
1081
+ fi
1082
+ done
1083
+
1084
+ echo ""
1085
+ echo -n " Remove all of the above? [y/N] "
1086
+ read -r answer
1087
+ [[ "$answer" =~ ^[Yy]$ ]] || { info "Aborted. 已取消。"; return; }
1088
+
1089
+ echo ""
1090
+ local removed=0
1091
+
1092
+ # Remove legacy home dirs
1093
+ for d in "$HOME/.cybernetix" "$HOME/.wukong"; do
1094
+ if [[ -d "$d" ]]; then
1095
+ rm -rf "$d"
1096
+ ok "Removed: ${d/#$HOME/~} 已删除: ${d/#$HOME/~}"
1097
+ removed=$((removed + 1))
1098
+ fi
1099
+ done
1100
+
1101
+ # Remove legacy binaries
1102
+ for b in "$HOME/.local/bin/cybernetix" "$HOME/.local/bin/wukong"; do
1103
+ if [[ -L "$b" ]] || [[ -f "$b" ]]; then
1104
+ rm -f "$b"
1105
+ ok "Removed: ${b/#$HOME/~} 已删除: ${b/#$HOME/~}"
1106
+ removed=$((removed + 1))
1107
+ fi
1108
+ done
1109
+
1110
+ # Remove legacy skill symlinks
1111
+ for ai_dir in "${ai_dirs[@]}"; do
1112
+ local skills_dir="$ai_dir/skills"
1113
+ [[ -d "$skills_dir" ]] || continue
1114
+ local old_links
1115
+ old_links=$(find "$skills_dir" -maxdepth 1 -type l \( -name "wk-*" -o -name "cnx-*" \) 2>/dev/null || true)
1116
+ if [[ -n "$old_links" ]]; then
1117
+ while IFS= read -r link; do rm -f "$link"; done <<< "$old_links"
1118
+ local count
1119
+ count=$(echo "$old_links" | wc -l | tr -d ' ')
1120
+ ok "Removed $count legacy symlinks from ${skills_dir/#$HOME/~} 已删除 $count 个遗留软链接"
1121
+ removed=$((removed + count))
1122
+ fi
1123
+ done
1124
+
1125
+ echo ""
1126
+ ok "Cleaned $removed legacy items. 已清理 $removed 个遗留文件。"
1127
+ }
1128
+
1129
+ # ═══════════════════════════════════════════════════════════════════════════════
1130
+ # COMMAND: status
1131
+ # Show current state of conventions
1132
+ # ═══════════════════════════════════════════════════════════════════════════════
1133
+ cmd_status() {
1134
+ echo -e "${BOLD}Roll Convention Status Roll 约定状态${NC}"
1135
+ echo ""
1136
+
1137
+ if [[ -d "$ROLL_HOME" ]]; then
1138
+ ok "~/.roll/ exists ~/.roll/ 已存在"
1139
+ else
1140
+ err "~/.roll/ not found — run 'roll setup' ~/.roll/ 不存在 — 请运行 'roll setup'"
1141
+ return
1142
+ fi
1143
+
1144
+ echo ""
1145
+ echo -e "${BOLD}Global conventions: 全局约定${NC}"
1146
+ for f in AGENTS.md CLAUDE.md GEMINI.md .cursor-rules; do
1147
+ if [[ -f "$ROLL_GLOBAL/$f" ]]; then
1148
+ echo -e " ${GREEN}+${NC} $f"
1149
+ else
1150
+ echo -e " ${RED}-${NC} $f (missing / 缺失)"
1151
+ fi
1152
+ done
1153
+
1154
+ echo ""
1155
+ echo -e "${BOLD}Global skills: 全局技能${NC}"
1156
+ if [[ -d "$ROLL_HOME/skills" ]]; then
1157
+ local count
1158
+ count=$(find "$ROLL_HOME/skills" -maxdepth 1 -type d | wc -l | tr -d ' ')
1159
+ count=$((count - 1))
1160
+ echo -e " ${GREEN}+${NC} ~/.roll/skills ($count skills installed / 已安装 $count 个技能)"
1161
+ else
1162
+ echo -e " ${RED}-${NC} ~/.roll/skills (missing / 缺失)"
1163
+ fi
1164
+
1165
+ echo ""
1166
+ echo -e "${BOLD}Sync targets: 同步目标${NC}"
1167
+
1168
+ local _sync_found=0
1169
+ while IFS= read -r _entry; do
1170
+ _sync_found=1
1171
+ local _ai_d _cfg _src _tool_name
1172
+ _ai_d="$(_ai_dir "$_entry")"
1173
+ _cfg="$(_ai_config "$_entry")"
1174
+ _src="$(_ai_src "$_entry")"
1175
+ _tool_name="$(ai_tool_name "$_ai_d")"
1176
+ check_sync_status "$_tool_name" "$ROLL_GLOBAL/$_src" "$_ai_d/$_cfg"
1177
+ done < <(_get_ai_tools)
1178
+ if [[ "$_sync_found" -eq 0 ]]; then
1179
+ warn "No AI tools configured — check ~/.roll/config.yaml 未配置 AI 工具 — 请检查 ~/.roll/config.yaml"
1180
+ info "Add ai_* entries or run 'roll setup' to restore defaults. 添加 ai_* 条目或运行 'roll setup' 恢复默认配置。"
1181
+ fi
1182
+
1183
+ echo ""
1184
+ echo -e "${BOLD}Skill symlinks: 技能软链接${NC}"
1185
+ local total_skills=0
1186
+ local wk_skills_real
1187
+ if [[ -d "$ROLL_HOME/skills" ]]; then
1188
+ # Count roll-* skill dirs to match the linked_count scope below
1189
+ total_skills=$(find "$ROLL_HOME/skills" -maxdepth 1 -mindepth 1 -type d -name "roll-*" | wc -l | tr -d ' ')
1190
+ wk_skills_real="$(canonical_dir "$ROLL_HOME/skills" 2>/dev/null || true)"
1191
+ fi
1192
+ local _skills_found=0
1193
+ while IFS= read -r _entry; do
1194
+ local ai_dir
1195
+ ai_dir="$(_ai_dir "$_entry")"
1196
+ [[ -d "$ai_dir" ]] || continue
1197
+ _skills_found=1
1198
+ local name name_lower
1199
+ name="$(ai_tool_name "$ai_dir")"
1200
+ name="$(echo "$name" | tr '[:lower:]' '[:upper:]' | cut -c1)$(echo "$name" | cut -c2-)"
1201
+ name_lower="$(lower_name "$name")"
1202
+ local skills_dir="$ai_dir/skills"
1203
+ if [[ -d "$skills_dir" ]]; then
1204
+ if [[ -L "$skills_dir" ]]; then
1205
+ local skills_target skills_real
1206
+ skills_target="$(readlink "$skills_dir")"
1207
+ skills_real="$(canonical_dir "$skills_dir" 2>/dev/null || true)"
1208
+ local skills_display="${skills_dir/#$HOME/~}"
1209
+ if [[ -n "$skills_real" && "$skills_real" == "$wk_skills_real" ]]; then
1210
+ echo -e " ${GREEN}=${NC} $name: $skills_display -> ~/.roll/skills (mounted / 已挂载)"
1211
+ else
1212
+ echo -e " ${YELLOW}~${NC} $name: $skills_display -> ${skills_target/#$HOME/~} (symlinked dir)"
1213
+ fi
1214
+ continue
1215
+ fi
1216
+
1217
+ local linked_count skills_display
1218
+ skills_display="${skills_dir/#$HOME/~}"
1219
+ linked_count=$(find "$skills_dir" -maxdepth 1 -mindepth 1 -type l -name "roll-*" 2>/dev/null | wc -l | tr -d ' ')
1220
+ if [[ "$linked_count" -eq "$total_skills" ]] && [[ "$total_skills" -gt 0 ]]; then
1221
+ echo -e " ${GREEN}=${NC} $name: $skills_display ($linked_count/$total_skills skills linked)"
1222
+ elif [[ "$linked_count" -gt 0 ]]; then
1223
+ echo -e " ${YELLOW}~${NC} $name: $skills_display ($linked_count/$total_skills skills linked)"
1224
+ else
1225
+ echo -e " ${RED}-${NC} $name: $skills_display (no roll-* skills linked / 未链接 roll-* 技能)"
1226
+ fi
1227
+ else
1228
+ echo -e " ${RED}-${NC} $name: ${skills_dir/#$HOME/~} (not found / 未找到)"
1229
+ fi
1230
+ done < <(_get_ai_tools)
1231
+ if [[ "$_skills_found" -eq 0 ]]; then
1232
+ warn "No AI tools configured — check ~/.roll/config.yaml 未配置 AI 工具 — 请检查 ~/.roll/config.yaml"
1233
+ fi
1234
+
1235
+ echo ""
1236
+ echo -e "${BOLD}Git hook: Git Hook${NC}"
1237
+ local hook_dst="$HOME/.config/git/hooks/prepare-commit-msg"
1238
+ local hooks_path
1239
+ hooks_path=$(git config --global core.hooksPath 2>/dev/null || true)
1240
+ if [[ -f "$hook_dst" ]] && [[ "$hooks_path" == "$HOME/.config/git/hooks" ]]; then
1241
+ echo -e " ${GREEN}+${NC} prepare-commit-msg (global, AI client auto-detect / 全局,自动检测 AI 客户端)"
1242
+ elif [[ -f "$hook_dst" ]]; then
1243
+ echo -e " ${YELLOW}~${NC} prepare-commit-msg exists but core.hooksPath not set prepare-commit-msg 已存在,但 core.hooksPath 未设置"
1244
+ echo -e " run: roll hook install 运行: roll hook install"
1245
+ else
1246
+ echo -e " ${RED}-${NC} not installed (optional) — run 'roll hook install' 未安装(可选)— 运行 'roll hook install'"
1247
+ fi
1248
+
1249
+ echo ""
1250
+ echo -e "${BOLD}Templates: 模板${NC}"
1251
+ for tpl in fullstack frontend-only backend-service cli; do
1252
+ if [[ -d "$ROLL_TEMPLATES/$tpl" ]]; then
1253
+ local count
1254
+ count=$(find "$ROLL_TEMPLATES/$tpl" -type f | wc -l | tr -d ' ')
1255
+ echo -e " ${GREEN}+${NC} $tpl ($count files)"
1256
+ else
1257
+ echo -e " ${RED}-${NC} $tpl (missing / 缺失)"
1258
+ fi
1259
+ done
1260
+ }
1261
+
1262
+ check_sync_status() {
1263
+ local name="$1"
1264
+ local src="$2"
1265
+ local dst="$3"
1266
+
1267
+ local display="${dst/#$HOME/~}"
1268
+ local dst_dir
1269
+ dst_dir="$(dirname "$dst")"
1270
+ local wk_file="$dst_dir/roll.md"
1271
+
1272
+ # Sync writes content to {dir}/roll.md and appends @roll.md to the main config.
1273
+ # So "in sync" means: roll.md exists + matches source + main config contains @roll.md.
1274
+ if [[ ! -f "$dst" ]]; then
1275
+ echo -e " ${RED}-${NC} $name: $display (not synced / 未同步)"
1276
+ elif [[ ! -f "$wk_file" ]]; then
1277
+ echo -e " ${YELLOW}~${NC} $name: $display (out of sync — roll.md missing / roll.md 缺失)"
1278
+ elif ! diff -q "$src" "$wk_file" &>/dev/null 2>&1; then
1279
+ echo -e " ${YELLOW}~${NC} $name: $display (out of sync — roll.md outdated / roll.md 已过期)"
1280
+ elif ! grep -qF "@roll.md" "$dst" 2>/dev/null; then
1281
+ echo -e " ${YELLOW}~${NC} $name: $display (out of sync — @roll.md not in config / 未包含 @roll.md)"
1282
+ else
1283
+ echo -e " ${GREEN}=${NC} $name: $display (in sync / 已同步)"
1284
+ fi
1285
+ }
1286
+
1287
+ # ═══════════════════════════════════════════════════════════════════════════════
1288
+ # MAIN
1289
+ # ═══════════════════════════════════════════════════════════════════════════════
1290
+ usage() {
1291
+ echo -e "${CYAN} ██████╗ ██████╗ ██╗ ██╗ ${NC}"
1292
+ echo -e "${CYAN} ██╔══██╗██╔═══██╗██║ ██║ ${NC}"
1293
+ echo -e "${CYAN} ██████╔╝██║ ██║██║ ██║ ${NC}"
1294
+ echo -e "${CYAN} ██╔══██╗██║ ██║██║ ██║ ${NC}"
1295
+ echo -e "${CYAN} ██║ ██║╚██████╔╝███████╗███████╗${NC}"
1296
+ echo -e "${CYAN} ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝${NC}"
1297
+ echo ""
1298
+ echo -e " ${BOLD}v${VERSION}${NC} — Roll out features with AI agents"
1299
+ echo ""
1300
+ echo "Usage: roll <command> [options]"
1301
+ echo "用法: roll <command> [options]"
1302
+ echo ""
1303
+ echo "Commands:"
1304
+ echo " setup [-f] [Machine] First-time install: init ~/.roll/ + sync 首次安装:初始化 ~/.roll/ 并同步"
1305
+ echo " update [Upgrade] Update roll to latest + re-sync 更新 roll 到最新版本并重新同步"
1306
+ echo " sync [-f] [Everyday] package → ~/.roll/ → AI tools 日常同步:从包同步到所有工具"
1307
+ echo " init [Project] Create AGENTS.md + BACKLOG.md + docs/ 初始化项目工作流文件"
1308
+ echo " hook [install|remove] [Optional] Manage global git hook 管理全局 git hook"
1309
+ echo " status [Diagnostic] Show current state 显示当前状态"
1310
+ echo " reset [Recovery] Force-rebuild from package source 强制从包源重建"
1311
+ echo " clean [Legacy] Remove ~/.wukong/ ~/.cybernetix/ remnants 清理遗留文件"
1312
+ echo ""
1313
+ echo "Examples / 示例:"
1314
+ echo " roll setup # New machine: first-time install 新机器:首次安装"
1315
+ echo " roll update # Get latest version + sync to AI tools 更新到最新版本并同步"
1316
+ echo " roll init # New or re-merge project (run in project) 新建或重新合并(项目目录)"
1317
+ echo " roll hook install # Optional: tag commits with AI client 可选:提交时标记 AI 工具"
1318
+ }
1319
+
1320
+ main() {
1321
+ local cmd="${1:-}"
1322
+ shift || true
1323
+
1324
+ case "$cmd" in
1325
+ setup) cmd_setup "$@" ;;
1326
+ sync) cmd_sync "$@" ;;
1327
+ init) cmd_init "$@" ;;
1328
+ hook) cmd_hook "$@" ;;
1329
+ update) cmd_update "$@" ;;
1330
+ reset) cmd_reset "$@" ;;
1331
+ clean) cmd_clean "$@" ;;
1332
+ status) cmd_status "$@" ;;
1333
+ version|--version|-v) echo "roll v${VERSION}" ;;
1334
+ help|--help|-h|"") usage ;;
1335
+ *)
1336
+ err "Unknown command: $cmd 未知命令: $cmd"
1337
+ echo ""
1338
+ usage
1339
+ exit 1
1340
+ ;;
1341
+ esac
1342
+ }
1343
+
1344
+ # ─── Version check (background, non-blocking, 24h cache) ─────────────────────
1345
+ _check_update_async() {
1346
+ local cache="${ROLL_HOME}/.update-check"
1347
+ local now; now=$(date +%s)
1348
+ local last=0
1349
+ [[ -f "$cache" ]] && last=$(awk '{print $1}' "$cache" 2>/dev/null || echo 0)
1350
+ (( now - last < 86400 )) && return
1351
+
1352
+ {
1353
+ local latest
1354
+ latest=$(curl -sf --max-time 5 \
1355
+ "https://api.github.com/repos/seanyao/roll/releases/latest" \
1356
+ | grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/' 2>/dev/null || true)
1357
+ echo "$now ${latest:-}" > "$cache"
1358
+ } &
1359
+ disown
1360
+ }
1361
+
1362
+ _notify_update() {
1363
+ local cache="${ROLL_HOME}/.update-check"
1364
+ [[ -f "$cache" ]] || return
1365
+ local latest; latest=$(awk '{print $2}' "$cache" 2>/dev/null || true)
1366
+ [[ -z "$latest" || "$latest" == "$VERSION" ]] && return
1367
+ echo ""
1368
+ warn "v${latest} available — run 'roll update' to upgrade 有新版本 v${latest} — 运行 'roll update' 升级"
1369
+ }
1370
+
1371
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
1372
+ _check_update_async
1373
+ main "$@"
1374
+ _notify_update
1375
+ fi