cortexhawk 3.1.1 → 3.3.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/install.sh CHANGED
@@ -7,7 +7,24 @@ 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
20
+ # Strip double quotes
10
21
  value="${value%\"}" && value="${value#\"}"
22
+ # Strip single quotes
23
+ value="${value%'}" && value="${value#'}"
24
+ # Strip inline comments (space + # marks start of comment)
25
+ value="$(printf '%s\n' "$value" | sed 's/[[:space:]]#.*$//')"
26
+ # Strip trailing whitespace
27
+ value="${value%"${value##*[! ]}"}"
11
28
  export "$key=$value" 2>/dev/null || true
12
29
  done < .env
13
30
  fi
@@ -20,6 +37,15 @@ get_version() {
20
37
  grep -m1 '## \[' "$SCRIPT_DIR/CHANGELOG.md" | sed 's/.*\[\([^]]*\)\].*/\1/'
21
38
  }
22
39
 
40
+ # Portable sed -i (GNU vs BSD)
41
+ sed_inplace() {
42
+ if sed --version 2>/dev/null | grep -q GNU; then
43
+ sed -i "$@"
44
+ else
45
+ sed -i '' "$@"
46
+ fi
47
+ }
48
+
23
49
  compute_checksum() {
24
50
  local file="$1"
25
51
  if command -v sha256sum >/dev/null 2>&1; then
@@ -106,6 +132,7 @@ print_usage() {
106
132
  echo " --dry-run Simulate install/update without writing files"
107
133
  echo " --test-hooks Dry-run all hooks with synthetic inputs"
108
134
  echo " --no-scan Skip post-install security audit"
135
+ echo " --version, -v Show CortexHawk version"
109
136
  echo " --help, -h Show this help"
110
137
  }
111
138
 
@@ -144,15 +171,41 @@ STATS_MODE=false
144
171
  PUBLISH_SKILL_PATH=""
145
172
  CHECK_UPDATE_MODE=false
146
173
  DEMO_MODE=false
174
+ TRUST_SKILL=false
147
175
  MAX_SNAPSHOTS=10
148
176
 
177
+ # === Component Registry ===
178
+ # Single source of truth for all CortexHawk components.
179
+ # Format: "name:executable"
180
+ # - executable = "yes" runs chmod +x *.sh after copy (hooks, scripts)
181
+ # - executable = "no" for markdown-only components (agents, commands, modes)
182
+ # - skills handled specially via copy_skills()/sync_skills_update() for profile filtering
183
+ # Used by: copy_all_components(), sync_all_components(), count_component_files()
184
+ COMPONENTS=(
185
+ "agents:no"
186
+ "commands:no"
187
+ "skills:no"
188
+ "hooks:yes"
189
+ "modes:no"
190
+ "mcp:no"
191
+ "scripts:yes"
192
+ )
193
+
149
194
  while [ $# -gt 0 ]; do
150
195
  case "$1" in
151
196
  --target)
197
+ if [ -z "${2:-}" ]; then
198
+ echo "Error: --target requires a value (claude|kimi|codex|auto|all)"
199
+ exit 1
200
+ fi
152
201
  TARGET_CLI="$2"
153
202
  shift 2
154
203
  ;;
155
204
  --profile)
205
+ if [ -z "${2:-}" ]; then
206
+ echo "Error: --profile requires a value (fullstack|api|data)"
207
+ exit 1
208
+ fi
156
209
  PROFILE="$2"
157
210
  shift 2
158
211
  ;;
@@ -218,6 +271,10 @@ while [ $# -gt 0 ]; do
218
271
  ;;
219
272
  --restore)
220
273
  RESTORE_MODE=true
274
+ if [ -z "${2:-}" ]; then
275
+ echo "Error: --restore requires a snapshot path or --latest"
276
+ exit 1
277
+ fi
221
278
  SNAPSHOT_FILE="$2"
222
279
  if [ "$SNAPSHOT_FILE" = "--latest" ]; then
223
280
  if [ "$GLOBAL" = true ]; then
@@ -271,6 +328,10 @@ while [ $# -gt 0 ]; do
271
328
  DEMO_MODE=true
272
329
  shift
273
330
  ;;
331
+ --trust)
332
+ TRUST_SKILL=true
333
+ shift
334
+ ;;
274
335
  --publish-skill)
275
336
  PUBLISH_SKILL_PATH="$2"
276
337
  if [ -z "$PUBLISH_SKILL_PATH" ]; then
@@ -323,6 +384,10 @@ while [ $# -gt 0 ]; do
323
384
  fi
324
385
  shift 2
325
386
  ;;
387
+ --version|-v)
388
+ echo "CortexHawk $(get_version)"
389
+ exit 0
390
+ ;;
326
391
  --help|-h)
327
392
  print_usage
328
393
  exit 0
