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/install.sh CHANGED
@@ -7,6 +7,16 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7
7
  if [ -f ".env" ]; then
8
8
  while IFS='=' read -r key value; do
9
9
  [[ -z "$key" || "$key" == \#* ]] && continue
10
+ # Validate key format: uppercase letters, digits, underscore only (POSIX env var names)
11
+ [[ "$key" =~ ^[A-Z_][A-Z0-9_]{0,63}$ ]] || continue
12
+ # Block dangerous variable names that could hijack script/shell behavior
13
+ case "$key" in
14
+ PATH|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|LD_DEBUG|BASH_ENV|BASH_FUNC_*|\
15
+ SCRIPT_DIR|HOME|SHELL|IFS|CDPATH|ENV|PYTHONPATH|PYTHONSTARTUP|\
16
+ GIT_SSH|GIT_SSH_COMMAND|GIT_PROXY_COMMAND|GIT_EXEC_PATH|\
17
+ NPM_CONFIG_*|NODE_OPTIONS|RUBYLIB|RUBYOPT|PERL5LIB|PERL5OPT)
18
+ continue ;;
19
+ esac
10
20
  # Strip double quotes
11
21
  value="${value%\"}" && value="${value#\"}"
12
22
  # Strip single quotes
@@ -24,7 +34,7 @@ green() { printf "\033[32m%s\033[0m\n" "$1"; }
24
34
  yellow() { printf "\033[33m%s\033[0m\n" "$1"; }
25
35
 
26
36
  get_version() {
27
- grep -m1 '## \[' "$SCRIPT_DIR/CHANGELOG.md" | sed 's/.*\[\([^]]*\)\].*/\1/'
37
+ grep -m1 '## \[[0-9]' "$SCRIPT_DIR/CHANGELOG.md" | sed 's/.*\[\([^]]*\)\].*/\1/'
28
38
  }
29
39
 
30
40
  # Portable sed -i (GNU vs BSD)
@@ -161,7 +171,26 @@ STATS_MODE=false
161
171
  PUBLISH_SKILL_PATH=""
162
172
  CHECK_UPDATE_MODE=false
163
173
  DEMO_MODE=false
174
+ TRUST_SKILL=false
164
175
  MAX_SNAPSHOTS=10
176
+ POST_MERGE_HOOK_MODE=false
177
+
178
+ # === Component Registry ===
179
+ # Single source of truth for all CortexHawk components.
180
+ # Format: "name:executable"
181
+ # - executable = "yes" runs chmod +x *.sh after copy (hooks, scripts)
182
+ # - executable = "no" for markdown-only components (agents, commands, modes)
183
+ # - skills handled specially via copy_skills()/sync_skills_update() for profile filtering
184
+ # Used by: copy_all_components(), sync_all_components(), count_component_files()
185
+ COMPONENTS=(
186
+ "agents:no"
187
+ "commands:no"
188
+ "skills:no"
189
+ "hooks:yes"
190
+ "modes:no"
191
+ "mcp:no"
192
+ "scripts:yes"
193
+ )
165
194
 
166
195
  while [ $# -gt 0 ]; do
167
196
  case "$1" in
@@ -300,6 +329,10 @@ while [ $# -gt 0 ]; do
300
329
  DEMO_MODE=true
301
330
  shift
302
331
  ;;
332
+ --trust)
333
+ TRUST_SKILL=true
334
+ shift
335
+ ;;
303
336
  --publish-skill)
304
337
  PUBLISH_SKILL_PATH="$2"
305
338
  if [ -z "$PUBLISH_SKILL_PATH" ]; then
@@ -352,6 +385,10 @@ while [ $# -gt 0 ]; do
352
385
  fi
353
386
  shift 2
354
387
  ;;
388
+ --post-merge-hook)
389
+ POST_MERGE_HOOK_MODE=true
390
+ shift
391
+ ;;
355
392
  --version|-v)
356
393
  echo "CortexHawk $(get_version)"
357
394
  exit 0
@@ -455,7 +492,7 @@ if [ -n "$PACK_NAME" ]; then
455
492
  exit 1
456
493
  fi
