cortexhawk 3.2.0 → 3.3.1
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/.cortexhawk-lint.yml.example +21 -0
- package/.gitmessage +10 -0
- package/CHANGELOG.md +45 -0
- package/CLAUDE.md +12 -4
- package/agents/git-manager.md +6 -2
- package/commands/backlog.md +1 -1
- package/commands/cleanup.md +37 -0
- package/commands/review-pr.md +31 -0
- package/commands/ship.md +1 -0
- package/commands/task.md +1 -1
- package/cortexhawk +9 -3
- package/hooks/branch-guard.sh +8 -1
- package/hooks/codex-dispatcher.sh +3 -0
- package/hooks/compose.yml +6 -0
- package/hooks/file-guard.sh +4 -0
- package/hooks/hooks.json +6 -0
- package/hooks/lint-guard.sh +46 -0
- package/hooks/post-merge.sh +12 -0
- package/hooks/session-start.sh +1 -1
- package/install.sh +159 -962
- package/mcp/README.md +36 -0
- package/mcp/context7.json +1 -1
- package/mcp/github.json +11 -0
- package/mcp/puppeteer.json +1 -1
- package/mcp/sequential-thinking.json +1 -1
- package/package.json +1 -1
- package/profiles/api.json +2 -1
- package/profiles/fullstack.json +2 -1
- package/scripts/autodetect-profile.sh +1 -1
- package/scripts/doctor.sh +164 -0
- package/scripts/install-claude.sh +179 -0
- package/scripts/interactive-init.sh +3 -2
- package/scripts/lint-guard-runner.sh +132 -0
- package/scripts/post-merge-cleanup.sh +233 -0
- package/scripts/restore.sh +212 -0
- package/scripts/snapshot.sh +163 -0
- package/scripts/update.sh +280 -0
- package/settings.json +12 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# post-merge-cleanup.sh — Unified post-merge cleanup for all git workflow strategies
|
|
3
|
+
# Strategies: direct-main, feature-branches, dev-branch, gitflow
|
|
4
|
+
# Called by /cleanup command and post-merge hook
|
|
5
|
+
# Args: --auto (silent mode, no prompts), --dry-run (preview without executing)
|
|
6
|
+
|
|
7
|
+
set -e
|
|
8
|
+
|
|
9
|
+
# --- 1. FLAGS ---
|
|
10
|
+
AUTO_MODE=false
|
|
11
|
+
DRY_RUN=false
|
|
12
|
+
[ ! -t 0 ] && AUTO_MODE=true
|
|
13
|
+
for arg in "$@"; do
|
|
14
|
+
case "$arg" in
|
|
15
|
+
--auto) AUTO_MODE=true ;;
|
|
16
|
+
--dry-run) DRY_RUN=true ;;
|
|
17
|
+
esac
|
|
18
|
+
done
|
|
19
|
+
|
|
20
|
+
# --- 2. GIT VALIDATION ---
|
|
21
|
+
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
22
|
+
echo "Error: not a git repository"
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
# run_cmd: execute command or print preview in dry-run mode
|
|
27
|
+
run_cmd() {
|
|
28
|
+
if [ "$DRY_RUN" = true ]; then echo "[dry-run] $*"; else "$@"; fi
|
|
29
|
+
}
|
|
30
|
+
log() { [ "$AUTO_MODE" = false ] && echo "$1" || true; }
|
|
31
|
+
|
|
32
|
+
# --- 3. STRATEGY DETECTION (layers 1-3) ---
|
|
33
|
+
BRANCHING="direct-main"
|
|
34
|
+
MAIN_BRANCH="main"
|
|
35
|
+
WORK_BRANCH=""
|
|
36
|
+
|
|
37
|
+
# Layer 1: .claude/git-workflow.conf (safe parsing, no source)
|
|
38
|
+
if [ -f ".claude/git-workflow.conf" ]; then
|
|
39
|
+
BRANCHING=$(grep -E '^BRANCHING=' ".claude/git-workflow.conf" | head -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
|
|
40
|
+
WORK_BRANCH=$(grep -E '^WORK_BRANCH=' ".claude/git-workflow.conf" | head -1 | cut -d= -f2 | tr -d '"' | tr -d "'")
|
|
41
|
+
case "$BRANCHING" in
|
|
42
|
+
dev-branch) MAIN_BRANCH="main" ;;
|
|
43
|
+
gitflow) MAIN_BRANCH="main"; WORK_BRANCH="${WORK_BRANCH:-develop}" ;;
|
|
44
|
+
feature-branches|direct-main) MAIN_BRANCH="main" ;;
|
|
45
|
+
esac
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Layer 2: Fallback to CLAUDE.md
|
|
49
|
+
if [ ! -f ".claude/git-workflow.conf" ] && [ -f "CLAUDE.md" ]; then
|
|
50
|
+
if grep -q "^## Git Workflow" "CLAUDE.md"; then
|
|
51
|
+
DETECTED=$(grep -i "branching:" "CLAUDE.md" | head -1 | sed 's/.*: //' | awk '{print $1}')
|
|
52
|
+
case "$DETECTED" in
|
|
53
|
+
dev-branch)
|
|
54
|
+
BRANCHING="dev-branch"
|
|
55
|
+
WORK_BRANCH=$(grep -i "working branch:" "CLAUDE.md" | head -1 | sed 's/.*: //' | tr -d ')')
|
|
56
|
+
MAIN_BRANCH="main"
|
|
57
|
+
;;
|
|
58
|
+
gitflow) BRANCHING="gitflow"; MAIN_BRANCH="main"; WORK_BRANCH="${WORK_BRANCH:-develop}" ;;
|
|
59
|
+
feature-branches) BRANCHING="feature-branches"; MAIN_BRANCH="main" ;;
|
|
60
|
+
direct-main) BRANCHING="direct-main"; MAIN_BRANCH="main" ;;
|
|
61
|
+
esac
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Layer 3: verify MAIN_BRANCH exists (main vs master fallback)
|
|
66
|
+
if ! git rev-parse --verify "$MAIN_BRANCH" >/dev/null 2>&1; then
|
|
67
|
+
if git rev-parse --verify main >/dev/null 2>&1; then
|
|
68
|
+
MAIN_BRANCH="main"
|
|
69
|
+
elif git rev-parse --verify master >/dev/null 2>&1; then
|
|
70
|
+
MAIN_BRANCH="master"
|
|
71
|
+
else
|
|
72
|
+
echo "Error: branch $MAIN_BRANCH does not exist"; exit 1
|
|
73
|
+
fi
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# --- 4. PROTECTED BRANCHES ---
|
|
77
|
+
# Single source of truth — these branches are never deleted
|
|
78
|
+
PROTECTED_BRANCHES="main master dev develop ${WORK_BRANCH}"
|
|
79
|
+
|
|
80
|
+
is_protected() {
|
|
81
|
+
local branch="$1"
|
|
82
|
+
for p in $PROTECTED_BRANCHES; do
|
|
83
|
+
[ "$branch" = "$p" ] && return 0
|
|
84
|
+
done
|
|
85
|
+
# Non-gitflow: protect release/hotfix patterns as safety net
|
|
86
|
+
# Gitflow: allow deletion of merged release/hotfix branches
|
|
87
|
+
if [ "$BRANCHING" != "gitflow" ]; then
|
|
88
|
+
case "$branch" in release/*|hotfix/*) return 0 ;; esac
|
|
89
|
+
fi
|
|
90
|
+
return 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# --- 5. REMOTE CHECK ---
|
|
94
|
+
REMOTE_EXISTS=true
|
|
95
|
+
if ! git remote get-url origin >/dev/null 2>&1; then
|
|
96
|
+
REMOTE_EXISTS=false
|
|
97
|
+
log "Warning: no remote 'origin' configured, skipping remote operations"
|
|
98
|
+
fi
|
|
99
|
+
CURRENT_BRANCH=$(git branch --show-current)
|
|
100
|
+
log "Strategy: $BRANCHING | main: $MAIN_BRANCH | work: ${WORK_BRANCH:-(none)}"
|
|
101
|
+
|
|
102
|
+
# --- 6. HELPERS ---
|
|
103
|
+
|
|
104
|
+
delete_branch() {
|
|
105
|
+
local branch="$1"
|
|
106
|
+
is_protected "$branch" && { log " Protected, skipping: $branch"; return 0; }
|
|
107
|
+
if [ "$AUTO_MODE" = true ]; then
|
|
108
|
+
run_cmd git branch -d "$branch" 2>/dev/null || log " Could not delete $branch"
|
|
109
|
+
else
|
|
110
|
+
read -r -p "Delete local branch '$branch'? [y/N]: " c
|
|
111
|
+
{ [ "$c" = "y" ] || [ "$c" = "Y" ]; } && \
|
|
112
|
+
{ run_cmd git branch -d "$branch" || log " Failed to delete $branch"; }
|
|
113
|
+
fi
|
|
114
|
+
if [ "$AUTO_MODE" = false ] && [ "$REMOTE_EXISTS" = true ]; then
|
|
115
|
+
if git rev-parse --verify "refs/remotes/origin/$branch" >/dev/null 2>&1; then
|
|
116
|
+
read -r -p "Delete remote 'origin/$branch'? [y/N]: " cr
|
|
117
|
+
{ [ "$cr" = "y" ] || [ "$cr" = "Y" ]; } && \
|
|
118
|
+
{ run_cmd git push origin --delete "$branch" 2>/dev/null || log " Failed to delete remote $branch"; }
|
|
119
|
+
fi
|
|
120
|
+
fi
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
delete_merged_branches() {
|
|
124
|
+
local target="${1:-$MAIN_BRANCH}"
|
|
125
|
+
local merged
|
|
126
|
+
merged=$(git branch --merged "$target" 2>/dev/null \
|
|
127
|
+
| grep -v '^\*' | sed 's/^[[:space:]]*//' || true)
|
|
128
|
+
if [ -z "$merged" ]; then
|
|
129
|
+
log "No merged branches to clean up"
|
|
130
|
+
return 0
|
|
131
|
+
fi
|
|
132
|
+
local count; count=$(echo "$merged" | wc -l | tr -d ' ')
|
|
133
|
+
log "Found $count merged branch(es): $(echo "$merged" | tr '\n' ' ')"
|
|
134
|
+
while IFS= read -r b; do
|
|
135
|
+
[ -z "$b" ] && continue
|
|
136
|
+
delete_branch "$b"
|
|
137
|
+
done <<< "$merged"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
resync_work_branch() {
|
|
141
|
+
local work="$1" target="$2"
|
|
142
|
+
{ [ -z "$work" ] || [ "$REMOTE_EXISTS" = false ]; } && return 0
|
|
143
|
+
log "Resyncing $work <- $target..."
|
|
144
|
+
local saved; saved=$(git branch --show-current)
|
|
145
|
+
if git rev-parse --verify "refs/heads/$work" >/dev/null 2>&1; then
|
|
146
|
+
run_cmd git checkout "$work" || { log "Warning: could not checkout $work; skipping resync"; return 0; }
|
|
147
|
+
elif git rev-parse --verify "refs/remotes/origin/$work" >/dev/null 2>&1; then
|
|
148
|
+
log " Creating local branch $work from origin/$work"
|
|
149
|
+
run_cmd git checkout -b "$work" "origin/$work" || { log "Warning: could not create $work; skipping resync"; return 0; }
|
|
150
|
+
else
|
|
151
|
+
log " Warning: branch $work does not exist locally or on origin; skipping resync"
|
|
152
|
+
return 0
|
|
153
|
+
fi
|
|
154
|
+
if ! run_cmd git pull origin "$target" --ff-only 2>/dev/null; then
|
|
155
|
+
if [ "$AUTO_MODE" = true ]; then
|
|
156
|
+
echo "Warning: resync $work <- $target needs non-ff merge. Run manually."
|
|
157
|
+
else
|
|
158
|
+
read -r -p "Fast-forward failed. Attempt merge? [y/N]: " c
|
|
159
|
+
if [ "$c" = "y" ] || [ "$c" = "Y" ]; then
|
|
160
|
+
run_cmd git merge "origin/$target" 2>/dev/null \
|
|
161
|
+
|| echo "Error: conflict. Resolve then: git push origin $work"
|
|
162
|
+
fi
|
|
163
|
+
fi
|
|
164
|
+
else
|
|
165
|
+
if ! run_cmd git push origin "$work" 2>/dev/null; then
|
|
166
|
+
log "Warning: push $work failed (network?)"
|
|
167
|
+
fi
|
|
168
|
+
fi
|
|
169
|
+
if [ "$saved" != "$work" ]; then
|
|
170
|
+
run_cmd git checkout "$saved" 2>/dev/null || log "Warning: could not return to branch $saved"
|
|
171
|
+
fi
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
prompt_new_feature_branch() {
|
|
175
|
+
[ "$AUTO_MODE" = true ] && return 0
|
|
176
|
+
[ "$CURRENT_BRANCH" = "$MAIN_BRANCH" ] || return 0
|
|
177
|
+
read -r -p "Create new feature branch? [y/N]: " c
|
|
178
|
+
{ [ "$c" = "y" ] || [ "$c" = "Y" ]; } || return 0
|
|
179
|
+
read -r -p "Branch name (default: feat/$(date +%s)): " name
|
|
180
|
+
name="${name:-feat/$(date +%s)}"
|
|
181
|
+
run_cmd git checkout -b "$name" || log "Failed to create branch $name"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# --- 7. STRATEGY DISPATCH ---
|
|
185
|
+
|
|
186
|
+
strategy_direct_main() {
|
|
187
|
+
delete_merged_branches
|
|
188
|
+
if [ "$CURRENT_BRANCH" = "$MAIN_BRANCH" ] && [ "$REMOTE_EXISTS" = true ]; then
|
|
189
|
+
log "Pulling latest from $MAIN_BRANCH..."
|
|
190
|
+
run_cmd git pull origin "$MAIN_BRANCH" 2>/dev/null \
|
|
191
|
+
|| log "Failed to pull from $MAIN_BRANCH"
|
|
192
|
+
fi
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
strategy_feature_branches() {
|
|
196
|
+
delete_merged_branches
|
|
197
|
+
if [ "$CURRENT_BRANCH" = "$MAIN_BRANCH" ] && [ "$REMOTE_EXISTS" = true ]; then
|
|
198
|
+
log "Pulling latest from $MAIN_BRANCH..."
|
|
199
|
+
run_cmd git pull origin "$MAIN_BRANCH" 2>/dev/null \
|
|
200
|
+
|| log "Failed to pull from $MAIN_BRANCH"
|
|
201
|
+
fi
|
|
202
|
+
prompt_new_feature_branch
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
strategy_dev_branch() {
|
|
206
|
+
delete_merged_branches "$MAIN_BRANCH"
|
|
207
|
+
delete_merged_branches "$WORK_BRANCH"
|
|
208
|
+
resync_work_branch "$WORK_BRANCH" "$MAIN_BRANCH"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
strategy_gitflow() {
|
|
212
|
+
# Step 1: feat/* merged into develop → delete
|
|
213
|
+
if git rev-parse --verify "$WORK_BRANCH" >/dev/null 2>&1; then
|
|
214
|
+
log "Cleaning branches merged into $WORK_BRANCH..."
|
|
215
|
+
delete_merged_branches "$WORK_BRANCH"
|
|
216
|
+
fi
|
|
217
|
+
# Step 2: release/* and hotfix/* merged into main → delete
|
|
218
|
+
log "Cleaning branches merged into $MAIN_BRANCH..."
|
|
219
|
+
delete_merged_branches "$MAIN_BRANCH"
|
|
220
|
+
# Step 3: resync develop ← main
|
|
221
|
+
resync_work_branch "$WORK_BRANCH" "$MAIN_BRANCH"
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "$BRANCHING" in
|
|
225
|
+
direct-main) strategy_direct_main ;;
|
|
226
|
+
feature-branches) strategy_feature_branches ;;
|
|
227
|
+
dev-branch) strategy_dev_branch ;;
|
|
228
|
+
gitflow) strategy_gitflow ;;
|
|
229
|
+
*) echo "Unknown branching strategy: $BRANCHING" >&2; exit 1 ;;
|
|
230
|
+
esac
|
|
231
|
+
|
|
232
|
+
log "Cleanup complete!"
|
|
233
|
+
exit 0
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# restore.sh — CortexHawk snapshot restoration
|
|
3
|
+
# Sourced by install.sh when --restore is used
|
|
4
|
+
# Uses shared functions: get_version, compute_checksum, copy_all_components, write_manifest
|
|
5
|
+
# Uses globals: GLOBAL, PROFILE, PROFILE_FILE, SCRIPT_DIR
|
|
6
|
+
|
|
7
|
+
do_restore() {
|
|
8
|
+
local snap_file="$1"
|
|
9
|
+
local archive_tmp=""
|
|
10
|
+
local portable_files=""
|
|
11
|
+
|
|
12
|
+
if [ -z "$snap_file" ] || [ ! -f "$snap_file" ]; then
|
|
13
|
+
echo "Error: snapshot file not found: $snap_file"
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
# Handle portable archive (.tar.gz)
|
|
18
|
+
if [[ "$snap_file" == *.tar.gz ]]; then
|
|
19
|
+
archive_tmp=$(mktemp -d)
|
|
20
|
+
# Validate archive paths before extraction (reject ../ and absolute paths)
|
|
21
|
+
while IFS= read -r entry; do
|
|
22
|
+
case "$entry" in
|
|
23
|
+
/*|*../*|*/..*|..) echo "Error: archive contains unsafe path: $entry"; rm -rf "$archive_tmp"; exit 1 ;;
|
|
24
|
+
esac
|
|
25
|
+
done < <(tar -tzf "$snap_file" 2>/dev/null || { echo "Error: invalid archive"; rm -rf "$archive_tmp"; exit 1; })
|
|
26
|
+
tar -xzf "$snap_file" -C "$archive_tmp"
|
|
27
|
+
snap_file="$archive_tmp/snapshot.json"
|
|
28
|
+
portable_files="$archive_tmp/files"
|
|
29
|
+
if [ ! -f "$snap_file" ]; then
|
|
30
|
+
echo "Error: invalid archive — snapshot.json not found"
|
|
31
|
+
rm -rf "$archive_tmp"
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
echo "Extracting portable archive..."
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Extract metadata from snapshot
|
|
38
|
+
local snap_version
|
|
39
|
+
snap_version=$(grep '"cortexhawk_version"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
40
|
+
local snap_profile
|
|
41
|
+
snap_profile=$(grep '"profile"' "$snap_file" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
42
|
+
local snap_source
|
|
43
|
+
snap_source=$(grep '"source"' "$snap_file" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
44
|
+
local snap_source_url
|
|
45
|
+
snap_source_url=$(grep '"source_url"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
46
|
+
local snap_source_path
|
|
47
|
+
snap_source_path=$(grep '"source_path"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
48
|
+
|
|
49
|
+
echo "CortexHawk Restore"
|
|
50
|
+
echo "====================="
|
|
51
|
+
echo " CortexHawk version: $snap_version"
|
|
52
|
+
echo " Profile: $snap_profile"
|
|
53
|
+
echo " Source: $snap_source"
|
|
54
|
+
echo ""
|
|
55
|
+
|
|
56
|
+
# Determine CortexHawk source
|
|
57
|
+
local restore_source="$SCRIPT_DIR"
|
|
58
|
+
if [ "$snap_source" = "git" ] && [ -n "$snap_source_path" ] && [ -d "$snap_source_path" ]; then
|
|
59
|
+
restore_source="$snap_source_path"
|
|
60
|
+
fi
|
|
61
|
+
if [ ! -d "$restore_source/agents" ]; then
|
|
62
|
+
echo "Error: CortexHawk source not found at $restore_source"
|
|
63
|
+
echo "Ensure the CortexHawk repo is available or set CORTEXHAWK_REPO"
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Set profile for reinstall
|
|
68
|
+
if [ -n "$snap_profile" ] && [ "$snap_profile" != "all" ]; then
|
|
69
|
+
PROFILE="$snap_profile"
|
|
70
|
+
PROFILE_FILE="$restore_source/profiles/${snap_profile}.json"
|
|
71
|
+
if [ ! -f "$PROFILE_FILE" ]; then
|
|
72
|
+
echo " Warning: profile '$snap_profile' not found — installing all skills"
|
|
73
|
+
PROFILE=""
|
|
74
|
+
PROFILE_FILE=""
|
|
75
|
+
fi
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Determine target
|
|
79
|
+
if [ "$GLOBAL" = true ]; then
|
|
80
|
+
TARGET="$HOME/.claude"
|
|
81
|
+
else
|
|
82
|
+
TARGET="$(pwd)/.claude"
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# Save the original SCRIPT_DIR, use snapshot source
|
|
86
|
+
local orig_script_dir="$SCRIPT_DIR"
|
|
87
|
+
SCRIPT_DIR="$restore_source"
|
|
88
|
+
|
|
89
|
+
# Warn if restore source version differs from snapshot
|
|
90
|
+
local current_version
|
|
91
|
+
current_version=$(get_version)
|
|
92
|
+
if [ "$snap_version" != "$current_version" ]; then
|
|
93
|
+
echo " Warning: snapshot is v$snap_version but restore source is v$current_version"
|
|
94
|
+
echo " Some file checksums may not match"
|
|
95
|
+
echo ""
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# Reinstall using the standard flow
|
|
99
|
+
echo "Reinstalling CortexHawk components..."
|
|
100
|
+
|
|
101
|
+
# Use portable archive files if available, otherwise use source repo
|
|
102
|
+
if [ -n "$portable_files" ] && [ -d "$portable_files" ]; then
|
|
103
|
+
echo " Using files from portable archive..."
|
|
104
|
+
copy_all_components "$portable_files" "$TARGET" ""
|
|
105
|
+
else
|
|
106
|
+
copy_all_components "$SCRIPT_DIR" "$TARGET" "$PROFILE"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# Restore settings.json from snapshot
|
|
110
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
111
|
+
python3 -c "
|
|
112
|
+
import json, sys
|
|
113
|
+
with open(sys.argv[1]) as f:
|
|
114
|
+
snap = json.load(f)
|
|
115
|
+
settings = snap.get('settings')
|
|
116
|
+
if settings is not None:
|
|
117
|
+
with open(sys.argv[2], 'w') as f:
|
|
118
|
+
json.dump(settings, f, indent=2)
|
|
119
|
+
f.write('\n')
|
|
120
|
+
print(' Restored settings.json')
|
|
121
|
+
" "$snap_file" "$TARGET/settings.json"
|
|
122
|
+
else
|
|
123
|
+
echo " Warning: python3 not found — settings.json not restored from snapshot"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# Restore git-workflow.conf from snapshot
|
|
127
|
+
local branching commit_conv pr_pref auto_push
|
|
128
|
+
branching=$(grep '"BRANCHING"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
129
|
+
commit_conv=$(grep '"COMMIT_CONVENTION"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
130
|
+
pr_pref=$(grep '"PR_PREFERENCE"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
131
|
+
auto_push=$(grep '"AUTO_PUSH"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
132
|
+
|
|
133
|
+
if [ -n "$branching" ] || [ -n "$commit_conv" ] || [ -n "$pr_pref" ] || [ -n "$auto_push" ]; then
|
|
134
|
+
{
|
|
135
|
+
echo "BRANCHING=$branching"
|
|
136
|
+
echo "COMMIT_CONVENTION=$commit_conv"
|
|
137
|
+
echo "PR_PREFERENCE=$pr_pref"
|
|
138
|
+
echo "AUTO_PUSH=$auto_push"
|
|
139
|
+
} > "$TARGET/git-workflow.conf"
|
|
140
|
+
echo " Restored git-workflow.conf (from git_workflow keys)"
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# Restore file_contents (snapshot v2) — overwrites git-workflow.conf if present
|
|
144
|
+
if grep -q '"file_contents"' "$snap_file" && command -v python3 >/dev/null 2>&1; then
|
|
145
|
+
python3 -c "
|
|
146
|
+
import json, base64, sys, os
|
|
147
|
+
snap_file, target_dir = sys.argv[1], sys.argv[2]
|
|
148
|
+
with open(snap_file) as f:
|
|
149
|
+
snap = json.load(f)
|
|
150
|
+
contents = snap.get('file_contents', {})
|
|
151
|
+
for filename, b64data in contents.items():
|
|
152
|
+
try:
|
|
153
|
+
# Reject path traversal and absolute paths
|
|
154
|
+
if '..' in filename or filename.startswith('/'):
|
|
155
|
+
print(f' Warning: skipping unsafe path: {filename}', file=sys.stderr)
|
|
156
|
+
continue
|
|
157
|
+
data = base64.b64decode(b64data).decode('utf-8')
|
|
158
|
+
if filename == 'CLAUDE.md':
|
|
159
|
+
target_path = os.path.dirname(target_dir) + '/CLAUDE.md'
|
|
160
|
+
else:
|
|
161
|
+
target_path = os.path.normpath(target_dir + '/' + filename)
|
|
162
|
+
if not target_path.startswith(target_dir + os.sep):
|
|
163
|
+
print(f' Warning: skipping path outside target: {filename}', file=sys.stderr)
|
|
164
|
+
continue
|
|
165
|
+
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
|
166
|
+
with open(target_path, 'w') as f:
|
|
167
|
+
f.write(data)
|
|
168
|
+
print(f' Restored {filename} (from file_contents)')
|
|
169
|
+
except Exception as e:
|
|
170
|
+
print(f' Warning: could not restore {filename}: {e}', file=sys.stderr)
|
|
171
|
+
" "$snap_file" "$TARGET"
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
# Write new manifest
|
|
175
|
+
write_manifest "$TARGET" "$PROFILE" "claude" false
|
|
176
|
+
|
|
177
|
+
# Verify checksums against snapshot
|
|
178
|
+
local verified=0 mismatched=0 missing=0
|
|
179
|
+
while IFS= read -r line; do
|
|
180
|
+
local file_relpath
|
|
181
|
+
file_relpath=$(echo "$line" | sed 's/.*"\([^"]*\)": "sha256:\([^"]*\)".*/\1/')
|
|
182
|
+
local expected_checksum
|
|
183
|
+
expected_checksum=$(echo "$line" | sed 's/.*"sha256:\([^"]*\)".*/\1/')
|
|
184
|
+
[ -z "$file_relpath" ] || [ -z "$expected_checksum" ] && continue
|
|
185
|
+
|
|
186
|
+
local target_file="$TARGET/$file_relpath"
|
|
187
|
+
if [ ! -f "$target_file" ]; then
|
|
188
|
+
missing=$((missing + 1))
|
|
189
|
+
else
|
|
190
|
+
local actual_checksum
|
|
191
|
+
actual_checksum=$(compute_checksum "$target_file")
|
|
192
|
+
if [ "$actual_checksum" = "$expected_checksum" ]; then
|
|
193
|
+
verified=$((verified + 1))
|
|
194
|
+
else
|
|
195
|
+
mismatched=$((mismatched + 1))
|
|
196
|
+
fi
|
|
197
|
+
fi
|
|
198
|
+
done < <(grep '"sha256:' "$snap_file")
|
|
199
|
+
|
|
200
|
+
SCRIPT_DIR="$orig_script_dir"
|
|
201
|
+
|
|
202
|
+
echo ""
|
|
203
|
+
echo "Restore complete"
|
|
204
|
+
echo " Verified: $verified files match snapshot checksums"
|
|
205
|
+
[ "$mismatched" -gt 0 ] && echo " Mismatched: $mismatched files differ (source version may differ from snapshot)"
|
|
206
|
+
[ "$missing" -gt 0 ] && echo " Missing: $missing files not found in source"
|
|
207
|
+
echo ""
|
|
208
|
+
echo " To activate: exit your CLI (ctrl+c) and relaunch in this directory."
|
|
209
|
+
|
|
210
|
+
# Cleanup temp dir from portable archive
|
|
211
|
+
[ -n "$archive_tmp" ] && rm -rf "$archive_tmp"
|
|
212
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# snapshot.sh — CortexHawk snapshot creation and rotation
|
|
3
|
+
# Sourced by install.sh when --snapshot is used
|
|
4
|
+
# Uses shared functions: sed_inplace, compute_checksum (via manifest)
|
|
5
|
+
# Uses globals: GLOBAL, TARGET, PORTABLE_MODE, PROFILE_FILE, MAX_SNAPSHOTS
|
|
6
|
+
|
|
7
|
+
# --- rotate_snapshots() ---
|
|
8
|
+
# Keeps only the N most recent snapshots, deletes the rest
|
|
9
|
+
rotate_snapshots() {
|
|
10
|
+
local snap_dir="$1"
|
|
11
|
+
local snaps
|
|
12
|
+
snaps=$(find "$snap_dir" -maxdepth 1 -name '*.json' -type f 2>/dev/null)
|
|
13
|
+
[ -z "$snaps" ] && return 0
|
|
14
|
+
local count
|
|
15
|
+
count=$(echo "$snaps" | wc -l | tr -d ' ')
|
|
16
|
+
if [ "$count" -gt "$MAX_SNAPSHOTS" ]; then
|
|
17
|
+
local to_delete=$((count - MAX_SNAPSHOTS))
|
|
18
|
+
ls -1t "$snap_dir"/*.json | tail -n "$to_delete" | while read -r old_snap; do
|
|
19
|
+
rm -f "$old_snap"
|
|
20
|
+
done
|
|
21
|
+
echo " Rotated: removed $to_delete old snapshot(s), keeping $MAX_SNAPSHOTS"
|
|
22
|
+
fi
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# --- do_snapshot() ---
|
|
26
|
+
do_snapshot() {
|
|
27
|
+
if [ "$GLOBAL" = true ]; then
|
|
28
|
+
TARGET="$HOME/.claude"
|
|
29
|
+
else
|
|
30
|
+
TARGET="$(pwd)/.claude"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
local manifest="$TARGET/.cortexhawk-manifest"
|
|
34
|
+
if [ ! -f "$manifest" ]; then
|
|
35
|
+
echo "Error: no CortexHawk manifest found at $manifest"
|
|
36
|
+
echo "Run install.sh first to create an installation"
|
|
37
|
+
exit 1
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Read manifest metadata
|
|
41
|
+
local version
|
|
42
|
+
version=$(grep '"version"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
43
|
+
local profile
|
|
44
|
+
profile=$(grep '"profile"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
45
|
+
local source_type
|
|
46
|
+
source_type=$(grep '"source"' "$manifest" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
47
|
+
local source_url
|
|
48
|
+
source_url=$(grep '"source_url"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
49
|
+
local source_path
|
|
50
|
+
source_path=$(grep '"source_path"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
|
|
51
|
+
|
|
52
|
+
# Read settings.json
|
|
53
|
+
local settings_json="null"
|
|
54
|
+
if [ -f "$TARGET/settings.json" ]; then
|
|
55
|
+
settings_json=$(cat "$TARGET/settings.json")
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Read git-workflow.conf
|
|
59
|
+
local git_branching="" git_commit="" git_pr="" git_push=""
|
|
60
|
+
if [ -f "$TARGET/git-workflow.conf" ]; then
|
|
61
|
+
git_branching=$(grep '^BRANCHING=' "$TARGET/git-workflow.conf" | cut -d= -f2)
|
|
62
|
+
git_commit=$(grep '^COMMIT_CONVENTION=' "$TARGET/git-workflow.conf" | cut -d= -f2)
|
|
63
|
+
git_pr=$(grep '^PR_PREFERENCE=' "$TARGET/git-workflow.conf" | cut -d= -f2)
|
|
64
|
+
git_push=$(grep '^AUTO_PUSH=' "$TARGET/git-workflow.conf" | cut -d= -f2)
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# Read custom profile if applicable (use PROFILE_FILE from current run, never glob /tmp/)
|
|
68
|
+
local profile_def="null"
|
|
69
|
+
if [ -n "$PROFILE_FILE" ] && [ -f "$PROFILE_FILE" ]; then
|
|
70
|
+
profile_def=$(cat "$PROFILE_FILE")
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Build files checksums from manifest
|
|
74
|
+
local files_json
|
|
75
|
+
files_json=$(sed -n '/"files"/,/^ }/p' "$manifest" | sed '1d;$d')
|
|
76
|
+
|
|
77
|
+
# Collect file contents (base64 encoded for binary safety)
|
|
78
|
+
local git_workflow_content="" claude_md_content=""
|
|
79
|
+
if [ -f "$TARGET/git-workflow.conf" ]; then
|
|
80
|
+
git_workflow_content=$(base64 < "$TARGET/git-workflow.conf" | tr -d '\n')
|
|
81
|
+
fi
|
|
82
|
+
if [ -f "$TARGET/../CLAUDE.md" ]; then
|
|
83
|
+
claude_md_content=$(base64 < "$TARGET/../CLAUDE.md" | tr -d '\n')
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
# Generate snapshot
|
|
87
|
+
local now
|
|
88
|
+
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
89
|
+
local snap_name
|
|
90
|
+
snap_name=$(date -u +"%Y-%m-%d-%H%M%S")
|
|
91
|
+
local snap_dir="$TARGET/.cortexhawk-snapshots"
|
|
92
|
+
local snap_file="$snap_dir/${snap_name}.json"
|
|
93
|
+
|
|
94
|
+
mkdir -p "$snap_dir"
|
|
95
|
+
|
|
96
|
+
# Write snapshot JSON
|
|
97
|
+
printf '{\n' > "$snap_file"
|
|
98
|
+
printf ' "snapshot_version": "2",\n' >> "$snap_file"
|
|
99
|
+
printf ' "snapshot_date": "%s",\n' "$now" >> "$snap_file"
|
|
100
|
+
printf ' "cortexhawk_version": "%s",\n' "$version" >> "$snap_file"
|
|
101
|
+
printf ' "target": "claude",\n' >> "$snap_file"
|
|
102
|
+
printf ' "profile": "%s",\n' "$profile" >> "$snap_file"
|
|
103
|
+
printf ' "profile_definition": %s,\n' "$profile_def" >> "$snap_file"
|
|
104
|
+
printf ' "source": "%s",\n' "$source_type" >> "$snap_file"
|
|
105
|
+
printf ' "source_url": "%s",\n' "$source_url" >> "$snap_file"
|
|
106
|
+
printf ' "source_path": "%s",\n' "$source_path" >> "$snap_file"
|
|
107
|
+
printf ' "settings": %s,\n' "$settings_json" >> "$snap_file"
|
|
108
|
+
printf ' "git_workflow": {\n' >> "$snap_file"
|
|
109
|
+
printf ' "BRANCHING": "%s",\n' "$git_branching" >> "$snap_file"
|
|
110
|
+
printf ' "COMMIT_CONVENTION": "%s",\n' "$git_commit" >> "$snap_file"
|
|
111
|
+
printf ' "PR_PREFERENCE": "%s",\n' "$git_pr" >> "$snap_file"
|
|
112
|
+
printf ' "AUTO_PUSH": "%s"\n' "$git_push" >> "$snap_file"
|
|
113
|
+
printf ' },\n' >> "$snap_file"
|
|
114
|
+
printf ' "files": {\n' >> "$snap_file"
|
|
115
|
+
printf '%s\n' "$files_json" >> "$snap_file"
|
|
116
|
+
printf ' },\n' >> "$snap_file"
|
|
117
|
+
printf ' "file_contents": {\n' >> "$snap_file"
|
|
118
|
+
[ -n "$git_workflow_content" ] && printf ' "git-workflow.conf": "%s",\n' "$git_workflow_content" >> "$snap_file"
|
|
119
|
+
[ -n "$claude_md_content" ] && printf ' "CLAUDE.md": "%s",\n' "$claude_md_content" >> "$snap_file"
|
|
120
|
+
# Remove trailing comma from last entry
|
|
121
|
+
sed_inplace '$ s/,$//' "$snap_file"
|
|
122
|
+
printf ' }\n' >> "$snap_file"
|
|
123
|
+
printf '}\n' >> "$snap_file"
|
|
124
|
+
|
|
125
|
+
echo "CortexHawk Snapshot"
|
|
126
|
+
echo "====================="
|
|
127
|
+
echo " Version: $version"
|
|
128
|
+
echo " Profile: $profile"
|
|
129
|
+
echo " Target: $TARGET"
|
|
130
|
+
echo " Saved to: $snap_file"
|
|
131
|
+
|
|
132
|
+
# Create portable archive if requested
|
|
133
|
+
if [ "$PORTABLE_MODE" = true ]; then
|
|
134
|
+
local archive_name="${snap_name}.tar.gz"
|
|
135
|
+
local archive_path="$snap_dir/$archive_name"
|
|
136
|
+
local tmp_dir
|
|
137
|
+
tmp_dir=$(mktemp -d)
|
|
138
|
+
|
|
139
|
+
# Copy snapshot and files to temp structure
|
|
140
|
+
cp "$snap_file" "$tmp_dir/snapshot.json"
|
|
141
|
+
mkdir -p "$tmp_dir/files"
|
|
142
|
+
cp -r "$TARGET/agents" "$tmp_dir/files/" 2>/dev/null || true
|
|
143
|
+
cp -r "$TARGET/commands" "$tmp_dir/files/" 2>/dev/null || true
|
|
144
|
+
cp -r "$TARGET/skills" "$tmp_dir/files/" 2>/dev/null || true
|
|
145
|
+
cp -r "$TARGET/hooks" "$tmp_dir/files/" 2>/dev/null || true
|
|
146
|
+
cp -r "$TARGET/modes" "$tmp_dir/files/" 2>/dev/null || true
|
|
147
|
+
cp -r "$TARGET/mcp" "$tmp_dir/files/" 2>/dev/null || true
|
|
148
|
+
cp "$TARGET/settings.json" "$tmp_dir/files/" 2>/dev/null || true
|
|
149
|
+
cp "$TARGET/git-workflow.conf" "$tmp_dir/files/" 2>/dev/null || true
|
|
150
|
+
|
|
151
|
+
# Create archive
|
|
152
|
+
tar -czf "$archive_path" -C "$tmp_dir" .
|
|
153
|
+
rm -rf "$tmp_dir"
|
|
154
|
+
|
|
155
|
+
echo " Archive: $archive_path"
|
|
156
|
+
echo ""
|
|
157
|
+
echo "Restore with: install.sh --restore $archive_path"
|
|
158
|
+
else
|
|
159
|
+
rotate_snapshots "$snap_dir"
|
|
160
|
+
echo ""
|
|
161
|
+
echo "Restore with: install.sh --restore $snap_file"
|
|
162
|
+
fi
|
|
163
|
+
}
|