@@ -368,14 +433,6 @@ if [ "$RESTORE_MODE" = true ] && { [ "$INIT_MODE" = true ] || [ "$UPDATE_MODE" =
368
433
  echo "Error: --restore cannot be combined with --init or --update"
369
434
  exit 1
370
435
  fi
371
- if [ "$TARGET_CLI" = "all" ] && [ "$INIT_MODE" = true ]; then
372
- echo "Error: --target all cannot be combined with --init (run --init per target instead)"
373
- exit 1
374
- fi
375
- if [ "$TARGET_CLI" = "auto" ] && [ "$INIT_MODE" = true ]; then
376
- echo "Error: --target auto cannot be combined with --init (run --init per target instead)"
377
- exit 1
378
- fi
379
436
  if [ -n "$ADD_SKILL_URL" ] && { [ "$INIT_MODE" = true ] || [ "$UPDATE_MODE" = true ] || [ "$SNAPSHOT_MODE" = true ] || [ "$RESTORE_MODE" = true ]; }; then
380
437
  echo "Error: --add-skill cannot be combined with --init, --update, --snapshot, or --restore"
381
438
  exit 1
@@ -430,7 +487,7 @@ if [ -n "$PACK_NAME" ]; then
430
487
  exit 1
431
488
  fi
432
489
  _skills_csv=$(echo "$_row" | awk -F'|' '{print $3}' | sed 's/^ *//;s/ *$//')
433
- _description=$(echo "$_row" | awk -F'|' '{print $4}' | sed 's/^ *//;s/ *$//')
490
+ _description=$(echo "$_row" | awk -F'|' '{print $4}' | sed 's/^ *//;s/ *$//' | sed 's/"/\\"/g')
434
491
  _tmp_profile=$(mktemp /tmp/cortexhawk-pack-XXXXXX.json)
435
492
  {
436
493
  echo '{'
@@ -480,12 +537,17 @@ detect_installed_clis() {
480
537
  copy_skills() {
481
538
  local target_dir="$1"
482
539
  local profile="$2"
540
+ local source_dir="${3:-$SCRIPT_DIR}"
483
541
  if [ -z "$profile" ]; then
484
- cp -r "$SCRIPT_DIR/skills/"* "$target_dir/skills/" 2>/dev/null || true
542
+ cp -r "$source_dir/skills/"* "$target_dir/skills/" 2>/dev/null || true
485
543
  else
486
544
  grep '"[a-z]' "$PROFILE_FILE" | sed 's/.*"\([a-z][^"]*\)".*/\1/' | grep '/' | while read -r skill; do
487
- local src="$SCRIPT_DIR/skills/$skill"
545
+ local src="$source_dir/skills/$skill"
488
546
  local dest="$target_dir/skills/$skill"
547
+ if [ ! -d "$src" ]; then
548
+ yellow " Warning: skill '$skill' not found in source — skipping"
549
+ continue
550
+ fi
489
551
  mkdir -p "$(dirname "$dest")"
490
552
  cp -r "$src" "$dest" 2>/dev/null || true
491
553
  done
@@ -545,6 +607,9 @@ convert_modes_to_skills() {
545
607
  done
546
608
  }
547
609
 
610
+ # NOTE: profiles/*.json contain an "mcp" field listing recommended servers, but install.sh
611
+ # currently installs all mcp/*.json regardless of profile. Per-profile MCP filtering is planned
612
+ # for a future phase of #137 (install.sh modularization).
548
613
  # merge_mcp_json(output_file) — merges mcp/*.json into single JSON file
549
614
  merge_mcp_json() {
550
615
  local output_file="$1"
@@ -553,7 +618,7 @@ merge_mcp_json() {
553
618
  [ -f "$mcp_file" ] || continue
554
619
  local server_name command_val args_val entry
555
620
  server_name=$(basename "$mcp_file" .json)
556
- command_val=$(grep '"command"' "$mcp_file" | sed 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')
621
+ command_val=$(grep '"command"' "$mcp_file" | sed 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' | sed 's/"/\\"/g')
557
622
  args_val=$(grep '"args"' "$mcp_file" | sed 's/.*"args"[[:space:]]*:[[:space:]]*\(\[.*\]\).*/\1/')
558
623
  entry=" \"$server_name\": {\n \"command\": \"$command_val\",\n \"args\": $args_val\n }"
559
624
  if [ -n "$entries" ]; then
@@ -594,11 +659,34 @@ update_gitignore() {
594
659
  touch "$gitignore"
595
660
  fi
596
661
 
597
- # Auto-add target directory (always)
598
- if ! grep -qx "$target_dir/" "$gitignore" 2>/dev/null; then
599
- echo "" >> "$gitignore"
600
- echo "# CortexHawk" >> "$gitignore"
601
- echo "$target_dir/" >> "$gitignore"
662
+ # Ensure essential entries are present (disable glob to keep *.log literal)
663
+ local essentials="node_modules/ __pycache__/ .env .env.local *.log dist/ build/ .DS_Store"
664
+ local added_count=0
665
+ set -f
666
+ for entry in $essentials; do
667
+ if ! grep -qxF "$entry" "$gitignore" 2>/dev/null; then
668
+ echo "$entry" >> "$gitignore"
669
+ added_count=$((added_count + 1))
670
+ fi
671
+ done
672
+ set +f
673
+ if [ "$added_count" -gt 0 ]; then
674
+ green " Added $added_count essential entries to .gitignore (.env, node_modules/, etc.)"
675
+ fi
676
+
677
+ # Auto-add target directory (always), group under single header
678
+ if ! grep -qxF "$target_dir/" "$gitignore" 2>/dev/null; then
679
+ if grep -q "# CortexHawk" "$gitignore" 2>/dev/null; then
680
+ # Append under the first existing header only
681
+ local header_line
682
+ header_line=$(grep -n "# CortexHawk" "$gitignore" | head -1 | cut -d: -f1)
683
+ sed_inplace "${header_line}a\\
684
+ $target_dir/" "$gitignore"
685
+ else
686
+ echo "" >> "$gitignore"
687
+ echo "# CortexHawk" >> "$gitignore"
688
+ echo "$target_dir/" >> "$gitignore"
689
+ fi
602
690
  green " Added $target_dir/ to .gitignore"
603
691
  fi
604
692
 
@@ -606,19 +694,69 @@ update_gitignore() {
606
694
  if [ -d "$project_root/docs" ] && ! grep -qx "docs/" "$gitignore" 2>/dev/null; then
607
695
  echo ""
608
696
  echo " docs/ contains agent outputs (brainstorms, plans, research)."
609
- read -r -p " Track docs/ in git? [Y/n]: " docs_choice
697
+ if [ -t 0 ]; then
698
+ read -r -p " Track docs/ in git? [Y/n]: " docs_choice
699
+ else
700
+ docs_choice="Y"
701
+ fi
610
702
  case "$docs_choice" in
611
703
  [nN]*)
612
704
  echo "docs/" >> "$gitignore"
613
705
  green " Added docs/ to .gitignore"
614
706
  ;;
615
707
  *)
616
- green " docs/ will be tracked in git"
708
+ green " docs/ will be tracked in git (default)"
617
709
  ;;
618
710
  esac
619
711
  fi
620
712
  }
621
713
 
714
+ # --- setup_templates() ---
715
+ # Detects existing PR/commit templates; generates CortexHawk defaults if missing
716
+ # Args: $1=project_root
717
+ setup_templates() {
718
+ local project_root="$1"
719
+
720
+ [ "$GLOBAL" = true ] && return
721
+
722
+ # PR template
723
+ local pr_template=""
724
+ for path in ".github/PULL_REQUEST_TEMPLATE.md" ".github/pull_request_template.md" "docs/pull_request_template.md"; do
725
+ if [ -f "$project_root/$path" ]; then
726
+ pr_template="$path"
727
+ break
728
+ fi
729
+ done
730
+ # Also check template directory
731
+ if [ -z "$pr_template" ] && [ -d "$project_root/.github/PULL_REQUEST_TEMPLATE" ]; then
732
+ pr_template=".github/PULL_REQUEST_TEMPLATE/"
733
+ fi
734
+
735
+ if [ -n "$pr_template" ]; then
736
+ green " PR template found: $pr_template"
737
+ else
738
+ mkdir -p "$project_root/.github"
739
+ cp "$SCRIPT_DIR/templates/github/PULL_REQUEST_TEMPLATE.md" "$project_root/.github/PULL_REQUEST_TEMPLATE.md"
740
+ green " PR template created: .github/PULL_REQUEST_TEMPLATE.md"
741
+ fi
742
+
743
+ # Commit template
744
+ local commit_template=""
745
+ for path in ".gitmessage" ".github/gitmessage" ".github/commit-template"; do
746
+ if [ -f "$project_root/$path" ]; then
747
+ commit_template="$path"
748
+ break
749
+ fi
750
+ done
751
+
752
+ if [ -n "$commit_template" ]; then
753
+ green " Commit template found: $commit_template"
754
+ else
755
+ cp "$SCRIPT_DIR/templates/github/gitmessage" "$project_root/.gitmessage"
756
+ green " Commit template created: .gitmessage"
757
+ fi
758
+ }
759
+
622
760
  # --- run_audit() ---
623
761
  run_audit() {
624
762
  local project_root="$1"
@@ -641,11 +779,12 @@ generate_hooks_config() {
641
779
  fi
642
780
 
643
781
  if ! command -v python3 >/dev/null 2>&1; then
782
+ yellow " Warning: python3 not found — hooks config will use static fallback" >&2
644
783
  return 1
645
784
  fi
646
785
 
647
- python3 << PYEOF
648
- import re, json, sys
786
+ python3 - "$compose_file" "$hooks_dir" << 'PYEOF'
787
+ import re, json, sys, shlex
649
788
 
650
789
  def parse_compose(path, hooks_dir):
651
790
  """Parse compose.yml and generate Claude Code hooks JSON."""
@@ -679,10 +818,13 @@ def parse_compose(path, hooks_dir):
679
818
  # Hook item
680
819
  elif line.strip().startswith('- '):
681
820
  hook_name = line.strip()[2:].strip()
821
+ if not re.match(r'^[a-zA-Z0-9_-]+$', hook_name):
822
+ print(f"Warning: skipping invalid hook name: {hook_name}", file=sys.stderr)
823
+ continue
682
824
  hook_path = f"{hooks_dir}/{hook_name}.sh"
683
825
 
684
826
  # Build command — hooks read stdin JSON (Claude Code protocol)
685
- cmd = f'bash {hook_path}'
827
+ cmd = f'bash {shlex.quote(hook_path)}'
686
828
 
687
829
  hook_entry = {"type": "command", "command": cmd}
688
830
 
@@ -708,7 +850,7 @@ def parse_compose(path, hooks_dir):
708
850
  return result
709
851
 
710
852
  try:
711
- result = parse_compose("$compose_file", "$hooks_dir")
853
+ result = parse_compose(sys.argv[1], sys.argv[2])
712
854
  print(json.dumps(result, indent=2))
713
855
  except Exception as e:
714
856
  print(f"Error: {e}", file=sys.stderr)
@@ -786,7 +928,8 @@ update_source_git() {
786
928
  }
787
929
 
788
930
  update_source_release() {
789
- local tmp_dir="/tmp/cortexhawk-update-$$"
931
+ local tmp_dir
932
+ tmp_dir=$(mktemp -d /tmp/cortexhawk-update-XXXXXX)
790
933
  local repo_url
791
934
  repo_url=$(get_source_url)
792
935
  if [ -z "$repo_url" ] && [ -f "$TARGET/.cortexhawk-manifest" ]; then
@@ -797,13 +940,19 @@ update_source_release() {
797
940
  echo "Set CORTEXHAWK_REPO environment variable and retry"
798
941
  exit 1
799
942
  fi
800
- mkdir -p "$tmp_dir"
801
943
  echo " Downloading from $repo_url..."
802
- if ! curl -sL "$repo_url/archive/refs/heads/main.tar.gz" | tar xz -C "$tmp_dir" --strip-components=1; then
944
+ local tarball="$tmp_dir/update.tar.gz"
945
+ if ! curl -sL "$repo_url/archive/refs/heads/main.tar.gz" -o "$tarball"; then
803
946
  echo "Error: failed to download latest release"
804
947
  rm -rf "$tmp_dir"
805
948
  exit 1
806
949
  fi
950
+ if ! tar xzf "$tarball" -C "$tmp_dir" --strip-components=1; then
951
+ echo "Error: failed to extract update archive"
952
+ rm -rf "$tmp_dir"
953
+ exit 1
954
+ fi
955
+ rm -f "$tarball"
807
956
  SCRIPT_DIR="$tmp_dir"
808
957
  UPDATE_CLEANUP_DIR="$tmp_dir"
809
958
  }
@@ -995,6 +1144,48 @@ sync_skills_update() {
995
1144
  fi
996
1145
  }
997
1146
 
1147
+ # === Generic Component Operations ===
1148
+ # These use the COMPONENTS array to avoid hardcoding directory lists.
1149
+
1150
+ # copy_all_components(source_dir, target_dir, profile)
1151
+ copy_all_components() {
1152
+ local src="$1" dst="$2" profile="$3"
1153
+ for entry in "${COMPONENTS[@]}"; do
1154
+ IFS=':' read -r name exec_flag <<< "$entry"
1155
+ [ -d "$src/$name" ] || continue
1156
+ mkdir -p "$dst/$name"
1157
+ if [ "$name" = "skills" ] && [ -n "$profile" ]; then
1158
+ copy_skills "$dst" "$profile" "$src"
1159
+ else
1160
+ cp -r "$src/$name/"* "$dst/$name/" 2>/dev/null || true
1161
+ fi
1162
+ [ "$exec_flag" = "yes" ] && chmod +x "$dst/$name/"*.sh 2>/dev/null || true
1163
+ done
1164
+ }
1165
+
1166
+ # sync_all_components(profile)
1167
+ sync_all_components() {
1168
+ local profile="$1"
1169
+ for entry in "${COMPONENTS[@]}"; do
1170
+ IFS=':' read -r name exec_flag <<< "$entry"
1171
+ if [ "$name" = "skills" ]; then
1172
+ sync_skills_update "$profile"
1173
+ else
1174
+ sync_component "$name"
1175
+ fi
1176
+ done
1177
+ }
1178
+
1179
+ # count_component_files(source_dir)
1180
+ count_component_files() {
1181
+ local src="$1"
1182
+ for entry in "${COMPONENTS[@]}"; do
1183
+ IFS=':' read -r name _ <<< "$entry"
1184
+ local c; c=$(find "$src/$name" -type f 2>/dev/null | wc -l | tr -d ' ')
1185
+ printf " %-12s %s files\n" "$name/" "$c"
1186
+ done
1187
+ }
1188
+
998
1189
  do_update() {
999
1190
  # 1. Validate target
1000
1191
  if [ "$TARGET_CLI" != "claude" ]; then
@@ -1143,12 +1334,7 @@ do_update() {
1143
1334
  SYNC_SKIPPED=0
1144
1335
  SYNC_CONFLICTS=0
1145
1336
 
1146
- sync_component "agents"
1147
- sync_component "commands"
1148
- sync_skills_update "$update_profile"
1149
- sync_component "hooks"
1150
- sync_component "modes"
1151
- sync_component "mcp"
1337
+ sync_all_components "$update_profile"
1152
1338
 
1153
1339
  # 6b. Sync agent personas from project root
1154
1340
  local project_root
@@ -1173,33 +1359,46 @@ do_update() {
1173
1359
  fi
1174
1360
 
1175
1361
  if [ "$DRY_RUN" != true ]; then
1176
- # Make hooks executable
1177
- chmod +x "$TARGET/hooks/"*.sh 2>/dev/null || true
1362
+ # Make executable components executable
1363
+ for entry in "${COMPONENTS[@]}"; do
1364
+ IFS=':' read -r name exec_flag <<< "$entry"
1365
+ [ "$exec_flag" = "yes" ] && chmod +x "$TARGET/$name/"*.sh 2>/dev/null || true
1366
+ done
1178
1367
 
1179
- # 7b. Regenerate settings.json hooks section from compose.yml
1368
+ # 7b. Regenerate settings.json hooks + merge new permissions from compose.yml
1180
1369
  if [ -f "$SCRIPT_DIR/hooks/compose.yml" ]; then
1181
1370
  local hooks_json
1182
1371
  hooks_json=$(generate_hooks_config "$SCRIPT_DIR/hooks/compose.yml" ".claude/hooks")
1183
- if [ -n "$hooks_json" ]; then
1184
- python3 << PYEOF
1372
+ if [ -n "$hooks_json" ] && [ "$hooks_json" != "{}" ]; then
1373
+ echo "$hooks_json" | python3 -c "
1185
1374
  import json, sys
1186
-
1187
- # Read current settings.json to preserve permissions
1375
+ hooks = json.load(sys.stdin)
1188
1376
  current = {}
1189
1377
  try:
1190
- with open('$TARGET/settings.json') as f:
1378
+ with open(sys.argv[1]) as f:
1191
1379
  current = json.load(f)
1192
1380
  except:
1193
1381
  pass
1194
-
1195
- # Merge: keep existing permissions, replace hooks
1196
- hooks = json.loads('''$hooks_json''')
1197
1382
  current['hooks'] = hooks
1198
-
1199
- with open('$TARGET/settings.json', 'w') as f:
1383
+ # Merge new permissions from source
1384
+ try:
1385
+ with open(sys.argv[2]) as f:
1386
+ src_perms = json.load(f).get('permissions', {})
1387
+ cur_perms = current.get('permissions', {})
1388
+ for key in ('allow', 'deny'):
1389
+ src_list = src_perms.get(key, [])
1390
+ cur_list = cur_perms.get(key, [])
1391
+ added = [p for p in src_list if p not in cur_list]
1392
+ if added:
1393
+ cur_list.extend(added)
1394
+ cur_perms[key] = cur_list
1395
+ current['permissions'] = cur_perms
1396
+ except:
1397
+ pass
1398
+ with open(sys.argv[1], 'w') as f:
1200
1399
  json.dump(current, f, indent=2)
1201
1400
  f.write('\n')
1202
- PYEOF
1401
+ " "$TARGET/settings.json" "$SCRIPT_DIR/settings.json"
1203
1402
  echo " Regenerated settings.json hooks from compose.yml"
1204
1403
  fi
1205
1404
  fi
@@ -1214,6 +1413,7 @@ PYEOF
1214
1413
  local target_dir_name=".${TARGET_CLI:-claude}"
1215
1414
  update_gitignore "$(dirname "$TARGET")" "$target_dir_name"
1216
1415
  [ "$TARGET_CLI" = "codex" ] && update_gitignore "$(dirname "$TARGET")" ".agents"
1416
+ setup_templates "$(dirname "$TARGET")"
1217
1417
 
1218
1418
  if [ ! -f "$TARGET/git-workflow.conf" ]; then
1219
1419
  GIT_BRANCHING="direct-main"
@@ -1313,12 +1513,10 @@ do_snapshot() {
1313
1513
  git_push=$(grep '^AUTO_PUSH=' "$TARGET/git-workflow.conf" | cut -d= -f2)
1314
1514
  fi
1315
1515
 
1316
- # Read custom profile if applicable
1516
+ # Read custom profile if applicable (use PROFILE_FILE from current run, never glob /tmp/)
1317
1517
  local profile_def="null"
1318
- local custom_profile
1319
- custom_profile=$(ls /tmp/cortexhawk-custom-*.json 2>/dev/null | head -1)
1320
- if [ -n "$custom_profile" ] && [ -f "$custom_profile" ]; then
1321
- profile_def=$(cat "$custom_profile")
1518
+ if [ -n "$PROFILE_FILE" ] && [ -f "$PROFILE_FILE" ]; then
1519
+ profile_def=$(cat "$PROFILE_FILE")
1322
1520
  fi
1323
1521
 
1324
1522
  # Build files checksums from manifest
@@ -1369,7 +1567,7 @@ do_snapshot() {
1369
1567
  [ -n "$git_workflow_content" ] && printf ' "git-workflow.conf": "%s",\n' "$git_workflow_content" >> "$snap_file"
1370
1568
  [ -n "$claude_md_content" ] && printf ' "CLAUDE.md": "%s",\n' "$claude_md_content" >> "$snap_file"
1371
1569
  # Remove trailing comma from last entry
1372
- sed -i '$ s/,$//' "$snap_file"
1570
+ sed_inplace '$ s/,$//' "$snap_file"
1373
1571
  printf ' }\n' >> "$snap_file"
1374
1572
  printf '}\n' >> "$snap_file"
1375
1573
 
@@ -1850,40 +2048,28 @@ do_restore() {
1850
2048
 
1851
2049
  # Reinstall using the standard flow
1852
2050
  echo "Reinstalling CortexHawk components..."
1853
- mkdir -p "$TARGET"/{agents,commands,skills,hooks,modes,mcp}
1854
2051
 
1855
2052
  # Use portable archive files if available, otherwise use source repo
1856
2053
  if [ -n "$portable_files" ] && [ -d "$portable_files" ]; then
1857
2054
  echo " Using files from portable archive..."
1858
- cp -r "$portable_files/agents/"* "$TARGET/agents/" 2>/dev/null || true
1859
- cp -r "$portable_files/commands/"* "$TARGET/commands/" 2>/dev/null || true
1860
- cp -r "$portable_files/skills/"* "$TARGET/skills/" 2>/dev/null || true
1861
- cp -r "$portable_files/hooks/"* "$TARGET/hooks/" 2>/dev/null || true
1862
- cp -r "$portable_files/modes/"* "$TARGET/modes/" 2>/dev/null || true
1863
- cp -r "$portable_files/mcp/"* "$TARGET/mcp/" 2>/dev/null || true
2055
+ copy_all_components "$portable_files" "$TARGET" ""
1864
2056
  else
1865
- cp -r "$SCRIPT_DIR/agents/"* "$TARGET/agents/" 2>/dev/null || true
1866
- cp -r "$SCRIPT_DIR/commands/"* "$TARGET/commands/" 2>/dev/null || true
1867
- copy_skills "$TARGET" "$PROFILE"
1868
- cp -r "$SCRIPT_DIR/hooks/"* "$TARGET/hooks/" 2>/dev/null || true
1869
- cp -r "$SCRIPT_DIR/modes/"* "$TARGET/modes/" 2>/dev/null || true
1870
- cp -r "$SCRIPT_DIR/mcp/"* "$TARGET/mcp/" 2>/dev/null || true
2057
+ copy_all_components "$SCRIPT_DIR" "$TARGET" "$PROFILE"
1871
2058
  fi
1872
- chmod +x "$TARGET/hooks/"*.sh 2>/dev/null || true
1873
2059
 
1874
2060
  # Restore settings.json from snapshot
1875
2061
  if command -v python3 >/dev/null 2>&1; then
1876
2062
  python3 -c "
1877
2063
  import json, sys
1878
- with open('$snap_file') as f:
2064
+ with open(sys.argv[1]) as f:
1879
2065
  snap = json.load(f)
1880
2066
  settings = snap.get('settings')
1881
2067
  if settings is not None:
1882
- with open('$TARGET/settings.json', 'w') as f:
2068
+ with open(sys.argv[2], 'w') as f:
1883
2069
  json.dump(settings, f, indent=2)
1884
2070
  f.write('\n')
1885
2071
  print(' Restored settings.json')
1886
- "
2072
+ " "$snap_file" "$TARGET/settings.json"
1887
2073
  else
1888
2074
  echo " Warning: python3 not found — settings.json not restored from snapshot"
1889
2075
  fi
@@ -1909,23 +2095,24 @@ if settings is not None:
1909
2095
  if grep -q '"file_contents"' "$snap_file" && command -v python3 >/dev/null 2>&1; then
1910
2096
  python3 -c "
1911
2097
  import json, base64, sys, os
1912
- with open('$snap_file') as f:
2098
+ snap_file, target_dir = sys.argv[1], sys.argv[2]
2099
+ with open(snap_file) as f:
1913
2100
  snap = json.load(f)
1914
2101
  contents = snap.get('file_contents', {})
1915
2102
  for filename, b64data in contents.items():
1916
2103
  try:
1917
2104
  data = base64.b64decode(b64data).decode('utf-8')
1918
2105
  if filename == 'CLAUDE.md':
1919
- target_path = os.path.dirname('$TARGET') + '/CLAUDE.md'
2106
+ target_path = os.path.dirname(target_dir) + '/CLAUDE.md'
1920
2107
  else:
1921
- target_path = '$TARGET/' + filename
2108
+ target_path = target_dir + '/' + filename
1922
2109
  os.makedirs(os.path.dirname(target_path), exist_ok=True)
1923
2110
  with open(target_path, 'w') as f:
1924
2111
  f.write(data)
1925
2112
  print(f' Restored {filename} (from file_contents)')
1926
2113
  except Exception as e:
1927
2114
  print(f' Warning: could not restore {filename}: {e}', file=sys.stderr)
1928
- "
2115
+ " "$snap_file" "$TARGET"
1929
2116
  fi
1930
2117
 
1931
2118
  # Write new manifest
@@ -2312,14 +2499,14 @@ do_list_hooks() {
2312
2499
  printf " %-20s %-14s %-8s %s\n" "Name" "Event" "Status" "Description"
2313
2500
  printf " %-20s %-14s %-8s %s\n" "----" "-----" "------" "-----------"
2314
2501
  # Parse hooks.json with python3 for reliable JSON handling
2315
- python3 << PYEOF
2316
- import json
2317
- with open("$hooks_json") as f:
2502
+ python3 - "$hooks_json" "$compose_file" << 'PYEOF'
2503
+ import json, sys
2504
+ with open(sys.argv[1]) as f:
2318
2505
  data = json.load(f)
2319
2506
  # Read compose.yml to detect disabled hooks (commented out)
2320
2507
  disabled = set()
2321
2508
  try:
2322
- with open("$compose_file") as f:
2509
+ with open(sys.argv[2]) as f:
2323
2510
  for line in f:
2324
2511
  stripped = line.strip()
2325
2512
  if stripped.startswith("# - "):
@@ -2368,14 +2555,14 @@ do_toggle_hook() {
2368
2555
  echo "Hook '$hook_name' is already disabled"
2369
2556
  return 0
2370
2557
  fi
2371
- sed -i "s/^ - ${hook_name}$/ # - ${hook_name}/" "$compose_file"
2558
+ sed_inplace "s/^ - ${hook_name}$/ # - ${hook_name}/" "$compose_file"
2372
2559
  echo "Disabled hook: $hook_name"
2373
2560
  else
2374
2561
  if grep -q "^ - ${hook_name}$" "$compose_file"; then
2375
2562
  echo "Hook '$hook_name' is already enabled"
2376
2563
  return 0
2377
2564
  fi
2378
- sed -i "s/^ # - ${hook_name}$/ - ${hook_name}/" "$compose_file"
2565
+ sed_inplace "s/^ # - ${hook_name}$/ - ${hook_name}/" "$compose_file"
2379
2566
  echo "Enabled hook: $hook_name"
2380
2567
  fi
2381
2568
  # Regenerate settings.json if target exists
@@ -2383,17 +2570,18 @@ do_toggle_hook() {
2383
2570
  if [ -d "$target" ] && [ -f "$target/settings.json" ]; then
2384
2571
  local hooks_json
2385
2572
  hooks_json=$(generate_hooks_config "$compose_file" ".claude/hooks")
2386
- if [ -n "$hooks_json" ]; then
2387
- python3 << PYEOF
2388
- import json
2389
- with open('$target/settings.json') as f:
2573
+ if [ -n "$hooks_json" ] && [ "$hooks_json" != "{}" ]; then
2574
+ echo "$hooks_json" | python3 -c "
2575
+ import json, sys
2576
+ hooks = json.load(sys.stdin)
2577
+ with open(sys.argv[1]) as f:
2390
2578
  settings = json.load(f)
2391
- settings['hooks'] = json.loads('''$hooks_json''')
2392
- with open('$target/settings.json', 'w') as f:
2579
+ settings['hooks'] = hooks
2580
+ with open(sys.argv[1], 'w') as f:
2393
2581
  json.dump(settings, f, indent=2)
2394
2582
  f.write('\n')
2395
2583
  print(' Regenerated settings.json')
2396
- PYEOF
2584
+ " "$target/settings.json"
2397
2585
  fi
2398
2586
  fi
2399
2587
  }
@@ -2441,17 +2629,18 @@ _search_skillsmp() {
2441
2629
  fi
2442
2630
 
2443
2631
  # Parse JSON response with python3
2444
- python3 << PYEOF
2632
+ python3 - "$tmp_response" "$keyword" << 'PYEOF'
2445
2633
  import json, sys
2446
2634
  try:
2447
- with open("$tmp_response") as f:
2635
+ response_file, keyword = sys.argv[1], sys.argv[2]
2636
+ with open(response_file) as f:
2448
2637
  data = json.load(f)
2449
2638
  container = data.get("data", {})
2450
2639
  skills = container.get("skills", [])
2451
2640
  pagination = container.get("pagination", {})
2452
2641
  total = pagination.get("total", len(skills))
2453
2642
  if not skills:
2454
- print(" No skills found matching '$keyword'")
2643
+ print(f" No skills found matching '{keyword}'")
2455
2644
  print("")
2456
2645
  print(" Try broader terms or check https://skillsmp.com")
2457
2646
  sys.exit(0)
@@ -2474,7 +2663,7 @@ try:
2474
2663
  if len(parts) >= 2:
2475
2664
  print(f" Install: ./install.sh --add-skill {parts[0]}/{parts[1]}")
2476
2665
  break
2477
- print(f" Browse all: https://skillsmp.com/?q=$keyword")
2666
+ print(f" Browse all: https://skillsmp.com/?q={keyword}")
2478
2667
  except Exception as e:
2479
2668
  print(f" Error parsing response: {e}")
2480
2669
  print(" Falling back to local registry")
@@ -2593,10 +2782,17 @@ do_add_skill() {
2593
2782
  exit 1
2594
2783
  fi
2595
2784
 
2596
- # Security warning if scripts present
2785
+ # Security gate: block skills with executable scripts unless --trust is used
2597
2786
  if find "$tmp_dir/repo" -name "*.sh" -o -name "*.py" 2>/dev/null | grep -q .; then
2598
- echo " Warning: this skill contains executable scripts"
2599
- echo " Review them before use: $skill_dir/scripts/"
2787
+ if [ "${TRUST_SKILL:-false}" = true ]; then
2788
+ yellow " Warning: this skill contains executable scripts — installing with --trust"
2789
+ else
2790
+ echo " ERROR: This skill contains executable scripts (.sh/.py)."
2791
+ echo " Re-run with --trust to install: install.sh --add-skill $ADD_SKILL_URL --trust"
2792
+ echo " Review the source first: $ADD_SKILL_URL"
2793
+ rm -rf "$tmp_dir"
2794
+ exit 1
2795
+ fi
2600
2796
  fi
2601
2797
 
2602
2798
  # Create community directory if needed
@@ -2717,7 +2913,8 @@ do_team_install() {
2717
2913
 
2718
2914
  # Generate temporary profile JSON from skills list
2719
2915
  if [ -n "$TEAM_SKILLS" ]; then
2720
- local tmp_profile="/tmp/cortexhawk-team-$$.json"
2916
+ local tmp_profile
2917
+ tmp_profile=$(mktemp /tmp/cortexhawk-team-XXXXXX.json)
2721
2918
  printf '{\n "name": "team",\n "skills": [\n' > "$tmp_profile"
2722
2919
  local first=true
2723
2920
  while IFS= read -r skill; do
@@ -2808,12 +3005,7 @@ install_claude() {
2808
3005
  echo " Profile: ${PROFILE:-all}"
2809
3006
  echo ""
2810
3007
  echo "Would install:"
2811
- for comp in agents commands hooks modes mcp; do
2812
- local c; c=$(find "$SCRIPT_DIR/$comp" -type f 2>/dev/null | wc -l | tr -d ' ')
2813
- printf " %-12s %s files\n" "$comp/" "$c"
2814
- done
2815
- local sc; sc=$(find "$SCRIPT_DIR/skills" -type f 2>/dev/null | wc -l | tr -d ' ')
2816
- printf " %-12s %s files\n" "skills/" "$sc"
3008
+ count_component_files "$SCRIPT_DIR"
2817
3009
  echo " settings.json"
2818
3010
  [ ! -f "$(pwd)/CLAUDE.md" ] && echo " CLAUDE.md"
2819
3011
  echo ""
@@ -2823,9 +3015,8 @@ install_claude() {
2823
3015
 
2824
3016
  echo "Installing for Claude Code to project: $TARGET"
2825
3017
 
2826
- mkdir -p "$TARGET"/{agents,commands,skills,hooks,modes,mcp}
3018
+ copy_all_components "$SCRIPT_DIR" "$TARGET" "$PROFILE"
2827
3019
 
2828
- cp -r "$SCRIPT_DIR/agents/"* "$TARGET/agents/" 2>/dev/null || true
2829
3020
  # Copy agent personas from project root if present
2830
3021
  local project_root
2831
3022
  project_root="$(dirname "$TARGET")"
@@ -2835,32 +3026,79 @@ install_claude() {
2835
3026
  persona_count=$(find "$project_root/.cortexhawk-agents" -name "*.md" -type f 2>/dev/null | wc -l | tr -d ' ')
2836
3027
  [ "$persona_count" -gt 0 ] && echo " Loaded $persona_count agent persona(s) from .cortexhawk-agents/"
2837
3028
  fi
2838
- cp -r "$SCRIPT_DIR/commands/"* "$TARGET/commands/" 2>/dev/null || true
2839
- copy_skills "$TARGET" "$PROFILE"
2840
- cp -r "$SCRIPT_DIR/hooks/"* "$TARGET/hooks/" 2>/dev/null || true
2841
- cp -r "$SCRIPT_DIR/modes/"* "$TARGET/modes/" 2>/dev/null || true
2842
- cp -r "$SCRIPT_DIR/mcp/"* "$TARGET/mcp/" 2>/dev/null || true
2843
3029
 
3030
+ local hooks_json
3031
+ hooks_json=$(generate_hooks_config "$SCRIPT_DIR/hooks/compose.yml" ".claude/hooks")
2844
3032
  if [ ! -f "$TARGET/settings.json" ]; then
2845
- # Generate settings.json with hooks from compose.yml
2846
- local hooks_json
2847
- hooks_json=$(generate_hooks_config "$SCRIPT_DIR/hooks/compose.yml" ".claude/hooks")
3033
+ # Fresh install: generate settings.json from scratch
2848
3034
  if [ -n "$hooks_json" ] && [ "$hooks_json" != "{}" ]; then
2849
- # Build settings.json with generated hooks
2850
- python3 -c "
2851
- import json
2852
- permissions = $(cat "$SCRIPT_DIR/settings.json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('permissions',{})))")
2853
- hooks = $hooks_json
2854
- with open('$TARGET/settings.json', 'w') as f:
3035
+ echo "$hooks_json" | python3 -c "
3036
+ import json, sys
3037
+ hooks = json.load(sys.stdin)
3038
+ with open(sys.argv[1]) as f:
3039
+ permissions = json.load(f).get('permissions', {})
3040
+ with open(sys.argv[2], 'w') as f:
2855
3041
  json.dump({'permissions': permissions, 'hooks': hooks}, f, indent=2)
2856
3042
  f.write('\n')
2857
- "
3043
+ " "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
2858
3044
  echo " Generated settings.json from hooks/compose.yml"
2859
3045
  else
2860
3046
  cp "$SCRIPT_DIR/settings.json" "$TARGET/settings.json"
2861
3047
  fi
2862
3048
  else
2863
- echo "settings.json already exists skipping (check manually for updates)"
3049
+ # Merge: preserve user customizations, add new hooks + permissions
3050
+ python3 -c "
3051
+ import json, sys, shutil, os
3052
+
3053
+ raw = sys.stdin.read().strip()
3054
+ hooks = json.loads(raw) if raw else {}
3055
+
3056
+ # Load current settings (tolerate corrupted JSON)
3057
+ try:
3058
+ with open(sys.argv[1]) as f:
3059
+ current = json.load(f)
3060
+ except Exception:
3061
+ backup = sys.argv[1] + '.bak'
3062
+ if os.path.isfile(sys.argv[1]):
3063
+ shutil.copy2(sys.argv[1], backup)
3064
+ print(f' Warning: settings.json corrupted — backed up to {os.path.basename(backup)}', file=sys.stderr)
3065
+ current = {}
3066
+
3067
+ try:
3068
+ with open(sys.argv[2]) as f:
3069
+ source = json.load(f)
3070
+ except Exception:
3071
+ source = {}
3072
+
3073
+ changes = []
3074
+
3075
+ # Merge hooks (regenerate from compose.yml)
3076
+ if hooks and hooks != {}:
3077
+ current['hooks'] = hooks
3078
+ changes.append('hooks regenerated')
3079
+
3080
+ # Merge permissions (union: keep user additions + add new from source)
3081
+ src_perms = source.get('permissions', {})
3082
+ cur_perms = current.get('permissions', {})
3083
+ for key in ('allow', 'deny'):
3084
+ src_list = src_perms.get(key, [])
3085
+ cur_list = cur_perms.get(key, [])
3086
+ added = [p for p in src_list if p not in cur_list]
3087
+ if added:
3088
+ cur_list.extend(added)
3089
+ changes.append(f'{len(added)} new {key} permission(s)')
3090
+ cur_perms[key] = cur_list
3091
+ current['permissions'] = cur_perms
3092
+
3093
+ with open(sys.argv[1], 'w') as f:
3094
+ json.dump(current, f, indent=2)
3095
+ f.write('\n')
3096
+
3097
+ if changes:
3098
+ print(' Merged settings.json: ' + ', '.join(changes))
3099
+ else:
3100
+ print(' settings.json up to date — no merge needed')
3101
+ " "$TARGET/settings.json" "$SCRIPT_DIR/settings.json" <<< "${hooks_json:-}"
2864
3102
  fi
2865
3103
 
2866
3104
  PROJECT_ROOT="$(dirname "$TARGET")"
@@ -2897,11 +3135,12 @@ with open('$TARGET/settings.json', 'w') as f:
2897
3135
 
2898
3136
  run_audit "$(dirname "$TARGET")"
2899
3137
  update_gitignore "$(dirname "$TARGET")" ".claude"
3138
+ setup_templates "$(dirname "$TARGET")"
2900
3139
 
2901
3140
  echo ""
2902
3141
  echo "CortexHawk installed successfully for Claude Code!"
2903
3142
  echo ""
2904
- echo " 32 commands | 20 agents | 36 skills | 9 hooks | 7 modes"
3143
+ echo " 33 commands | 20 agents | 36 skills | 9 hooks | 7 modes"
2905
3144
  echo ""
2906
3145
  do_quickstart
2907
3146
  echo ""
@@ -3163,7 +3402,8 @@ do_test_hooks() {
3163
3402
  echo ""
3164
3403
 
3165
3404
  local ok=0 fail=0
3166
- local tmpfile="/tmp/.cortexhawk-hooktest-$$"
3405
+ local tmpfile
3406
+ tmpfile=$(mktemp /tmp/.cortexhawk-hooktest-XXXXXX)
3167
3407
  echo "test file content" > "$tmpfile"
3168
3408
 
3169
3409
  for hook in "$hooks_dir"/*.sh; do
@@ -3501,9 +3741,11 @@ do_publish_skill() {
3501
3741
  # Create GitHub repo
3502
3742
  echo ""
3503
3743
  echo " Creating repository..."
3744
+ local repo_exists=false
3504
3745
  if gh repo view "$gh_user/$repo_name" &>/dev/null; then
3505
3746
  echo " Repository already exists: $gh_user/$repo_name"
3506
3747
  echo " Pushing latest files..."
3748
+ repo_exists=true
3507
3749
  else
3508
3750
  gh repo create "$repo_name" --public --description "CortexHawk skill: $skill_desc" --clone=false
3509
3751
  fi
@@ -3527,7 +3769,11 @@ do_publish_skill() {
3527
3769
  git commit --quiet -m "feat: publish $skill_name skill"
3528
3770
  git branch -M main
3529
3771
  git remote add origin "https://github.com/$gh_user/$repo_name.git"
3530
- git push -u origin main --force --quiet 2>/dev/null
3772
+ if [ "$repo_exists" = true ]; then
3773
+ git push -u origin main --quiet 2>/dev/null
3774
+ else
3775
+ git push -u origin main --force --quiet 2>/dev/null
3776
+ fi
3531
3777
  cd - >/dev/null
3532
3778
 
3533
3779
  # Cleanup
@@ -3562,7 +3808,8 @@ do_publish_skill() {
3562
3808
 
3563
3809
  # --- do_demo() ---
3564
3810
  do_demo() {
3565
- local demo_dir="/tmp/cortexhawk-demo-$$"
3811
+ local demo_dir
3812
+ demo_dir=$(mktemp -d /tmp/cortexhawk-demo-XXXXXX)
3566
3813
  mkdir -p "$demo_dir"
3567
3814
 
3568
3815
  echo ""
@@ -3749,7 +3996,6 @@ else
3749
3996
  install_codex
3750
3997
  ;;
3751
3998
  auto)
3752
- local detected
3753
3999
  detected=$(detect_installed_clis)
3754
4000
  if [ -z "$detected" ]; then
3755
4001
  echo "Error: no supported CLI found (claude, kimi, codex)"
@@ -3758,7 +4004,7 @@ else
3758
4004
  fi
3759
4005
  echo "Auto-detected CLIs: $detected"
3760
4006
  echo ""
3761
- local auto_count=0
4007
+ auto_count=0
3762
4008
  for cli in $detected; do
3763
4009
  [ $auto_count -gt 0 ] && echo "" && echo "---" && echo ""
3764
4010
  case "$cli" in