457
494
  _skills_csv=$(echo "$_row" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
458
- _description=$(echo "$_row" | awk -F'|' '{print $4}' | sed 's/^ *//;s/ *$//')
495
+ _description=$(echo "$_row" | awk -F'|' '{print $4}' | sed 's/^ *//;s/ *$//' | sed 's/"/\\"/g')
459
496
  _tmp_profile=$(mktemp /tmp/cortexhawk-pack-XXXXXX.json)
460
497
  {
461
498
  echo '{'
@@ -505,11 +542,12 @@ detect_installed_clis() {
505
542
  copy_skills() {
506
543
  local target_dir="$1"
507
544
  local profile="$2"
545
+ local source_dir="${3:-$SCRIPT_DIR}"
508
546
  if [ -z "$profile" ]; then
509
- cp -r "$SCRIPT_DIR/skills/"* "$target_dir/skills/" 2>/dev/null || true
547
+ cp -r "$source_dir/skills/"* "$target_dir/skills/" 2>/dev/null || true
510
548
  else
511
549
  grep '"[a-z]' "$PROFILE_FILE" | sed 's/.*"\([a-z][^"]*\)".*/\1/' | grep '/' | while read -r skill; do
512
- local src="$SCRIPT_DIR/skills/$skill"
550
+ local src="$source_dir/skills/$skill"
513
551
  local dest="$target_dir/skills/$skill"
514
552
  if [ ! -d "$src" ]; then
515
553
  yellow " Warning: skill '$skill' not found in source — skipping"
@@ -574,6 +612,9 @@ convert_modes_to_skills() {
574
612
  done
575
613
  }
576
614
 
615
+ # NOTE: profiles/*.json contain an "mcp" field listing recommended servers, but install.sh
616
+ # currently installs all mcp/*.json regardless of profile. Per-profile MCP filtering is planned
617
+ # for a future phase of #137 (install.sh modularization).
577
618
  # merge_mcp_json(output_file) — merges mcp/*.json into single JSON file
578
619
  merge_mcp_json() {
579
620
  local output_file="$1"
@@ -582,7 +623,7 @@ merge_mcp_json() {
582
623
  [ -f "$mcp_file" ] || continue
583
624
  local server_name command_val args_val entry
584
625
  server_name=$(basename "$mcp_file" .json)
585
- command_val=$(grep '"command"' "$mcp_file" | sed 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
626
+ command_val=$(grep '"command"' "$mcp_file" | sed 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | sed 's/"/\\"/g')
586
627
  args_val=$(grep '"args"' "$mcp_file" | sed 's/.*"args"[[:space:]]*:[[:space:]]*\(\[.*\]\).*/\1/')
587
628
  entry=" \"$server_name\": {\n \"command\": \"$command_val\",\n \"args\": $args_val\n }"
588
629
  if [ -n "$entries" ]; then
@@ -658,14 +699,18 @@ $target_dir/" "$gitignore"
658
699
  if [ -d "$project_root/docs" ] && ! grep -qx "docs/" "$gitignore" 2>/dev/null; then
659
700
  echo ""
660
701
  echo " docs/ contains agent outputs (brainstorms, plans, research)."
661
- read -r -p " Track docs/ in git? [Y/n]: " docs_choice
702
+ if [ -t 0 ]; then
703
+ read -r -p " Track docs/ in git? [Y/n]: " docs_choice
704
+ else
705
+ docs_choice="Y"
706
+ fi
662
707
  case "$docs_choice" in
663
708
  [nN]*)
664
709
  echo "docs/" >> "$gitignore"
665
710
  green " Added docs/ to .gitignore"
666
711
  ;;
667
712
  *)
668
- green " docs/ will be tracked in git"
713
+ green " docs/ will be tracked in git (default)"
669
714
  ;;
670
715
  esac
671
716
  fi
@@ -876,6 +921,10 @@ cleanup_update() {
876
921
  }
877
922
 
878
923
  update_source_git() {
924
+ if ! git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
925
+ echo " CortexHawk installed via npm — run: npm update -g cortexhawk"
926
+ exit 0
927
+ fi
879
928
  local current_branch
880
929
  current_branch=$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
881
930
  if [ "$current_branch" != "main" ]; then
@@ -888,7 +937,8 @@ update_source_git() {
888
937
  }
889
938
 
890
939
  update_source_release() {
891
- local tmp_dir="/tmp/cortexhawk-update-$$"
940
+ local tmp_dir
941
+ tmp_dir=$(mktemp -d /tmp/cortexhawk-update-XXXXXX)
892
942
  local repo_url
893
943
  repo_url=$(get_source_url)
894
944
  if [ -z "$repo_url" ] && [ -f "$TARGET/.cortexhawk-manifest" ]; then
@@ -899,13 +949,19 @@ update_source_release() {
899
949
  echo "Set CORTEXHAWK_REPO environment variable and retry"
900
950
  exit 1
901
951
  fi
902
- mkdir -p "$tmp_dir"
903
952
  echo " Downloading from $repo_url..."
904
- if ! curl -sL "$repo_url/archive/refs/heads/main.tar.gz" | tar xz -C "$tmp_dir" --strip-components=1; then
953
+ local tarball="$tmp_dir/update.tar.gz"
954
+ if ! curl -sL "$repo_url/archive/refs/heads/main.tar.gz" -o "$tarball"; then
905
955
  echo "Error: failed to download latest release"
906
956
  rm -rf "$tmp_dir"
907
957
  exit 1
908
958
  fi
959
+ if ! tar xzf "$tarball" -C "$tmp_dir" --strip-components=1; then
960
+ echo "Error: failed to extract update archive"
961
+ rm -rf "$tmp_dir"
962
+ exit 1
963
+ fi
964
+ rm -f "$tarball"
909
965
  SCRIPT_DIR="$tmp_dir"
910
966
  UPDATE_CLEANUP_DIR="$tmp_dir"
911
967
  }
@@ -1097,431 +1153,46 @@ sync_skills_update() {
1097
1153
  fi
1098
1154
  }
1099
1155
 
1100
- do_update() {
1101
- # 1. Validate target
1102
- if [ "$TARGET_CLI" != "claude" ]; then
1103
- echo "Error: --update is currently supported for Claude Code only"
1104
- echo "For Kimi CLI, re-run: install.sh --target kimi"
1105
- exit 1
1106
- fi
1107
-
1108
- if [ "$GLOBAL" = true ]; then
1109
- TARGET="$HOME/.claude"
1110
- else
1111
- TARGET="$(pwd)/.claude"
1112
- fi
1113
-
1114
- if [ ! -d "$TARGET" ]; then
1115
- echo "Error: no CortexHawk installation found at $TARGET"
1116
- echo "Run install.sh without --update for a fresh install"
1117
- exit 1
1118
- fi
1119
-
1120
- # 2. Read manifest
1121
- local manifest="$TARGET/.cortexhawk-manifest"
1122
- local current_version="unknown"
1123
- local current_profile="all"
1124
- local source_type
1125
- source_type=$(detect_source_type)
1126
-
1127
- if [ -f "$manifest" ]; then
1128
- current_version=$(grep '"version"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
1129
- current_profile=$(grep '"profile"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
1130
- local manifest_source
1131
- manifest_source=$(grep '"source"' "$manifest" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
1132
- [ -n "$manifest_source" ] && source_type="$manifest_source"
1133
- else
1134
- echo " No manifest found — treating as pre-update installation"
1135
- fi
1136
-
1137
- # 3. Profile override
1138
- local update_profile="$current_profile"
1139
- if [ -n "$PROFILE" ]; then
1140
- update_profile="$PROFILE"
1141
- fi
1142
-
1143
- if [ "$DRY_RUN" = true ]; then
1144
- echo "CortexHawk Dry Run (update)"
1145
- echo "============================"
1146
- else
1147
- echo "CortexHawk Update"
1148
- echo "==================="
1149
- fi
1150
- echo " Current version: $current_version"
1151
- echo " Profile: $update_profile"
1152
- echo " Source: $source_type"
1153
- echo ""
1154
-
1155
- # 3b. Auto-snapshot before update (skip in dry-run)
1156
- if [ "$DRY_RUN" != true ] && [ -f "$manifest" ]; then
1157
- echo "Creating pre-update snapshot..."
1158
- do_snapshot 2>/dev/null
1159
- PRE_UPDATE_SNAP=$(ls -t "$TARGET/.cortexhawk-snapshots"/*.json 2>/dev/null | head -1)
1160
- [ -n "$PRE_UPDATE_SNAP" ] && echo " Saved: $(basename "$PRE_UPDATE_SNAP")"
1161
- echo ""
1162
- fi
1163
-
1164
- # 4. Pull source (skip in dry-run — compare against current source)
1165
- if [ "$DRY_RUN" != true ]; then
1166
- if [ "$source_type" = "git" ]; then
1167
- echo "Updating CortexHawk source via git pull..."
1168
- update_source_git
1156
+ # === Generic Component Operations ===
1157
+ # These use the COMPONENTS array to avoid hardcoding directory lists.
1158
+
1159
+ # copy_all_components(source_dir, target_dir, profile)
1160
+ copy_all_components() {
1161
+ local src="$1" dst="$2" profile="$3"
1162
+ for entry in "${COMPONENTS[@]}"; do
1163
+ IFS=':' read -r name exec_flag <<< "$entry"
1164
+ [ -d "$src/$name" ] || continue
1165
+ mkdir -p "$dst/$name"
1166
+ if [ "$name" = "skills" ] && [ -n "$profile" ]; then
1167
+ copy_skills "$dst" "$profile" "$src"
1169
1168
  else
1170
- echo "Updating CortexHawk source via download..."
1171
- update_source_release
1172
- fi
1173
- echo " Source updated successfully."
1174
- fi
1175
-
1176
- # 5. Compare versions
1177
- local new_version
1178
- new_version=$(get_version)
1179
- echo " New version: $new_version"
1180
- echo ""
1181
-
1182
- if [ "$current_version" = "$new_version" ] && [ "$FORCE_MODE" != true ]; then
1183
- # Same version — check if files actually changed (checksum comparison)
1184
- local files_changed=0
1185
- if [ -f "$manifest" ]; then
1186
- while IFS= read -r line; do
1187
- local fpath fhash
1188
- fpath=$(echo "$line" | sed 's/.*"\([^"]*\)": "sha256:\([^"]*\)".*/\1/')
1189
- fhash=$(echo "$line" | sed 's/.*"sha256:\([^"]*\)".*/\1/')
1190
- [ -z "$fpath" ] || [ -z "$fhash" ] && continue
1191
- local source_file="$SCRIPT_DIR/$fpath"
1192
- [ -f "$source_file" ] || continue
1193
- local source_hash
1194
- source_hash=$(compute_checksum "$source_file")
1195
- if [ "$fhash" != "$source_hash" ]; then
1196
- files_changed=$((files_changed + 1))
1197
- fi
1198
- done < <(grep '"sha256:' "$manifest")
1199
- fi
1200
-
1201
- if [ "$files_changed" -eq 0 ] && [ "$DRY_RUN" != true ]; then
1202
- # Still apply install improvements even if no component files changed
1203
- local target_dir_name=".${TARGET_CLI:-claude}"
1204
- update_gitignore "$(dirname "$TARGET")" "$target_dir_name"
1205
- [ "$TARGET_CLI" = "codex" ] && update_gitignore "$(dirname "$TARGET")" ".agents"
1206
- if [ ! -f "$TARGET/git-workflow.conf" ]; then
1207
- GIT_BRANCHING="direct-main"
1208
- GIT_COMMIT_CONVENTION="conventional"
1209
- GIT_PR_PREFERENCE="on-demand"
1210
- GIT_AUTO_PUSH="after-commit"
1211
- GIT_WORK_BRANCH=""
1212
- source "$SCRIPT_DIR/scripts/git-workflow-init.sh" "$(dirname "$TARGET")" "$TARGET"
1213
- fi
1214
- echo "Already up to date ($new_version). No component files changed."
1215
- cleanup_update
1216
- exit 0
1217
- elif [ "$files_changed" -gt 0 ]; then
1218
- echo " Same version ($new_version) but $files_changed file(s) changed in source."
1219
- fi
1220
- fi
1221
-
1222
- if [ "$DRY_RUN" = true ]; then
1223
- echo " Comparing source $new_version vs installed $current_version"
1224
- elif [ "$current_version" = "$new_version" ]; then
1225
- echo " Syncing changed files ($new_version)..."
1226
- else
1227
- echo " Updating $current_version -> $new_version"
1228
- fi
1229
- echo ""
1230
-
1231
- # Set up profile file for skill filtering
1232
- if [ -n "$update_profile" ] && [ "$update_profile" != "all" ]; then
1233
- PROFILE_FILE="$SCRIPT_DIR/profiles/${update_profile}.json"
1234
- if [ ! -f "$PROFILE_FILE" ]; then
1235
- echo " Warning: profile '$update_profile' not found in source — installing all skills"
1236
- update_profile="all"
1237
- PROFILE_FILE=""
1169
+ cp -r "$src/$name/"* "$dst/$name/" 2>/dev/null || true
1238
1170
  fi
1239
- fi
1240
-
1241
- # 6. Reset counters and sync components
1242
- SYNC_ADDED=0
1243
- SYNC_UPDATED=0
1244
- SYNC_UNCHANGED=0
1245
- SYNC_SKIPPED=0
1246
- SYNC_CONFLICTS=0
1247
-
1248
- sync_component "agents"
1249
- sync_component "commands"
1250
- sync_skills_update "$update_profile"
1251
- sync_component "hooks"
1252
- sync_component "modes"
1253
- sync_component "mcp"
1254
-
1255
- # 6b. Sync agent personas from project root
1256
- local project_root
1257
- project_root="$(dirname "$TARGET")"
1258
- if [ -d "$project_root/.cortexhawk-agents" ] && [ "$DRY_RUN" != true ]; then
1259
- cp -r "$project_root/.cortexhawk-agents/"*.md "$TARGET/agents/" 2>/dev/null || true
1260
- local pc
1261
- pc=$(find "$project_root/.cortexhawk-agents" -name "*.md" -type f 2>/dev/null | wc -l | tr -d ' ')
1262
- [ "$pc" -gt 0 ] && echo " Synced $pc agent persona(s) from .cortexhawk-agents/"
1263
- fi
1264
-
1265
- # 7. Detect removed upstream files
1266
- if [ -f "$manifest" ]; then
1267
- while IFS= read -r line; do
1268
- local file_relpath
1269
- file_relpath=$(echo "$line" | sed 's/.*"\([^"]*\)": "sha256:.*/\1/')
1270
- [ -z "$file_relpath" ] && continue
1271
- if [ ! -f "$SCRIPT_DIR/$file_relpath" ] && [ -f "$TARGET/$file_relpath" ]; then
1272
- echo " Warning: $file_relpath was removed upstream (kept locally)"
1273
- fi
1274
- done < <(grep '"sha256:' "$manifest")
1275
- fi
1276
-
1277
- if [ "$DRY_RUN" != true ]; then
1278
- # Make hooks executable
1279
- chmod +x "$TARGET/hooks/"*.sh 2>/dev/null || true
1280
-
1281
- # 7b. Regenerate settings.json hooks + merge new permissions from compose.yml
1282
- if [ -f "$SCRIPT_DIR/hooks/compose.yml" ]; then
1283
- local hooks_json
1284
- hooks_json=$(generate_hooks_config "$SCRIPT_DIR/hooks/compose.yml" ".claude/hooks")
1285
- if [ -n "$hooks_json" ] && [ "$hooks_json" != "{}" ]; then
1286
- echo "$hooks_json" | python3 -c "
1287
- import json, sys
1288
- hooks = json.load(sys.stdin)
1289
- current = {}
1290
- try:
1291
- with open(sys.argv[1]) as f:
1292
- current = json.load(f)
1293
- except:
1294
- pass
1295
- current['hooks'] = hooks
1296
- # Merge new permissions from source
1297
- try:
1298
- with open(sys.argv[2]) as f:
1299
- src_perms = json.load(f).get('permissions', {})
1300
- cur_perms = current.get('permissions', {})
1301
- for key in ('allow', 'deny'):
1302
- src_list = src_perms.get(key, [])
1303
- cur_list = cur_perms.get(key, [])
1304
- added = [p for p in src_list if p not in cur_list]
1305
- if added:
1306
- cur_list.extend(added)
1307
- cur_perms[key] = cur_list
1308
- current['permissions'] = cur_perms
1309
- except:
1310
- pass
1311
- with open(sys.argv[1], 'w') as f:
1312
- json.dump(current, f, indent=2)
1313
- f.write('\n')
1314
- " "$TARGET/settings.json" "$SCRIPT_DIR/settings.json"
1315
- echo " Regenerated settings.json hooks from compose.yml"
1316
- fi
1317
- fi
1318
-
1319
- # 8. Write new manifest
1320
- write_manifest "$TARGET" "$update_profile" "$TARGET_CLI" true
1321
-
1322
- # 9. Run audit
1323
- run_audit "$(dirname "$TARGET")"
1324
-
1325
- # 10. Apply install improvements (gitignore, git-workflow defaults)
1326
- local target_dir_name=".${TARGET_CLI:-claude}"
1327
- update_gitignore "$(dirname "$TARGET")" "$target_dir_name"
1328
- [ "$TARGET_CLI" = "codex" ] && update_gitignore "$(dirname "$TARGET")" ".agents"
1329
- setup_templates "$(dirname "$TARGET")"
1330
-
1331
- if [ ! -f "$TARGET/git-workflow.conf" ]; then
1332
- GIT_BRANCHING="direct-main"
1333
- GIT_COMMIT_CONVENTION="conventional"
1334
- GIT_PR_PREFERENCE="on-demand"
1335
- GIT_AUTO_PUSH="after-commit"
1336
- GIT_WORK_BRANCH=""
1337
- source "$SCRIPT_DIR/scripts/git-workflow-init.sh" "$(dirname "$TARGET")" "$TARGET"
1338
- fi
1339
- fi
1340
-
1341
- # 10. Print summary
1342
- echo ""
1343
- if [ "$DRY_RUN" = true ]; then
1344
- echo "Dry run summary:"
1345
- echo " Would add: $SYNC_ADDED"
1346
- echo " Would update: $SYNC_UPDATED"
1347
- echo " Unchanged: $SYNC_UNCHANGED"
1348
- echo " Would skip: $SYNC_SKIPPED"
1349
- echo " Conflicts: $SYNC_CONFLICTS"
1350
- echo ""
1351
- echo "No files were modified (dry run)."
1352
- else
1353
- echo "Update complete: $current_version -> $new_version"
1354
- echo " Added: $SYNC_ADDED"
1355
- echo " Updated: $SYNC_UPDATED"
1356
- echo " Unchanged: $SYNC_UNCHANGED"
1357
- echo " Skipped: $SYNC_SKIPPED"
1358
- echo " Conflicts: $SYNC_CONFLICTS"
1359
- if [ -n "${PRE_UPDATE_SNAP:-}" ]; then
1360
- echo " Rollback: install.sh --restore $PRE_UPDATE_SNAP"
1361
- fi
1362
- echo ""
1363
- echo " To activate: exit your CLI (ctrl+c) and relaunch in this directory."
1364
- fi
1365
-
1366
- cleanup_update
1171
+ [ "$exec_flag" = "yes" ] && chmod +x "$dst/$name/"*.sh 2>/dev/null || true
1172
+ done
1367
1173
  }
1368
1174
 
1369
- # --- rotate_snapshots() ---
1370
- # Keeps only the N most recent snapshots, deletes the rest
1371
- rotate_snapshots() {
1372
- local snap_dir="$1"
1373
- local snaps
1374
- snaps=$(find "$snap_dir" -maxdepth 1 -name '*.json' -type f 2>/dev/null)
1375
- [ -z "$snaps" ] && return 0
1376
- local count
1377
- count=$(echo "$snaps" | wc -l | tr -d ' ')
1378
- if [ "$count" -gt "$MAX_SNAPSHOTS" ]; then
1379
- local to_delete=$((count - MAX_SNAPSHOTS))
1380
- ls -1t "$snap_dir"/*.json | tail -n "$to_delete" | while read -r old_snap; do
1381
- rm -f "$old_snap"
1382
- done
1383
- echo " Rotated: removed $to_delete old snapshot(s), keeping $MAX_SNAPSHOTS"
1384
- fi
1175
+ # sync_all_components(profile)
1176
+ sync_all_components() {
1177
+ local profile="$1"
1178
+ for entry in "${COMPONENTS[@]}"; do
1179
+ IFS=':' read -r name exec_flag <<< "$entry"
1180
+ if [ "$name" = "skills" ]; then
1181
+ sync_skills_update "$profile"
1182
+ else
1183
+ sync_component "$name"
1184
+ fi
1185
+ done
1385
1186
  }
1386
1187
 
1387
- # --- do_snapshot() ---
1388
- do_snapshot() {
1389
- if [ "$GLOBAL" = true ]; then
1390
- TARGET="$HOME/.claude"
1391
- else
1392
- TARGET="$(pwd)/.claude"
1393
- fi
1394
-
1395
- local manifest="$TARGET/.cortexhawk-manifest"
1396
- if [ ! -f "$manifest" ]; then
1397
- echo "Error: no CortexHawk manifest found at $manifest"
1398
- echo "Run install.sh first to create an installation"
1399
- exit 1
1400
- fi
1401
-
1402
- # Read manifest metadata
1403
- local version
1404
- version=$(grep '"version"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
1405
- local profile
1406
- profile=$(grep '"profile"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
1407
- local source_type
1408
- source_type=$(grep '"source"' "$manifest" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
1409
- local source_url
1410
- source_url=$(grep '"source_url"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
1411
- local source_path
1412
- source_path=$(grep '"source_path"' "$manifest" | sed 's/.*: *"\([^"]*\)".*/\1/')
1413
-
1414
- # Read settings.json
1415
- local settings_json="null"
1416
- if [ -f "$TARGET/settings.json" ]; then
1417
- settings_json=$(cat "$TARGET/settings.json")
1418
- fi
1419
-
1420
- # Read git-workflow.conf
1421
- local git_branching="" git_commit="" git_pr="" git_push=""
1422
- if [ -f "$TARGET/git-workflow.conf" ]; then
1423
- git_branching=$(grep '^BRANCHING=' "$TARGET/git-workflow.conf" | cut -d= -f2)
1424
- git_commit=$(grep '^COMMIT_CONVENTION=' "$TARGET/git-workflow.conf" | cut -d= -f2)
1425
- git_pr=$(grep '^PR_PREFERENCE=' "$TARGET/git-workflow.conf" | cut -d= -f2)
1426
- git_push=$(grep '^AUTO_PUSH=' "$TARGET/git-workflow.conf" | cut -d= -f2)
1427
- fi
1428
-
1429
- # Read custom profile if applicable (use PROFILE_FILE from current run, never glob /tmp/)
1430
- local profile_def="null"
1431
- if [ -n "$PROFILE_FILE" ] && [ -f "$PROFILE_FILE" ]; then
1432
- profile_def=$(cat "$PROFILE_FILE")
1433
- fi
1434
-
1435
- # Build files checksums from manifest
1436
- local files_json
1437
- files_json=$(sed -n '/"files"/,/^ }/p' "$manifest" | sed '1d;$d')
1438
-
1439
- # Collect file contents (base64 encoded for binary safety)
1440
- local git_workflow_content="" claude_md_content=""
1441
- if [ -f "$TARGET/git-workflow.conf" ]; then
1442
- git_workflow_content=$(base64 < "$TARGET/git-workflow.conf" | tr -d '\n')
1443
- fi
1444
- if [ -f "$TARGET/../CLAUDE.md" ]; then
1445
- claude_md_content=$(base64 < "$TARGET/../CLAUDE.md" | tr -d '\n')
1446
- fi
1447
-
1448
- # Generate snapshot
1449
- local now
1450
- now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1451
- local snap_name
1452
- snap_name=$(date -u +"%Y-%m-%d-%H%M%S")
1453
- local snap_dir="$TARGET/.cortexhawk-snapshots"
1454
- local snap_file="$snap_dir/${snap_name}.json"
1455
-
1456
- mkdir -p "$snap_dir"
1457
-
1458
- # Write snapshot JSON
1459
- printf '{\n' > "$snap_file"
1460
- printf ' "snapshot_version": "2",\n' >> "$snap_file"
1461
- printf ' "snapshot_date": "%s",\n' "$now" >> "$snap_file"
1462
- printf ' "cortexhawk_version": "%s",\n' "$version" >> "$snap_file"
1463
- printf ' "target": "claude",\n' >> "$snap_file"
1464
- printf ' "profile": "%s",\n' "$profile" >> "$snap_file"
1465
- printf ' "profile_definition": %s,\n' "$profile_def" >> "$snap_file"
1466
- printf ' "source": "%s",\n' "$source_type" >> "$snap_file"
1467
- printf ' "source_url": "%s",\n' "$source_url" >> "$snap_file"
1468
- printf ' "source_path": "%s",\n' "$source_path" >> "$snap_file"
1469
- printf ' "settings": %s,\n' "$settings_json" >> "$snap_file"
1470
- printf ' "git_workflow": {\n' >> "$snap_file"
1471
- printf ' "BRANCHING": "%s",\n' "$git_branching" >> "$snap_file"
1472
- printf ' "COMMIT_CONVENTION": "%s",\n' "$git_commit" >> "$snap_file"
1473
- printf ' "PR_PREFERENCE": "%s",\n' "$git_pr" >> "$snap_file"
1474
- printf ' "AUTO_PUSH": "%s"\n' "$git_push" >> "$snap_file"
1475
- printf ' },\n' >> "$snap_file"
1476
- printf ' "files": {\n' >> "$snap_file"
1477
- printf '%s\n' "$files_json" >> "$snap_file"
1478
- printf ' },\n' >> "$snap_file"
1479
- printf ' "file_contents": {\n' >> "$snap_file"
1480
- [ -n "$git_workflow_content" ] && printf ' "git-workflow.conf": "%s",\n' "$git_workflow_content" >> "$snap_file"
1481
- [ -n "$claude_md_content" ] && printf ' "CLAUDE.md": "%s",\n' "$claude_md_content" >> "$snap_file"
1482
- # Remove trailing comma from last entry
1483
- sed_inplace '$ s/,$//' "$snap_file"
1484
- printf ' }\n' >> "$snap_file"
1485
- printf '}\n' >> "$snap_file"
1486
-
1487
- echo "CortexHawk Snapshot"
1488
- echo "====================="
1489
- echo " Version: $version"
1490
- echo " Profile: $profile"
1491
- echo " Target: $TARGET"
1492
- echo " Saved to: $snap_file"
1493
-
1494
- # Create portable archive if requested
1495
- if [ "$PORTABLE_MODE" = true ]; then
1496
- local archive_name="${snap_name}.tar.gz"
1497
- local archive_path="$snap_dir/$archive_name"
1498
- local tmp_dir
1499
- tmp_dir=$(mktemp -d)
1500
-
1501
- # Copy snapshot and files to temp structure
1502
- cp "$snap_file" "$tmp_dir/snapshot.json"
1503
- mkdir -p "$tmp_dir/files"
1504
- cp -r "$TARGET/agents" "$tmp_dir/files/" 2>/dev/null || true
1505
- cp -r "$TARGET/commands" "$tmp_dir/files/" 2>/dev/null || true
1506
- cp -r "$TARGET/skills" "$tmp_dir/files/" 2>/dev/null || true
1507
- cp -r "$TARGET/hooks" "$tmp_dir/files/" 2>/dev/null || true
1508
- cp -r "$TARGET/modes" "$tmp_dir/files/" 2>/dev/null || true
1509
- cp -r "$TARGET/mcp" "$tmp_dir/files/" 2>/dev/null || true
1510
- cp "$TARGET/settings.json" "$tmp_dir/files/" 2>/dev/null || true
1511
- cp "$TARGET/git-workflow.conf" "$tmp_dir/files/" 2>/dev/null || true
1512
-
1513
- # Create archive
1514
- tar -czf "$archive_path" -C "$tmp_dir" .
1515
- rm -rf "$tmp_dir"
1516
-
1517
- echo " Archive: $archive_path"
1518
- echo ""
1519
- echo "Restore with: install.sh --restore $archive_path"
1520
- else
1521
- rotate_snapshots "$snap_dir"
1522
- echo ""
1523
- echo "Restore with: install.sh --restore $snap_file"
1524
- fi
1188
+ # count_component_files(source_dir)
1189
+ count_component_files() {
1190
+ local src="$1"
1191
+ for entry in "${COMPONENTS[@]}"; do
1192
+ IFS=':' read -r name _ <<< "$entry"
1193
+ local c; c=$(find "$src/$name" -type f 2>/dev/null | wc -l | tr -d ' ')
1194
+ printf " %-12s %s files\n" "$name/" "$c"
1195
+ done
1525
1196
  }
1526
1197
 
1527
1198
  # --- do_snapshots_list() ---
@@ -1873,370 +1544,6 @@ do_export_team() {
1873
1544
  echo "Share this file with your team. Install with: install.sh --team"
1874
1545
  }
1875
1546
 
1876
- # --- do_restore() ---
1877
- do_restore() {
1878
- local snap_file="$1"
1879
- local archive_tmp=""
1880
- local portable_files=""
1881
-
1882
- if [ -z "$snap_file" ] || [ ! -f "$snap_file" ]; then
1883
- echo "Error: snapshot file not found: $snap_file"
1884
- exit 1
1885
- fi
1886
-
1887
- # Handle portable archive (.tar.gz)
1888
- if [[ "$snap_file" == *.tar.gz ]]; then
1889
- archive_tmp=$(mktemp -d)
1890
- tar -xzf "$snap_file" -C "$archive_tmp"
1891
- snap_file="$archive_tmp/snapshot.json"
1892
- portable_files="$archive_tmp/files"
1893
- if [ ! -f "$snap_file" ]; then
1894
- echo "Error: invalid archive — snapshot.json not found"
1895
- rm -rf "$archive_tmp"
1896
- exit 1
1897
- fi
1898
- echo "Extracting portable archive..."
1899
- fi
1900
-
1901
- # Extract metadata from snapshot
1902
- local snap_version
1903
- snap_version=$(grep '"cortexhawk_version"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
1904
- local snap_profile
1905
- snap_profile=$(grep '"profile"' "$snap_file" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
1906
- local snap_source
1907
- snap_source=$(grep '"source"' "$snap_file" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
1908
- local snap_source_url
1909
- snap_source_url=$(grep '"source_url"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
1910
- local snap_source_path
1911
- snap_source_path=$(grep '"source_path"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
1912
-
1913
- echo "CortexHawk Restore"
1914
- echo "====================="
1915
- echo " Snapshot version: $snap_version"
1916
- echo " Profile: $snap_profile"
1917
- echo " Source: $snap_source"
1918
- echo ""
1919
-
1920
- # Determine CortexHawk source
1921
- local restore_source="$SCRIPT_DIR"
1922
- if [ "$snap_source" = "git" ] && [ -n "$snap_source_path" ] && [ -d "$snap_source_path" ]; then
1923
- restore_source="$snap_source_path"
1924
- fi
1925
- if [ ! -d "$restore_source/agents" ]; then
1926
- echo "Error: CortexHawk source not found at $restore_source"
1927
- echo "Ensure the CortexHawk repo is available or set CORTEXHAWK_REPO"
1928
- exit 1
1929
- fi
1930
-
1931
- # Warn if source version differs from snapshot
1932
- local current_version
1933
- current_version=$(get_version)
1934
- if [ "$snap_version" != "$current_version" ]; then
1935
- echo " Warning: snapshot is v$snap_version but source is v$current_version"
1936
- echo " Some file checksums may not match"
1937
- echo ""
1938
- fi
1939
-
1940
- # Set profile for reinstall
1941
- if [ -n "$snap_profile" ] && [ "$snap_profile" != "all" ]; then
1942
- PROFILE="$snap_profile"
1943
- PROFILE_FILE="$restore_source/profiles/${snap_profile}.json"
1944
- if [ ! -f "$PROFILE_FILE" ]; then
1945
- echo " Warning: profile '$snap_profile' not found — installing all skills"
1946
- PROFILE=""
1947
- PROFILE_FILE=""
1948
- fi
1949
- fi
1950
-
1951
- # Determine target
1952
- if [ "$GLOBAL" = true ]; then
1953
- TARGET="$HOME/.claude"
1954
- else
1955
- TARGET="$(pwd)/.claude"
1956
- fi
1957
-
1958
- # Save the original SCRIPT_DIR, use snapshot source
1959
- local orig_script_dir="$SCRIPT_DIR"
1960
- SCRIPT_DIR="$restore_source"
1961
-
1962
- # Reinstall using the standard flow
1963
- echo "Reinstalling CortexHawk components..."
1964
- mkdir -p "$TARGET"/{agents,commands,skills,hooks,modes,mcp}
1965
-
1966
- # Use portable archive files if available, otherwise use source repo
1967
- if [ -n "$portable_files" ] && [ -d "$portable_files" ]; then
1968
- echo " Using files from portable archive..."
1969
- cp -r "$portable_files/agents/"* "$TARGET/agents/" 2>/dev/null || true
1970
- cp -r "$portable_files/commands/"* "$TARGET/commands/" 2>/dev/null || true
1971
- cp -r "$portable_files/skills/"* "$TARGET/skills/" 2>/dev/null || true
1972
- cp -r "$portable_files/hooks/"* "$TARGET/hooks/" 2>/dev/null || true
1973
- cp -r "$portable_files/modes/"* "$TARGET/modes/" 2>/dev/null || true
1974
- cp -r "$portable_files/mcp/"* "$TARGET/mcp/" 2>/dev/null || true
1975
- else
1976
- cp -r "$SCRIPT_DIR/agents/"* "$TARGET/agents/" 2>/dev/null || true
1977
- cp -r "$SCRIPT_DIR/commands/"* "$TARGET/commands/" 2>/dev/null || true
1978
- copy_skills "$TARGET" "$PROFILE"
1979
- cp -r "$SCRIPT_DIR/hooks/"* "$TARGET/hooks/" 2>/dev/null || true
1980
- cp -r "$SCRIPT_DIR/modes/"* "$TARGET/modes/" 2>/dev/null || true
1981
- cp -r "$SCRIPT_DIR/mcp/"* "$TARGET/mcp/" 2>/dev/null || true
1982
- fi
1983
- chmod +x "$TARGET/hooks/"*.sh 2>/dev/null || true
1984
-
1985
- # Restore settings.json from snapshot
1986
- if command -v python3 >/dev/null 2>&1; then
1987
- python3 -c "
1988
- import json, sys
1989
- with open(sys.argv[1]) as f:
1990
- snap = json.load(f)
1991
- settings = snap.get('settings')
1992
- if settings is not None:
1993
- with open(sys.argv[2], 'w') as f:
1994
- json.dump(settings, f, indent=2)
1995
- f.write('\n')
1996
- print(' Restored settings.json')
1997
- " "$snap_file" "$TARGET/settings.json"
1998
- else
1999
- echo " Warning: python3 not found — settings.json not restored from snapshot"
2000
- fi
2001
-
2002
- # Restore git-workflow.conf from snapshot
2003
- local branching commit_conv pr_pref auto_push
2004
- branching=$(grep '"BRANCHING"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
2005
- commit_conv=$(grep '"COMMIT_CONVENTION"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
2006
- pr_pref=$(grep '"PR_PREFERENCE"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
2007
- auto_push=$(grep '"AUTO_PUSH"' "$snap_file" | sed 's/.*: *"\([^"]*\)".*/\1/')
2008
-
2009
- if [ -n "$branching" ] || [ -n "$commit_conv" ] || [ -n "$pr_pref" ] || [ -n "$auto_push" ]; then
2010
- {
2011
- echo "BRANCHING=$branching"
2012
- echo "COMMIT_CONVENTION=$commit_conv"
2013
- echo "PR_PREFERENCE=$pr_pref"
2014
- echo "AUTO_PUSH=$auto_push"
2015
- } > "$TARGET/git-workflow.conf"
2016
- echo " Restored git-workflow.conf (from git_workflow keys)"
2017
- fi
2018
-
2019
- # Restore file_contents (snapshot v2) — overwrites git-workflow.conf if present
2020
- if grep -q '"file_contents"' "$snap_file" && command -v python3 >/dev/null 2>&1; then
2021
- python3 -c "
2022
- import json, base64, sys, os
2023
- snap_file, target_dir = sys.argv[1], sys.argv[2]
2024
- with open(snap_file) as f:
2025
- snap = json.load(f)
2026
- contents = snap.get('file_contents', {})
2027
- for filename, b64data in contents.items():
2028
- try:
2029
- data = base64.b64decode(b64data).decode('utf-8')
2030
- if filename == 'CLAUDE.md':
2031
- target_path = os.path.dirname(target_dir) + '/CLAUDE.md'
2032
- else:
2033
- target_path = target_dir + '/' + filename
2034
- os.makedirs(os.path.dirname(target_path), exist_ok=True)
2035
- with open(target_path, 'w') as f:
2036
- f.write(data)
2037
- print(f' Restored {filename} (from file_contents)')
2038
- except Exception as e:
2039
- print(f' Warning: could not restore {filename}: {e}', file=sys.stderr)
2040
- " "$snap_file" "$TARGET"
2041
- fi
2042
-
2043
- # Write new manifest
2044
- write_manifest "$TARGET" "$PROFILE" "claude" false
2045
-
2046
- # Verify checksums against snapshot
2047
- local verified=0 mismatched=0 missing=0
2048
- while IFS= read -r line; do
2049
- local file_relpath
2050
- file_relpath=$(echo "$line" | sed 's/.*"\([^"]*\)": "sha256:\([^"]*\)".*/\1/')
2051
- local expected_checksum
2052
- expected_checksum=$(echo "$line" | sed 's/.*"sha256:\([^"]*\)".*/\1/')
2053
- [ -z "$file_relpath" ] || [ -z "$expected_checksum" ] && continue
2054
-
2055
- local target_file="$TARGET/$file_relpath"
2056
- if [ ! -f "$target_file" ]; then
2057
- missing=$((missing + 1))
2058
- else
2059
- local actual_checksum
2060
- actual_checksum=$(compute_checksum "$target_file")
2061
- if [ "$actual_checksum" = "$expected_checksum" ]; then
2062
- verified=$((verified + 1))
2063
- else
2064
- mismatched=$((mismatched + 1))
2065
- fi
2066
- fi
2067
- done < <(grep '"sha256:' "$snap_file")
2068
-
2069
- SCRIPT_DIR="$orig_script_dir"
2070
-
2071
- echo ""
2072
- echo "Restore complete"
2073
- echo " Verified: $verified files match snapshot checksums"
2074
- [ "$mismatched" -gt 0 ] && echo " Mismatched: $mismatched files differ (source version may differ from snapshot)"
2075
- [ "$missing" -gt 0 ] && echo " Missing: $missing files not found in source"
2076
- echo ""
2077
- echo " To activate: exit your CLI (ctrl+c) and relaunch in this directory."
2078
-
2079
- # Cleanup temp dir from portable archive
2080
- [ -n "$archive_tmp" ] && rm -rf "$archive_tmp"
2081
- }
2082
-
2083
- # --- do_doctor() ---
2084
- # Diagnose installation health
2085
- do_doctor() {
2086
- if [ "$GLOBAL" = true ]; then
2087
- TARGET="$HOME/.claude"
2088
- else
2089
- TARGET="$(pwd)/.claude"
2090
- fi
2091
-
2092
- local ok=0 warn=0 err=0
2093
- _doc_ok() { echo " [OK] $1"; ok=$((ok+1)); }
2094
- _doc_warn() { echo " [WARN] $1"; warn=$((warn+1)); }
2095
- _doc_err() { echo " [ERR] $1"; err=$((err+1)); }
2096
-
2097
- # Header
2098
- local version="" profile="" target_cli_name=""
2099
- if [ -f "$TARGET/.cortexhawk-manifest" ]; then
2100
- version=$(grep -o '"version": "[^"]*"' "$TARGET/.cortexhawk-manifest" | head -1 | cut -d'"' -f4)
2101
- profile=$(grep -o '"profile": "[^"]*"' "$TARGET/.cortexhawk-manifest" | head -1 | cut -d'"' -f4)
2102
- target_cli_name=$(grep -o '"target": "[^"]*"' "$TARGET/.cortexhawk-manifest" | head -1 | cut -d'"' -f4)
2103
- fi
2104
- echo "CortexHawk Doctor"
2105
- echo "==================="
2106
- echo " Installation: $TARGET"
2107
- echo " Version: ${version:-unknown}"
2108
- echo " Profile: ${profile:-unknown}"
2109
- echo " Target: ${target_cli_name:-claude}"
2110
- echo ""
2111
- echo "Checks:"
2112
-
2113
- # 1. Manifest
2114
- if [ -f "$TARGET/.cortexhawk-manifest" ]; then
2115
- _doc_ok "Manifest present"
2116
- else
2117
- _doc_err "Manifest missing — run install.sh to create an installation"
2118
- fi
2119
-
2120
- # 2. settings.json valid JSON
2121
- if [ -f "$TARGET/settings.json" ]; then
2122
- if python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$TARGET/settings.json" 2>/dev/null; then
2123
- _doc_ok "settings.json valid JSON"
2124
- else
2125
- _doc_err "settings.json invalid JSON"
2126
- fi
2127
- else
2128
- _doc_warn "settings.json not found"
2129
- fi
2130
-
2131
- # 3. Component counts (compare installed vs source)
2132
- for comp in agents commands modes; do
2133
- local installed=0 source_count=0
2134
- installed=$(find "$TARGET/$comp" -name "*.md" -type f 2>/dev/null | wc -l)
2135
- source_count=$(find "$SCRIPT_DIR/$comp" -name "*.md" -type f 2>/dev/null | wc -l)
2136
- if [ "$installed" -eq "$source_count" ] 2>/dev/null; then
2137
- _doc_ok "$installed/$source_count $comp installed"
2138
- elif [ "$installed" -gt 0 ] 2>/dev/null; then
2139
- _doc_warn "$installed/$source_count $comp installed"
2140
- else
2141
- _doc_err "0/$source_count $comp installed"
2142
- fi
2143
- done
2144
-
2145
- # 4. Skills (profile-dependent, just count what's there)
2146
- local skills_installed=0 skills_source=0
2147
- skills_installed=$(find "$TARGET/skills" -name "*.md" -type f 2>/dev/null | wc -l)
2148
- skills_source=$(find "$SCRIPT_DIR/skills" -name "*.md" -type f 2>/dev/null | wc -l)
2149
- if [ "$skills_installed" -gt 0 ] 2>/dev/null; then
2150
- _doc_ok "$skills_installed/$skills_source skills installed (profile: ${profile:-all})"
2151
- else
2152
- _doc_err "No skills installed"
2153
- fi
2154
-
2155
- # 5. Hooks executable
2156
- local hooks_ok=0 hooks_total=0
2157
- for hook in "$TARGET/hooks/"*.sh; do
2158
- [ -f "$hook" ] || continue
2159
- hooks_total=$((hooks_total+1))
2160
- if [ -x "$hook" ]; then
2161
- hooks_ok=$((hooks_ok+1))
2162
- else
2163
- _doc_warn "Hook not executable: $(basename "$hook")"
2164
- fi
2165
- done
2166
- if [ "$hooks_total" -gt 0 ]; then
2167
- if [ "$hooks_ok" -eq "$hooks_total" ]; then
2168
- _doc_ok "$hooks_ok/$hooks_total hooks executable"
2169
- fi
2170
- else
2171
- _doc_warn "No hooks found"
2172
- fi
2173
-
2174
- # 6. compose.yml vs settings.json coherence
2175
- if [ -f "$TARGET/../hooks/compose.yml" ] || [ -f "$SCRIPT_DIR/hooks/compose.yml" ]; then
2176
- _doc_ok "compose.yml present"
2177
- fi
2178
-
2179
- # 7. MCP configs
2180
- if [ -d "$TARGET/mcp" ] && [ "$(find "$TARGET/mcp" -type f 2>/dev/null | wc -l)" -gt 0 ]; then
2181
- _doc_ok "MCP configs present"
2182
- elif [ -d "$TARGET/mcp" ]; then
2183
- _doc_warn "MCP directory exists but empty"
2184
- fi
2185
-
2186
- # 8. docs/ workspace
2187
- local project_root
2188
- project_root="$(dirname "$TARGET")"
2189
- if [ -d "$project_root/docs" ]; then
2190
- _doc_ok "docs/ workspace exists"
2191
- else
2192
- _doc_warn "docs/ workspace missing"
2193
- fi
2194
-
2195
- # 9. Broken symlinks in docs/plans/
2196
- local broken=0
2197
- if [ -d "$project_root/docs/plans" ]; then
2198
- while IFS= read -r link; do
2199
- [ -z "$link" ] && continue
2200
- _doc_warn "Broken symlink: $link"
2201
- broken=$((broken+1))
2202
- done < <(find "$project_root/docs/plans" -type l ! -exec test -e {} \; -print 2>/dev/null)
2203
- [ "$broken" -eq 0 ] && _doc_ok "No broken symlinks in docs/plans/"
2204
- fi
2205
-
2206
- # 10. git-workflow.conf
2207
- if [ -f "$TARGET/git-workflow.conf" ]; then
2208
- _doc_ok "git-workflow.conf present"
2209
- else
2210
- _doc_warn "git-workflow.conf not found (run --init to configure)"
2211
- fi
2212
-
2213
- # 11. CLAUDE.md at project root
2214
- if [ -f "$project_root/CLAUDE.md" ]; then
2215
- _doc_ok "CLAUDE.md present at project root"
2216
- else
2217
- _doc_warn "CLAUDE.md not found at project root"
2218
- fi
2219
-
2220
- # 12. Version match source vs manifest
2221
- if [ -n "$version" ]; then
2222
- local source_version
2223
- source_version=$(get_version)
2224
- if [ "$version" = "$source_version" ]; then
2225
- _doc_ok "Version match: source $source_version = manifest $version"
2226
- else
2227
- _doc_warn "Version mismatch: source $source_version != manifest $version (run --update)"
2228
- fi
2229
- fi
2230
-
2231
- # Summary
2232
- echo ""
2233
- echo "Summary: $ok OK, $warn WARN, $err ERR"
2234
-
2235
- # Exit code: 1 if any errors
2236
- [ "$err" -gt 0 ] && exit 1
2237
- return 0
2238
- }
2239
-
2240
1547
  # --- do_uninstall() ---
2241
1548
  # Remove CortexHawk installation cleanly
2242
1549
  do_uninstall() {
@@ -2707,10 +2014,17 @@ do_add_skill() {
2707
2014
  exit 1
2708
2015
  fi
2709
2016
 
2710
- # Security warning if scripts present
2017
+ # Security gate: block skills with executable scripts unless --trust is used
2711
2018
  if find "$tmp_dir/repo" -name "*.sh" -o -name "*.py" 2>/dev/null | grep -q .; then
2712
- echo " Warning: this skill contains executable scripts"
2713
- echo " Review them before use: $skill_dir/scripts/"
2019
+ if [ "${TRUST_SKILL:-false}" = true ]; then
2020
+ yellow " Warning: this skill contains executable scripts — installing with --trust"
2021
+ else
2022
+ echo " ERROR: This skill contains executable scripts (.sh/.py)."
2023
+ echo " Re-run with --trust to install: install.sh --add-skill $ADD_SKILL_URL --trust"
2024
+ echo " Review the source first: $ADD_SKILL_URL"
2025
+ rm -rf "$tmp_dir"
2026
+ exit 1
2027
+ fi
2714
2028
  fi
2715
2029
 
2716
2030
  # Create community directory if needed
@@ -2831,7 +2145,8 @@ do_team_install() {
2831
2145
 
2832
2146
  # Generate temporary profile JSON from skills list
2833
2147
  if [ -n "$TEAM_SKILLS" ]; then
2834
- local tmp_profile="/tmp/cortexhawk-team-$$.json"
2148
+ local tmp_profile
2149
+ tmp_profile=$(mktemp /tmp/cortexhawk-team-XXXXXX.json)
2835
2150
  printf '{\n "name": "team",\n "skills": [\n' > "$tmp_profile"
2836
2151
  local first=true
2837
2152
  while IFS= read -r skill; do
@@ -2907,172 +2222,37 @@ do_team_install() {
2907
2222
  echo "Team install complete."
2908
2223
  }
2909
2224
 
2910
- # --- install_claude() ---
2911
- install_claude() {
2912
- if [ "$GLOBAL" = true ]; then
2913
- TARGET="$HOME/.claude"
2914
- else
2915
- TARGET="$(pwd)/.claude"
2916
- fi
2917
-
2918
- if [ "$DRY_RUN" = true ]; then
2919
- echo "CortexHawk Dry Run (install)"
2920
- echo "=============================="
2921
- echo " Target: $TARGET"
2922
- echo " Profile: ${PROFILE:-all}"
2923
- echo ""
2924
- echo "Would install:"
2925
- for comp in agents commands hooks modes mcp; do
2926
- local c; c=$(find "$SCRIPT_DIR/$comp" -type f 2>/dev/null | wc -l | tr -d ' ')
2927
- printf " %-12s %s files\n" "$comp/" "$c"
2928
- done
2929
- local sc; sc=$(find "$SCRIPT_DIR/skills" -type f 2>/dev/null | wc -l | tr -d ' ')
2930
- printf " %-12s %s files\n" "skills/" "$sc"
2931
- echo " settings.json"
2932
- [ ! -f "$(pwd)/CLAUDE.md" ] && echo " CLAUDE.md"
2933
- echo ""
2934
- echo "No files were modified (dry run)."
2935
- return
2225
+ # --- install_git_post_merge_hook(project_root) ---
2226
+ # Installs .git/hooks/post-merge to auto-run cleanup after git merge (opt-in)
2227
+ install_git_post_merge_hook() {
2228
+ local project_root="${1:-$(pwd)}"
2229
+ local git_dir="$project_root/.git"
2230
+ if [ ! -d "$git_dir" ]; then
2231
+ yellow " Warning: no .git directory found — skipping post-merge hook"
2232
+ return 0
2936
2233
  fi
2937
-
2938
- echo "Installing for Claude Code to project: $TARGET"
2939
-
2940
- mkdir -p "$TARGET"/{agents,commands,skills,hooks,modes,mcp}
2941
-
2942
- cp -r "$SCRIPT_DIR/agents/"* "$TARGET/agents/" 2>/dev/null || true
2943
- # Copy agent personas from project root if present
2944
- local project_root
2945
- project_root="$(dirname "$TARGET")"
2946
- if [ -d "$project_root/.cortexhawk-agents" ]; then
2947
- cp -r "$project_root/.cortexhawk-agents/"*.md "$TARGET/agents/" 2>/dev/null || true
2948
- local persona_count
2949
- persona_count=$(find "$project_root/.cortexhawk-agents" -name "*.md" -type f 2>/dev/null | wc -l | tr -d ' ')
2950
- [ "$persona_count" -gt 0 ] && echo " Loaded $persona_count agent persona(s) from .cortexhawk-agents/"
2951
- fi
2952
- cp -r "$SCRIPT_DIR/commands/"* "$TARGET/commands/" 2>/dev/null || true
2953
- copy_skills "$TARGET" "$PROFILE"
2954
- cp -r "$SCRIPT_DIR/hooks/"* "$TARGET/hooks/" 2>/dev/null || true
2955
- cp -r "$SCRIPT_DIR/modes/"* "$TARGET/modes/" 2>/dev/null || true
2956
- cp -r "$SCRIPT_DIR/mcp/"* "$TARGET/mcp/" 2>/dev/null || true
2957
-
2958
- local hooks_json
2959
- hooks_json=$(generate_hooks_config "$SCRIPT_DIR/hooks/compose.yml" ".claude/hooks")
2960
- if [ ! -f "$TARGET/settings.json" ]; then
2961
- # Fresh install: generate settings.json from scratch
2962
- if [ -n "$hooks_json" ] && [ "$hooks_json" != "{}" ]; then
2963
- echo "$hooks_json" | python3 -c "
2964
- import json, sys
2965
- hooks = json.load(sys.stdin)
2966
- with open(sys.argv[1]) as f:
2967
- permissions = json.load(f).get('permissions', {})
2968
- with open(sys.argv[2], 'w') as f:
2969
- json.dump({'permissions': permissions, 'hooks': hooks}, f, indent=2)
2970
- f.write('\n')
2971
- " "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
2972
- echo " Generated settings.json from hooks/compose.yml"
2234
+ mkdir -p "$git_dir/hooks"
2235
+ local hook_path="$git_dir/hooks/post-merge"
2236
+ if [ -f "$hook_path" ]; then
2237
+ if grep -q "CortexHawk" "$hook_path" 2>/dev/null; then
2238
+ echo " post-merge hook: already installed"
2973
2239
  else
2974
- cp "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
2975
- fi
2976
- else
2977
- # Merge: preserve user customizations, add new hooks + permissions
2978
- python3 -c "
2979
- import json, sys, shutil, os
2980
-
2981
- raw = sys.stdin.read().strip()
2982
- hooks = json.loads(raw) if raw else {}
2983
-
2984
- # Load current settings (tolerate corrupted JSON)
2985
- try:
2986
- with open(sys.argv[1]) as f:
2987
- current = json.load(f)
2988
- except Exception:
2989
- backup = sys.argv[1] + '.bak'
2990
- if os.path.isfile(sys.argv[1]):
2991
- shutil.copy2(sys.argv[1], backup)
2992
- print(f' Warning: settings.json corrupted — backed up to {os.path.basename(backup)}', file=sys.stderr)
2993
- current = {}
2994
-
2995
- try:
2996
- with open(sys.argv[2]) as f:
2997
- source = json.load(f)
2998
- except Exception:
2999
- source = {}
3000
-
3001
- changes = []
3002
-
3003
- # Merge hooks (regenerate from compose.yml)
3004
- if hooks and hooks != {}:
3005
- current['hooks'] = hooks
3006
- changes.append('hooks regenerated')
3007
-
3008
- # Merge permissions (union: keep user additions + add new from source)
3009
- src_perms = source.get('permissions', {})
3010
- cur_perms = current.get('permissions', {})
3011
- for key in ('allow', 'deny'):
3012
- src_list = src_perms.get(key, [])
3013
- cur_list = cur_perms.get(key, [])
3014
- added = [p for p in src_list if p not in cur_list]
3015
- if added:
3016
- cur_list.extend(added)
3017
- changes.append(f'{len(added)} new {key} permission(s)')
3018
- cur_perms[key] = cur_list
3019
- current['permissions'] = cur_perms
3020
-
3021
- with open(sys.argv[1], 'w') as f:
3022
- json.dump(current, f, indent=2)
3023
- f.write('\n')
3024
-
3025
- if changes:
3026
- print(' Merged settings.json: ' + ', '.join(changes))
3027
- else:
3028
- print(' settings.json up to date — no merge needed')
3029
- " "$TARGET/settings.json" "$SCRIPT_DIR/settings.json" <<< "${hooks_json:-}"
3030
- fi
3031
-
3032
- PROJECT_ROOT="$(dirname "$TARGET")"
3033
- if [ ! -f "$PROJECT_ROOT/CLAUDE.md" ]; then
3034
- cp "$SCRIPT_DIR/CLAUDE.md" "$PROJECT_ROOT/CLAUDE.md"
3035
- else
3036
- echo "CLAUDE.md already exists — skipping"
3037
- fi
3038
-
3039
- # Git workflow config (interactive in --init, defaults otherwise)
3040
- if [ "$GLOBAL" = false ]; then
3041
- if [ "$INIT_MODE" = true ]; then
3042
- source "$SCRIPT_DIR/scripts/git-workflow-init.sh" "$PROJECT_ROOT" "$TARGET"
3043
- elif [ ! -f "$TARGET/git-workflow.conf" ]; then
3044
- # Apply sensible defaults without asking
3045
- GIT_BRANCHING="direct-main"
3046
- GIT_COMMIT_CONVENTION="conventional"
3047
- GIT_PR_PREFERENCE="on-demand"
3048
- GIT_AUTO_PUSH="after-commit"
3049
- GIT_WORK_BRANCH=""
3050
- source "$SCRIPT_DIR/scripts/git-workflow-init.sh" "$PROJECT_ROOT" "$TARGET"
2240
+ yellow " post-merge hook: skipped (custom hook exists at .git/hooks/post-merge)"
3051
2241
  fi
2242
+ return 0
3052
2243
  fi
3053
-
3054
- chmod +x "$TARGET/hooks/"*.sh 2>/dev/null || true
3055
-
3056
- # Write manifest for future updates
3057
- write_manifest "$TARGET" "$PROFILE" "claude" false
3058
-
3059
- # Create docs/ workspace for agent outputs (local only)
3060
- if [ "$GLOBAL" = false ]; then
3061
- create_docs_workspace "$(dirname "$TARGET")"
3062
- fi
3063
-
3064
- run_audit "$(dirname "$TARGET")"
3065
- update_gitignore "$(dirname "$TARGET")" ".claude"
3066
- setup_templates "$(dirname "$TARGET")"
3067
-
3068
- echo ""
3069
- echo "CortexHawk installed successfully for Claude Code!"
3070
- echo ""
3071
- echo " 33 commands | 20 agents | 36 skills | 9 hooks | 7 modes"
3072
- echo ""
3073
- do_quickstart
3074
- echo ""
3075
- echo " To activate: exit Claude Code (ctrl+c) and relaunch 'claude' in this directory."
2244
+ # Write hook — calls cleanup script in auto mode, never blocks the merge
2245
+ cat > "$hook_path" <<'GITHOOK'
2246
+ #!/bin/bash
2247
+ # post-merge CortexHawk auto-cleanup after git merge
2248
+ # Installed by CortexHawk. Remove this file to disable.
2249
+ if [ -f ".claude/scripts/post-merge-cleanup.sh" ]; then
2250
+ bash ".claude/scripts/post-merge-cleanup.sh" --auto 2>/dev/null || true
2251
+ fi
2252
+ exit 0
2253
+ GITHOOK
2254
+ chmod +x "$hook_path"
2255
+ green " → native git post-merge hook installed (.git/hooks/post-merge)"
3076
2256
  }
3077
2257
 
3078
2258
  # --- install_kimi() ---
@@ -3330,7 +2510,8 @@ do_test_hooks() {
3330
2510
  echo ""
3331
2511
 
3332
2512
  local ok=0 fail=0
3333
- local tmpfile="/tmp/.cortexhawk-hooktest-$$"
2513
+ local tmpfile
2514
+ tmpfile=$(mktemp /tmp/.cortexhawk-hooktest-XXXXXX)
3334
2515
  echo "test file content" > "$tmpfile"
3335
2516
 
3336
2517
  for hook in "$hooks_dir"/*.sh; do
@@ -3668,9 +2849,11 @@ do_publish_skill() {
3668
2849
  # Create GitHub repo
3669
2850
  echo ""
3670
2851
  echo " Creating repository..."
2852
+ local repo_exists=false
3671
2853
  if gh repo view "$gh_user/$repo_name" &>/dev/null; then
3672
2854
  echo " Repository already exists: $gh_user/$repo_name"
3673
2855
  echo " Pushing latest files..."
2856
+ repo_exists=true
3674
2857
  else
3675
2858
  gh repo create "$repo_name" --public --description "CortexHawk skill: $skill_desc" --clone=false
3676
2859
  fi
@@ -3694,7 +2877,11 @@ do_publish_skill() {
3694
2877
  git commit --quiet -m "feat: publish $skill_name skill"
3695
2878
  git branch -M main
3696
2879
  git remote add origin "https://github.com/$gh_user/$repo_name.git"
3697
- git push -u origin main --force --quiet 2>/dev/null
2880
+ if [ "$repo_exists" = true ]; then
2881
+ git push -u origin main --quiet 2>/dev/null
2882
+ else
2883
+ git push -u origin main --force --quiet 2>/dev/null
2884
+ fi
3698
2885
  cd - >/dev/null
3699
2886
 
3700
2887
  # Cleanup
@@ -3729,7 +2916,8 @@ do_publish_skill() {
3729
2916
 
3730
2917
  # --- do_demo() ---
3731
2918
  do_demo() {
3732
- local demo_dir="/tmp/cortexhawk-demo-$$"
2919
+ local demo_dir
2920
+ demo_dir=$(mktemp -d /tmp/cortexhawk-demo-XXXXXX)
3733
2921
  mkdir -p "$demo_dir"
3734
2922
 
3735
2923
  echo ""
@@ -3851,6 +3039,13 @@ UTILSJS
3851
3039
  echo ""
3852
3040
  }
3853
3041
 
3042
+ # --- Source extracted modules ---
3043
+ source "$SCRIPT_DIR/scripts/install-claude.sh"
3044
+ source "$SCRIPT_DIR/scripts/update.sh"
3045
+ source "$SCRIPT_DIR/scripts/snapshot.sh"
3046
+ source "$SCRIPT_DIR/scripts/restore.sh"
3047
+ source "$SCRIPT_DIR/scripts/doctor.sh"
3048
+
3854
3049
  # --- Dispatcher ---
3855
3050
  if [ "$DEMO_MODE" = true ]; then
3856
3051
  do_demo
@@ -3892,6 +3087,8 @@ elif [ -n "$ENABLE_HOOK" ]; then
3892
3087
  do_toggle_hook "$ENABLE_HOOK" "enable"
3893
3088
  elif [ -n "$DISABLE_HOOK" ]; then
3894
3089
  do_toggle_hook "$DISABLE_HOOK" "disable"
3090
+ elif [ "$POST_MERGE_HOOK_MODE" = true ]; then
3091
+ install_git_post_merge_hook "$(pwd)"
3895
3092
  elif [ -n "$SEARCH_KEYWORD" ]; then
3896
3093
  do_search_skills "$SEARCH_KEYWORD"
3897
3094
  elif [ -n "$ADD_SKILL_URL" ]; then