@adityaaria/spark 6.0.15 → 6.0.17

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.
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env bash
2
+ # spark-install.sh — Native self-installer for SPARK
3
+ # Installs skills + hooks for detected coding agents.
4
+ # Zero external runtime dependencies (pure bash + common unix tools).
5
+ #
6
+ # Usage:
7
+ # bash bin/spark-install.sh # project-scope install
8
+ # bash bin/spark-install.sh -g # global-scope install
9
+ # bash bin/spark-install.sh --force # re-install even if already installed
10
+ # bash bin/spark-install.sh --help # show usage
11
+
12
+ set -euo pipefail
13
+
14
+
15
+ # =============================================================================
16
+ # Constants & Colors
17
+ # =============================================================================
18
+
19
+ readonly VERSION="1.0.0"
20
+ readonly LOCK_FILE_NAME=".spark-lock.json"
21
+
22
+ # Colors (disabled if not a TTY)
23
+ if [ -t 1 ]; then
24
+ readonly C_RESET='\033[0m'
25
+ readonly C_BOLD='\033[1m'
26
+ readonly C_GREEN='\033[38;5;46m' # 8-bit neon green
27
+ readonly C_YELLOW='\033[38;5;226m' # 8-bit bright yellow
28
+ readonly C_RED='\033[38;5;196m' # 8-bit bright red
29
+ readonly C_CYAN='\033[38;5;51m' # 8-bit cyan
30
+ readonly C_MAGENTA='\033[38;5;201m' # 8-bit magenta
31
+ readonly C_DIM='\033[2m'
32
+ else
33
+ readonly C_RESET=''
34
+ readonly C_BOLD=''
35
+ readonly C_GREEN=''
36
+ readonly C_YELLOW=''
37
+ readonly C_RED=''
38
+ readonly C_CYAN=''
39
+ readonly C_MAGENTA=''
40
+ readonly C_DIM=''
41
+ fi
42
+
43
+ # Exit codes
44
+ readonly EXIT_SUCCESS=0
45
+ readonly EXIT_NO_AGENTS=1
46
+ readonly EXIT_PERMISSION=2
47
+ readonly EXIT_NO_REPO=3
48
+ readonly EXIT_INSTALL_FAILED=4
49
+
50
+ # =============================================================================
51
+ # Output helpers (8-Bit Theme)
52
+ # =============================================================================
53
+
54
+ info() { printf "${C_CYAN}►${C_RESET} %s\n" "$*"; }
55
+ success() { printf "${C_GREEN}★${C_RESET} %s\n" "$*"; }
56
+ warn() { printf "${C_YELLOW}▲${C_RESET} %s\n" "$*" >&2; }
57
+ error() { printf "${C_RED}[X]${C_RESET} %s\n" "$*" >&2; }
58
+ step() { printf "${C_MAGENTA}[LVL %s/%s]${C_RESET} %s\n" "$1" "$2" "$3"; }
59
+ header() {
60
+ printf "\n${C_MAGENTA}▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄${C_RESET}\n"
61
+ printf "${C_MAGENTA}█${C_RESET} ${C_BOLD}%-35s${C_RESET} ${C_MAGENTA}█${C_RESET}\n" " $1 "
62
+ printf "${C_MAGENTA}▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀${C_RESET}\n\n"
63
+ }
64
+
65
+ # =============================================================================
66
+ # Argument parsing
67
+ # =============================================================================
68
+
69
+ SCOPE="project"
70
+ FORCE=false
71
+ HELP=false
72
+ DRY_RUN=false
73
+
74
+ parse_args() {
75
+ while [ $# -gt 0 ]; do
76
+ case "$1" in
77
+ -g|--global) SCOPE="global" ;;
78
+ --force) FORCE=true ;;
79
+ --dry-run) DRY_RUN=true ;;
80
+ -h|--help) HELP=true ;;
81
+ *)
82
+ error "Unknown argument: $1"
83
+ echo "Run with --help for usage."
84
+ exit 1
85
+ ;;
86
+ esac
87
+ shift
88
+ done
89
+ }
90
+
91
+ print_help() {
92
+ cat <<'EOF'
93
+
94
+ SPARK Native Installer
95
+
96
+ Usage:
97
+ bash bin/spark-install.sh [options]
98
+
99
+ Options:
100
+ -g, --global Install to global agent config (~/.agent/skills/)
101
+ Default: project scope (./.agent/skills/)
102
+ --force Re-install even if already installed
103
+ --dry-run Show what would be done without making changes
104
+ -h, --help Show this help message
105
+
106
+ Examples:
107
+ bash bin/spark-install.sh # install for detected agents (project scope)
108
+ bash bin/spark-install.sh -g # install globally
109
+ bash bin/spark-install.sh --force -g # force reinstall globally
110
+
111
+ One-liner from scratch:
112
+ git clone https://github.com/adityaaria/SPARK.git && cd SPARK && bash bin/spark-install.sh
113
+
114
+ EOF
115
+ }
116
+
117
+ # =============================================================================
118
+ # Repo resolution & version detection
119
+ # =============================================================================
120
+
121
+ SPARK_ROOT=""
122
+ SPARK_VERSION=""
123
+ SPARK_COMMIT=""
124
+
125
+ resolve_spark_root() {
126
+ # Determine repo root from the script's own location
127
+ local script_dir
128
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
129
+
130
+ # Script lives in bin/, so repo root is one level up
131
+ SPARK_ROOT="$(cd "$script_dir/.." && pwd)"
132
+
133
+ # Validate: skills/ directory must exist
134
+ if [ ! -d "$SPARK_ROOT/skills" ]; then
135
+ error "Cannot find skills/ directory at $SPARK_ROOT"
136
+ error "Are you running this from within the SPARK repo?"
137
+ exit $EXIT_NO_REPO
138
+ fi
139
+
140
+ # Validate: hooks/ directory must exist
141
+ if [ ! -d "$SPARK_ROOT/hooks" ]; then
142
+ error "Cannot find hooks/ directory at $SPARK_ROOT"
143
+ exit $EXIT_NO_REPO
144
+ fi
145
+ }
146
+
147
+ detect_version() {
148
+ # Try git tag first
149
+ if command -v git >/dev/null 2>&1 && [ -d "$SPARK_ROOT/.git" ]; then
150
+ SPARK_COMMIT="$(git -C "$SPARK_ROOT" rev-parse HEAD 2>/dev/null || echo "unknown")"
151
+ SPARK_VERSION="$(git -C "$SPARK_ROOT" describe --tags --always 2>/dev/null || echo "")"
152
+ fi
153
+
154
+ # Fallback: parse version from package.json without jq
155
+ if [ -z "$SPARK_VERSION" ] && [ -f "$SPARK_ROOT/package.json" ]; then
156
+ # Simple grep-based extraction — no jq required
157
+ SPARK_VERSION="$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$SPARK_ROOT/package.json" | head -1 | grep -o '"[^"]*"$' | tr -d '"')"
158
+ fi
159
+
160
+ if [ -z "$SPARK_VERSION" ]; then SPARK_VERSION="unknown"; fi
161
+ if [ -z "$SPARK_COMMIT" ]; then SPARK_COMMIT="unknown"; fi
162
+ }
163
+
164
+ # =============================================================================
165
+ # Skill & hook discovery
166
+ # =============================================================================
167
+
168
+ # Parallel arrays for discovered skills
169
+ SKILL_NAMES=()
170
+ SKILL_PATHS=()
171
+
172
+ discover_skills() {
173
+ local count=0
174
+ for skill_dir in "$SPARK_ROOT"/skills/*/; do
175
+ [ ! -d "$skill_dir" ] && continue
176
+ local skill_md="$skill_dir/SKILL.md"
177
+ if [ ! -f "$skill_md" ]; then
178
+ warn "Skill directory $(basename "$skill_dir") missing SKILL.md — skipping"
179
+ continue
180
+ fi
181
+
182
+ # Validate frontmatter: must have name: and description:
183
+ local has_name=false has_desc=false in_frontmatter=false
184
+ while IFS= read -r line; do
185
+ if [ "$line" = "---" ]; then
186
+ if $in_frontmatter; then
187
+ break # end of frontmatter
188
+ else
189
+ in_frontmatter=true
190
+ continue
191
+ fi
192
+ fi
193
+ if $in_frontmatter; then
194
+ case "$line" in
195
+ name:*) has_name=true ;;
196
+ description:*) has_desc=true ;;
197
+ esac
198
+ fi
199
+ done < "$skill_md"
200
+
201
+ if ! $has_name || ! $has_desc; then
202
+ warn "Skill $(basename "$skill_dir") has invalid frontmatter (missing name/description) — skipping"
203
+ continue
204
+ fi
205
+
206
+ SKILL_NAMES+=("$(basename "$skill_dir")")
207
+ SKILL_PATHS+=("$skill_dir")
208
+ count=$((count + 1))
209
+ done
210
+
211
+ if [ $count -eq 0 ]; then
212
+ error "No valid skills found in $SPARK_ROOT/skills/"
213
+ exit $EXIT_NO_REPO
214
+ fi
215
+
216
+ info "Discovered $count skills: ${SKILL_NAMES[*]}"
217
+ }
218
+
219
+ # =============================================================================
220
+ # Agent detection
221
+ # =============================================================================
222
+
223
+ # Agent registry: parallel arrays
224
+ AGENT_IDS=()
225
+ AGENT_LABELS=()
226
+ AGENT_DETECTED=() # "true" / "false"
227
+ AGENT_REASONS=()
228
+
229
+ register_agent() {
230
+ local id="$1" label="$2" detected="$3" reason="$4"
231
+ AGENT_IDS+=("$id")
232
+ AGENT_LABELS+=("$label")
233
+ AGENT_DETECTED+=("$detected")
234
+ AGENT_REASONS+=("$reason")
235
+ }
236
+
237
+ command_exists() {
238
+ command -v "$1" >/dev/null 2>&1
239
+ }
240
+
241
+ detect_agents() {
242
+ local home="${HOME:-}"
243
+ [ -z "$home" ] && home="$(eval echo ~)"
244
+
245
+ # Claude Code: ~/.claude/ or claude binary or CLAUDE_PLUGIN_ROOT env
246
+ if [ -d "$home/.claude" ] || [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || command_exists claude; then
247
+ local reason=""
248
+ [ -d "$home/.claude" ] && reason="config:~/.claude"
249
+ [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && reason="${reason:+$reason, }env:CLAUDE_PLUGIN_ROOT"
250
+ command_exists claude && reason="${reason:+$reason, }path:claude"
251
+ register_agent "claude" "Claude Code" "true" "$reason"
252
+ else
253
+ register_agent "claude" "Claude Code" "false" ""
254
+ fi
255
+
256
+ # Codex CLI: ~/.codex/ or codex binary
257
+ if [ -d "$home/.codex" ] || command_exists codex; then
258
+ local reason=""
259
+ [ -d "$home/.codex" ] && reason="config:~/.codex"
260
+ command_exists codex && reason="${reason:+$reason, }path:codex"
261
+ register_agent "codex" "Codex CLI" "true" "$reason"
262
+ else
263
+ register_agent "codex" "Codex CLI" "false" ""
264
+ fi
265
+
266
+ # Cursor: ~/.cursor/ or CURSOR_PLUGIN_ROOT env or cursor binary
267
+ if [ -d "$home/.cursor" ] || [ -n "${CURSOR_PLUGIN_ROOT:-}" ] || command_exists cursor; then
268
+ local reason=""
269
+ [ -d "$home/.cursor" ] && reason="config:~/.cursor"
270
+ [ -n "${CURSOR_PLUGIN_ROOT:-}" ] && reason="${reason:+$reason, }env:CURSOR_PLUGIN_ROOT"
271
+ command_exists cursor && reason="${reason:+$reason, }path:cursor"
272
+ register_agent "cursor" "Cursor" "true" "$reason"
273
+ else
274
+ register_agent "cursor" "Cursor" "false" ""
275
+ fi
276
+
277
+ # Kimi: ~/.kimi/ or kimi binary
278
+ if [ -d "$home/.kimi" ] || command_exists kimi; then
279
+ local reason=""
280
+ [ -d "$home/.kimi" ] && reason="config:~/.kimi"
281
+ command_exists kimi && reason="${reason:+$reason, }path:kimi"
282
+ register_agent "kimi" "Kimi Code" "true" "$reason"
283
+ else
284
+ register_agent "kimi" "Kimi Code" "false" ""
285
+ fi
286
+
287
+ # OpenCode: ~/.config/opencode/ or OPENCODE_CONFIG_DIR or opencode binary
288
+ local oc_config="${OPENCODE_CONFIG_DIR:-$home/.config/opencode}"
289
+ if [ -d "$oc_config" ] || command_exists opencode; then
290
+ local reason=""
291
+ [ -d "$oc_config" ] && reason="config:$oc_config"
292
+ [ -n "${OPENCODE_CONFIG_DIR:-}" ] && reason="${reason:+$reason, }env:OPENCODE_CONFIG_DIR"
293
+ command_exists opencode && reason="${reason:+$reason, }path:opencode"
294
+ register_agent "opencode" "OpenCode" "true" "$reason"
295
+ else
296
+ register_agent "opencode" "OpenCode" "false" ""
297
+ fi
298
+
299
+ # Pi: PI_HOME env or pi binary
300
+ if [ -n "${PI_HOME:-}" ] || command_exists pi; then
301
+ local reason=""
302
+ [ -n "${PI_HOME:-}" ] && reason="env:PI_HOME"
303
+ command_exists pi && reason="${reason:+$reason, }path:pi"
304
+ register_agent "pi" "Pi" "true" "$reason"
305
+ else
306
+ register_agent "pi" "Pi" "false" ""
307
+ fi
308
+
309
+ # Count detected
310
+ local detected_count=0
311
+ for i in "${!AGENT_IDS[@]}"; do
312
+ [ "${AGENT_DETECTED[$i]}" = "true" ] && detected_count=$((detected_count + 1))
313
+ done
314
+
315
+ if [ $detected_count -gt 0 ]; then
316
+ info "Detected $detected_count agent(s):"
317
+ for i in "${!AGENT_IDS[@]}"; do
318
+ if [ "${AGENT_DETECTED[$i]}" = "true" ]; then
319
+ printf " ${C_GREEN}■${C_RESET} %-15s ${C_DIM}(%s)${C_RESET}\n" "${AGENT_LABELS[$i]}" "${AGENT_REASONS[$i]}"
320
+ fi
321
+ done
322
+ fi
323
+
324
+ return $detected_count
325
+ }
326
+
327
+ # =============================================================================
328
+ # Interactive agent selection (when none detected)
329
+ # =============================================================================
330
+
331
+ SELECTED_AGENTS=()
332
+
333
+ prompt_agent_selection() {
334
+ # Check if stdin is a terminal
335
+ if [ ! -t 0 ]; then
336
+ error "No agents detected and stdin is not a terminal."
337
+ error "Run interactively or set agent config directories manually."
338
+ exit $EXIT_NO_AGENTS
339
+ fi
340
+
341
+ echo ""
342
+ warn "No coding agents detected automatically."
343
+ echo ""
344
+ echo "Select which agents to install SPARK for:"
345
+ echo ""
346
+
347
+ for i in "${!AGENT_IDS[@]}"; do
348
+ printf " ${C_BOLD}%d)${C_RESET} %s\n" "$((i + 1))" "${AGENT_LABELS[$i]}"
349
+ done
350
+
351
+ echo ""
352
+ printf "Enter numbers separated by spaces (e.g. '1 3 5'), or 'q' to quit: "
353
+ read -r selection
354
+
355
+ if [ "$selection" = "q" ] || [ "$selection" = "Q" ] || [ -z "$selection" ]; then
356
+ info "No agents selected. Exiting."
357
+ exit $EXIT_NO_AGENTS
358
+ fi
359
+
360
+ for num in $selection; do
361
+ # Validate: must be a number in range
362
+ if [[ "$num" =~ ^[0-9]+$ ]] && [ "$num" -ge 1 ] && [ "$num" -le "${#AGENT_IDS[@]}" ]; then
363
+ local idx=$((num - 1))
364
+ SELECTED_AGENTS+=("${AGENT_IDS[$idx]}")
365
+ else
366
+ warn "Ignoring invalid selection: $num"
367
+ fi
368
+ done
369
+
370
+ if [ ${#SELECTED_AGENTS[@]} -eq 0 ]; then
371
+ error "No valid agents selected."
372
+ exit $EXIT_NO_AGENTS
373
+ fi
374
+
375
+ info "Selected: ${SELECTED_AGENTS[*]}"
376
+ }
377
+
378
+ build_selected_agents() {
379
+ # If agents were detected, use those. Otherwise, use manual selection.
380
+ local detected_count=0
381
+ for i in "${!AGENT_IDS[@]}"; do
382
+ [ "${AGENT_DETECTED[$i]}" = "true" ] && detected_count=$((detected_count + 1))
383
+ done
384
+
385
+ if [ $detected_count -eq 0 ]; then
386
+ prompt_agent_selection
387
+ else
388
+ # Use all detected agents
389
+ for i in "${!AGENT_IDS[@]}"; do
390
+ if [ "${AGENT_DETECTED[$i]}" = "true" ]; then
391
+ SELECTED_AGENTS+=("${AGENT_IDS[$i]}")
392
+ fi
393
+ done
394
+ fi
395
+ }
396
+
397
+ # =============================================================================
398
+ # Installation logic
399
+ # =============================================================================
400
+
401
+ INSTALL_RESULTS=() # "agent_id:method" pairs
402
+ INSTALL_ERRORS=()
403
+
404
+ # Determine target directory for a given agent
405
+ get_target_dir() {
406
+ local agent_id="$1"
407
+ local home="${HOME:-$(eval echo ~)}"
408
+
409
+ if [ "$SCOPE" = "global" ]; then
410
+ case "$agent_id" in
411
+ claude) echo "$home/.claude" ;;
412
+ codex) echo "$home/.codex" ;;
413
+ cursor) echo "$home/.cursor/plugins/spark" ;;
414
+ kimi) echo "$home/.kimi" ;;
415
+ opencode) echo "${OPENCODE_CONFIG_DIR:-$home/.config/opencode}" ;;
416
+ pi) echo "${PI_HOME:-$home/.pi}" ;;
417
+ *) echo "$home/.$agent_id" ;;
418
+ esac
419
+ else
420
+ # Project scope: relative to current working directory
421
+ case "$agent_id" in
422
+ claude) echo "$(pwd)/.claude" ;;
423
+ codex) echo "$(pwd)/.codex" ;;
424
+ cursor) echo "$(pwd)/.cursor" ;;
425
+ kimi) echo "$(pwd)/.kimi" ;;
426
+ opencode) echo "$(pwd)/.opencode" ;;
427
+ pi) echo "$(pwd)/.pi" ;;
428
+ *) echo "$(pwd)/.$agent_id" ;;
429
+ esac
430
+ fi
431
+ }
432
+
433
+ # Try to create a symlink, fallback to copy
434
+ # Returns: "symlink" or "copy"
435
+ link_or_copy() {
436
+ local source="$1"
437
+ local target="$2"
438
+
439
+ # If target already exists and points to same source, skip
440
+ if [ -L "$target" ]; then
441
+ local existing_target
442
+ existing_target="$(readlink "$target" 2>/dev/null || true)"
443
+ if [ "$existing_target" = "$source" ]; then
444
+ return 0 # Already linked correctly
445
+ fi
446
+ # Remove stale symlink
447
+ rm -f "$target"
448
+ elif [ -e "$target" ]; then
449
+ if $FORCE; then
450
+ rm -rf "$target"
451
+ else
452
+ warn "Target already exists (not a symlink): $target"
453
+ warn "Use --force to overwrite"
454
+ return 1
455
+ fi
456
+ fi
457
+
458
+ # Create parent directory
459
+ mkdir -p "$(dirname "$target")"
460
+
461
+ # Try symlink first
462
+ if ln -s "$source" "$target" 2>/dev/null; then
463
+ echo "symlink"
464
+ return 0
465
+ fi
466
+
467
+ # Fallback to copy
468
+ if [ -d "$source" ]; then
469
+ cp -R "$source" "$target"
470
+ else
471
+ cp "$source" "$target"
472
+ fi
473
+ echo "copy"
474
+ return 0
475
+ }
476
+
477
+ # Copy a file preserving permissions
478
+ copy_file() {
479
+ local source="$1"
480
+ local target="$2"
481
+
482
+ mkdir -p "$(dirname "$target")"
483
+
484
+ if [ -e "$target" ] && ! $FORCE; then
485
+ # Check if content is identical
486
+ if cmp -s "$source" "$target" 2>/dev/null; then
487
+ return 0 # Already identical
488
+ fi
489
+ fi
490
+
491
+ cp "$source" "$target"
492
+ # Preserve executable permission if source has it
493
+ if [ -x "$source" ]; then
494
+ chmod +x "$target"
495
+ fi
496
+ }
497
+
498
+ # Get hook files needed for a specific agent
499
+ get_agent_hook_files() {
500
+ local agent_id="$1"
501
+
502
+ case "$agent_id" in
503
+ claude)
504
+ echo "hooks/hooks.json"
505
+ echo "hooks/run-hook.cmd"
506
+ echo "hooks/session-start"
507
+ ;;
508
+ codex)
509
+ echo "hooks/hooks-codex.json"
510
+ echo "hooks/run-hook.cmd"
511
+ echo "hooks/session-start-codex"
512
+ ;;
513
+ cursor)
514
+ echo "hooks/hooks-cursor.json"
515
+ echo "hooks/run-hook.cmd"
516
+ echo "hooks/session-start"
517
+ ;;
518
+ kimi)
519
+ # Kimi uses sessionStart field in plugin.json, no separate hook files
520
+ ;;
521
+ opencode)
522
+ # OpenCode uses plugin JS for bootstrap, no separate hook files
523
+ ;;
524
+ pi)
525
+ # Pi uses extension TS for bootstrap, no separate hook files
526
+ ;;
527
+ esac
528
+ }
529
+
530
+ # Get plugin manifest files for a specific agent
531
+ get_agent_manifest_files() {
532
+ local agent_id="$1"
533
+
534
+ case "$agent_id" in
535
+ claude) echo ".claude-plugin/plugin.json" ;;
536
+ codex) echo ".codex-plugin/plugin.json" ;;
537
+ cursor) echo ".cursor-plugin/plugin.json" ;;
538
+ kimi) echo ".kimi-plugin/plugin.json" ;;
539
+ opencode) echo ".opencode/plugins/spark.js" ;;
540
+ pi) echo ".pi/extensions/spark.ts" ;;
541
+ esac
542
+ }
543
+
544
+ install_for_agent() {
545
+ local agent_id="$1"
546
+ local target_dir
547
+ target_dir="$(get_target_dir "$agent_id")"
548
+
549
+ if $DRY_RUN; then
550
+ info "[DRY-RUN] Would install for $agent_id at $target_dir"
551
+ INSTALL_RESULTS+=("$agent_id:dry-run")
552
+ return 0
553
+ fi
554
+
555
+ # Create target directory
556
+ if ! mkdir -p "$target_dir" 2>/dev/null; then
557
+ error "Cannot create directory: $target_dir"
558
+ INSTALL_ERRORS+=("$agent_id:permission_denied")
559
+ return 1
560
+ fi
561
+
562
+ local method="symlink"
563
+ local hooks_installed=false
564
+
565
+ # 1. Install skills — symlink the entire skills/ directory
566
+ local skills_target="$target_dir/skills"
567
+ local result
568
+ result="$(link_or_copy "$SPARK_ROOT/skills" "$skills_target")" || {
569
+ error "Failed to install skills for $agent_id"
570
+ INSTALL_ERRORS+=("$agent_id:skills_failed")
571
+ return 1
572
+ }
573
+ [ -n "$result" ] && method="$result"
574
+
575
+ # 2. Install hooks (for shell-hook agents)
576
+ local hook_files
577
+ hook_files="$(get_agent_hook_files "$agent_id")"
578
+
579
+ if [ -n "$hook_files" ]; then
580
+ local hooks_dir="$target_dir/hooks"
581
+ mkdir -p "$hooks_dir"
582
+
583
+ while IFS= read -r hook_file; do
584
+ [ -z "$hook_file" ] && continue
585
+ local source_path="$SPARK_ROOT/$hook_file"
586
+ local target_path="$target_dir/$hook_file"
587
+
588
+ if [ ! -f "$source_path" ]; then
589
+ warn "Hook file not found: $source_path"
590
+ continue
591
+ fi
592
+
593
+ copy_file "$source_path" "$target_path"
594
+ done <<< "$hook_files"
595
+
596
+ hooks_installed=true
597
+ fi
598
+
599
+ # 3. Install plugin manifest (for agents that need it locally)
600
+ local manifest_files
601
+ manifest_files="$(get_agent_manifest_files "$agent_id")"
602
+
603
+ if [ -n "$manifest_files" ]; then
604
+ while IFS= read -r manifest_file; do
605
+ [ -z "$manifest_file" ] && continue
606
+ local source_path="$SPARK_ROOT/$manifest_file"
607
+ local manifest_basename
608
+ manifest_basename="$(basename "$manifest_file")"
609
+
610
+ # For extension-style agents, install the plugin/extension file
611
+ case "$agent_id" in
612
+ opencode)
613
+ # OpenCode needs the plugin JS in its plugins directory
614
+ local oc_plugins="$target_dir/plugins"
615
+ mkdir -p "$oc_plugins"
616
+ copy_file "$source_path" "$oc_plugins/spark.js"
617
+ ;;
618
+ pi)
619
+ # Pi needs the extension TS in its extensions directory
620
+ local pi_ext="$target_dir/extensions"
621
+ mkdir -p "$pi_ext"
622
+ copy_file "$source_path" "$pi_ext/spark.ts"
623
+ ;;
624
+ *)
625
+ # Shell-hook agents: copy manifest to agent-plugin dir
626
+ local plugin_dir="$target_dir/.${agent_id}-plugin"
627
+ # For project scope, put manifest in the target dir itself
628
+ if [ "$SCOPE" = "project" ]; then
629
+ plugin_dir="$(pwd)/.${agent_id}-plugin"
630
+ fi
631
+ if [ -f "$source_path" ]; then
632
+ mkdir -p "$plugin_dir"
633
+ copy_file "$source_path" "$plugin_dir/$manifest_basename"
634
+ fi
635
+ ;;
636
+ esac
637
+ done <<< "$manifest_files"
638
+ fi
639
+
640
+ # 4. Print agent-specific post-install notes
641
+ case "$agent_id" in
642
+ opencode)
643
+ info " Note: OpenCode also needs plugin registered in opencode.json."
644
+ info " Add to your opencode.json: \"plugin\": [\"spark@git+https://github.com/adityaaria/SPARK.git\"]"
645
+ ;;
646
+ pi)
647
+ info " Note: For full Pi integration, also run: pi install git:github.com/adityaaria/SPARK"
648
+ ;;
649
+ esac
650
+
651
+ INSTALL_RESULTS+=("$agent_id:$method:hooks=$hooks_installed")
652
+ success "Installed SPARK for $agent_id ($method) at $target_dir"
653
+ return 0
654
+ }
655
+
656
+ # =============================================================================
657
+ # Lock file
658
+ # =============================================================================
659
+
660
+ write_lock_file() {
661
+ local lock_dir
662
+ if [ "$SCOPE" = "global" ]; then
663
+ lock_dir="${HOME:-$(eval echo ~)}"
664
+ else
665
+ lock_dir="$(pwd)"
666
+ fi
667
+
668
+ local lock_path="$lock_dir/$LOCK_FILE_NAME"
669
+ local timestamp
670
+ timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%S")"
671
+
672
+ if $DRY_RUN; then
673
+ info "[DRY-RUN] Would write lock file to $lock_path"
674
+ return 0
675
+ fi
676
+
677
+ # Build agents JSON manually (no jq dependency)
678
+ local agents_json=""
679
+ for entry in "${INSTALL_RESULTS[@]}"; do
680
+ local agent_id method hooks_installed
681
+ agent_id="$(echo "$entry" | cut -d: -f1)"
682
+ method="$(echo "$entry" | cut -d: -f2)"
683
+ hooks_installed="$(echo "$entry" | cut -d: -f3 | sed 's/hooks=//')"
684
+
685
+ local target_dir
686
+ target_dir="$(get_target_dir "$agent_id")"
687
+
688
+ [ -n "$agents_json" ] && agents_json="$agents_json,"
689
+ agents_json="$agents_json
690
+ \"$agent_id\": {
691
+ \"scope\": \"$SCOPE\",
692
+ \"target\": \"$target_dir\",
693
+ \"method\": \"$method\",
694
+ \"hooks_installed\": $hooks_installed
695
+ }"
696
+ done
697
+
698
+ cat > "$lock_path" <<EOF
699
+ {
700
+ "installer": "spark-install.sh",
701
+ "installer_version": "$VERSION",
702
+ "spark_version": "$SPARK_VERSION",
703
+ "commit": "$SPARK_COMMIT",
704
+ "spark_root": "$SPARK_ROOT",
705
+ "installed_at": "$timestamp",
706
+ "scope": "$SCOPE",
707
+ "agents": {$agents_json
708
+ }
709
+ }
710
+ EOF
711
+
712
+ success "Lock file written to $lock_path"
713
+ }
714
+
715
+ # =============================================================================
716
+ # Idempotency check
717
+ # =============================================================================
718
+
719
+ check_existing_install() {
720
+ local lock_dir
721
+ if [ "$SCOPE" = "global" ]; then
722
+ lock_dir="${HOME:-$(eval echo ~)}"
723
+ else
724
+ lock_dir="$(pwd)"
725
+ fi
726
+
727
+ local lock_path="$lock_dir/$LOCK_FILE_NAME"
728
+
729
+ if [ -f "$lock_path" ] && ! $FORCE; then
730
+ local existing_version=""
731
+ local existing_commit=""
732
+
733
+ # Parse existing lock file without jq
734
+ existing_version="$(grep -o '"spark_version"[[:space:]]*:[[:space:]]*"[^"]*"' "$lock_path" 2>/dev/null | grep -o '"[^"]*"$' | tr -d '"' || true)"
735
+ existing_commit="$(grep -o '"commit"[[:space:]]*:[[:space:]]*"[^"]*"' "$lock_path" 2>/dev/null | grep -o '"[^"]*"$' | tr -d '"' || true)"
736
+
737
+ echo ""
738
+ warn "SPARK is already installed (version: ${existing_version:-unknown}, commit: ${existing_commit:-unknown})"
739
+
740
+ if [ "$existing_version" = "$SPARK_VERSION" ] && [ "$existing_commit" = "$SPARK_COMMIT" ]; then
741
+ success "Already up to date. Nothing to do."
742
+ info "Use --force to reinstall anyway."
743
+ exit $EXIT_SUCCESS
744
+ fi
745
+
746
+ info "A different version is installed. Updating..."
747
+ info " Installed: $existing_version ($existing_commit)"
748
+ info " Available: $SPARK_VERSION ($SPARK_COMMIT)"
749
+ echo ""
750
+ fi
751
+ }
752
+
753
+ # =============================================================================
754
+ # Summary
755
+ # =============================================================================
756
+
757
+ print_summary() {
758
+ header "Installation Summary"
759
+
760
+ echo " SPARK version: $SPARK_VERSION"
761
+ echo " Commit: ${SPARK_COMMIT:0:12}"
762
+ echo " Scope: $SCOPE"
763
+ echo " Skills: ${#SKILL_NAMES[@]} installed"
764
+ echo ""
765
+
766
+ if [ ${#INSTALL_RESULTS[@]} -gt 0 ]; then
767
+ echo " Agents:"
768
+ for entry in "${INSTALL_RESULTS[@]}"; do
769
+ local agent_id method
770
+ agent_id="$(echo "$entry" | cut -d: -f1)"
771
+ method="$(echo "$entry" | cut -d: -f2)"
772
+ local target_dir
773
+ target_dir="$(get_target_dir "$agent_id")"
774
+ printf " ${C_GREEN}■${C_RESET} %-15s → %s ${C_DIM}(%s)${C_RESET}\n" "$agent_id" "$target_dir" "$method"
775
+ done
776
+ fi
777
+
778
+ if [ ${#INSTALL_ERRORS[@]} -gt 0 ]; then
779
+ echo ""
780
+ echo " Errors:"
781
+ for entry in "${INSTALL_ERRORS[@]}"; do
782
+ printf " ${C_RED}■${C_RESET} %s\n" "$entry"
783
+ done
784
+ fi
785
+
786
+ echo ""
787
+ success "Start a fresh agent session to confirm using-spark loads before coding."
788
+ echo ""
789
+ }
790
+
791
+ # =============================================================================
792
+ # Main
793
+ # =============================================================================
794
+
795
+ main() {
796
+ parse_args "$@"
797
+
798
+ if $HELP; then
799
+ print_help
800
+ exit $EXIT_SUCCESS
801
+ fi
802
+
803
+ header "SPARK Native Installer"
804
+
805
+ # Step 1: Resolve repo
806
+ info "Resolving SPARK repository..."
807
+ resolve_spark_root
808
+ detect_version
809
+ info "SPARK root: $SPARK_ROOT"
810
+ info "Version: $SPARK_VERSION (${SPARK_COMMIT:0:12})"
811
+
812
+ # Step 2: Check existing install
813
+ check_existing_install
814
+
815
+ # Step 3: Discover skills
816
+ info "Discovering skills..."
817
+ discover_skills
818
+
819
+ # Step 4: Detect agents
820
+ echo ""
821
+ info "Detecting installed agents..."
822
+ detect_agents || true # detect_agents returns count via exit code
823
+
824
+ # Step 5: Build selection
825
+ build_selected_agents
826
+
827
+ if [ ${#SELECTED_AGENTS[@]} -eq 0 ]; then
828
+ error "No agents to install for."
829
+ exit $EXIT_NO_AGENTS
830
+ fi
831
+
832
+ # Step 6: Install for each selected agent
833
+ echo ""
834
+ header "Installing"
835
+
836
+ local total=${#SELECTED_AGENTS[@]}
837
+ local current=0
838
+ for agent_id in "${SELECTED_AGENTS[@]}"; do
839
+ current=$((current + 1))
840
+ step "$current" "$total" "Installing for $agent_id..."
841
+ install_for_agent "$agent_id" || true
842
+ done
843
+
844
+ # Step 7: Write lock file
845
+ if [ ${#INSTALL_RESULTS[@]} -gt 0 ]; then
846
+ echo ""
847
+ write_lock_file
848
+ fi
849
+
850
+ # Step 8: Summary
851
+ print_summary
852
+
853
+ # Exit with error if any installs failed
854
+ if [ ${#INSTALL_ERRORS[@]} -gt 0 ]; then
855
+ exit $EXIT_INSTALL_FAILED
856
+ fi
857
+
858
+ exit $EXIT_SUCCESS
859
+ }
860
+
861
+ main "$@"