@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.
- package/README.md +201 -0
- package/bin/roll +1375 -0
- package/conventions/config.yaml +15 -0
- package/conventions/global/.cursor-rules +31 -0
- package/conventions/global/AGENTS.md +100 -0
- package/conventions/global/CLAUDE.md +32 -0
- package/conventions/global/GEMINI.md +28 -0
- package/conventions/templates/backend-service/.cursor-rules +17 -0
- package/conventions/templates/backend-service/AGENTS.md +88 -0
- package/conventions/templates/backend-service/CLAUDE.md +18 -0
- package/conventions/templates/backend-service/GEMINI.md +16 -0
- package/conventions/templates/cli/.cursor-rules +17 -0
- package/conventions/templates/cli/AGENTS.md +66 -0
- package/conventions/templates/cli/CLAUDE.md +18 -0
- package/conventions/templates/cli/GEMINI.md +16 -0
- package/conventions/templates/frontend-only/.cursor-rules +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +71 -0
- package/conventions/templates/frontend-only/CLAUDE.md +16 -0
- package/conventions/templates/frontend-only/GEMINI.md +14 -0
- package/conventions/templates/fullstack/.cursor-rules +17 -0
- package/conventions/templates/fullstack/AGENTS.md +87 -0
- package/conventions/templates/fullstack/CLAUDE.md +17 -0
- package/conventions/templates/fullstack/GEMINI.md +15 -0
- package/package.json +33 -0
- package/skills/roll-.changelog/SKILL.md +79 -0
- package/skills/roll-.clarify/SKILL.md +59 -0
- package/skills/roll-.echo/SKILL.md +113 -0
- package/skills/roll-.qa/SKILL.md +204 -0
- package/skills/roll-.review/SKILL.md +105 -0
- package/skills/roll-build/SKILL.md +559 -0
- package/skills/roll-debug/SKILL.md +428 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +256 -0
- package/skills/roll-design/SKILL.md +276 -0
- package/skills/roll-fix/SKILL.md +442 -0
- package/skills/roll-jot/SKILL.md +50 -0
- package/skills/roll-research/SKILL.md +307 -0
- package/skills/roll-research/references/schema.json +162 -0
- package/skills/roll-research/scripts/md_to_pdf.py +289 -0
- package/skills/roll-sentinel/SKILL.md +355 -0
- package/skills/roll-spar/SKILL.md +287 -0
- package/template/.env.example +47 -0
- package/template/.github/workflows/ci.yml +32 -0
- package/template/.github/workflows/sentinel.yml +26 -0
- package/template/AGENTS.md +80 -0
- package/template/BACKLOG.md +42 -0
- package/template/package.json +43 -0
- package/tools/roll-fetch/SKILL.md +182 -0
- package/tools/roll-fetch/package.json +15 -0
- package/tools/roll-fetch/smart-web-fetch.js +558 -0
- 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
|