@adityaaria/spark 6.0.15 → 6.0.16

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