@revotools/cli 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/revo +3263 -0
  4. package/package.json +34 -0
package/dist/revo ADDED
@@ -0,0 +1,3263 @@
1
+ #!/usr/bin/env bash
2
+ # Revo CLI - Claude-first multi-repo workspace manager
3
+ # https://github.com/marcus.salinas/revo
4
+ # This is a bundled distribution - do not edit
5
+
6
+ set -euo pipefail
7
+
8
+ # Exit cleanly on SIGPIPE (e.g., revo clone | grep, revo status | head)
9
+ trap 'exit 0' PIPE
10
+
11
+ REVO_VERSION="0.2.0"
12
+
13
+
14
+ # === lib/ui.sh ===
15
+ # Revo CLI - UI Components (Clack-style)
16
+ # Provides terminal UI primitives with Unicode/ASCII fallback
17
+
18
+ # Detect color support
19
+ if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then
20
+ UI_COLOR=1
21
+ else
22
+ UI_COLOR=0
23
+ fi
24
+
25
+ # Detect Unicode support
26
+ if [[ "${LANG:-}" == *UTF-8* ]] || [[ "${LC_ALL:-}" == *UTF-8* ]]; then
27
+ UI_UNICODE=1
28
+ else
29
+ UI_UNICODE=0
30
+ fi
31
+
32
+ # --- Symbols ---
33
+ if [[ "$UI_UNICODE" -eq 1 ]]; then
34
+ S_STEP_ACTIVE="◆"
35
+ S_STEP_DONE="◇"
36
+ S_STEP_ERROR="▲"
37
+ S_STEP_CANCEL="■"
38
+ S_BAR="│"
39
+ S_BAR_START="┌"
40
+ S_BAR_END="└"
41
+ S_SPINNER_FRAMES=("◒" "◐" "◓" "◑")
42
+ S_CHECKBOX_ON="◼"
43
+ S_CHECKBOX_OFF="◻"
44
+ S_RADIO_ON="●"
45
+ S_RADIO_OFF="○"
46
+ else
47
+ S_STEP_ACTIVE="*"
48
+ S_STEP_DONE="+"
49
+ S_STEP_ERROR="!"
50
+ S_STEP_CANCEL="x"
51
+ S_BAR="|"
52
+ S_BAR_START="/"
53
+ S_BAR_END="\\"
54
+ S_SPINNER_FRAMES=("-" "\\" "|" "/")
55
+ S_CHECKBOX_ON="[x]"
56
+ S_CHECKBOX_OFF="[ ]"
57
+ S_RADIO_ON="(*)"
58
+ S_RADIO_OFF="( )"
59
+ fi
60
+
61
+ # --- Colors ---
62
+ ui_reset() {
63
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[0m' || true
64
+ }
65
+
66
+ ui_cyan() {
67
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[36m%s\033[0m' "$1" || printf '%s' "$1"
68
+ }
69
+
70
+ ui_green() {
71
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"
72
+ }
73
+
74
+ ui_yellow() {
75
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[33m%s\033[0m' "$1" || printf '%s' "$1"
76
+ }
77
+
78
+ ui_red() {
79
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"
80
+ }
81
+
82
+ ui_dim() {
83
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"
84
+ }
85
+
86
+ ui_bold() {
87
+ [[ "$UI_COLOR" -eq 1 ]] && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"
88
+ }
89
+
90
+ # --- Bar Components ---
91
+ ui_bar() {
92
+ ui_dim "$S_BAR"
93
+ }
94
+
95
+ ui_bar_start() {
96
+ ui_dim "$S_BAR_START"
97
+ }
98
+
99
+ ui_bar_end() {
100
+ ui_dim "$S_BAR_END"
101
+ }
102
+
103
+ # --- Layout Components ---
104
+
105
+ # Intro: Start of a section
106
+ # Usage: ui_intro "Title"
107
+ ui_intro() {
108
+ local title="$1"
109
+ printf '%s %s\n' "$(ui_bar_start)" "$(ui_cyan "$title")"
110
+ printf '%s\n' "$(ui_bar)"
111
+ }
112
+
113
+ # Outro: End of a section
114
+ # Usage: ui_outro "Message"
115
+ ui_outro() {
116
+ local message="$1"
117
+ printf '%s\n' "$(ui_bar)"
118
+ printf '%s %s\n' "$(ui_bar_end)" "$(ui_green "$message")"
119
+ }
120
+
121
+ # Outro with cancel
122
+ ui_outro_cancel() {
123
+ local message="$1"
124
+ printf '%s\n' "$(ui_bar)"
125
+ printf '%s %s\n' "$(ui_bar_end)" "$(ui_red "$message")"
126
+ }
127
+
128
+ # Step: Active prompt
129
+ # Usage: ui_step "Label"
130
+ ui_step() {
131
+ local label="$1"
132
+ printf '%s %s\n' "$(ui_cyan "$S_STEP_ACTIVE")" "$label"
133
+ }
134
+
135
+ # Step done: Completed step
136
+ # Usage: ui_step_done "Label" "value"
137
+ ui_step_done() {
138
+ local label="$1"
139
+ local value="${2:-}"
140
+ if [[ -n "$value" ]]; then
141
+ printf '%s %s %s\n' "$(ui_green "$S_STEP_DONE")" "$label" "$(ui_dim "$value")"
142
+ else
143
+ printf '%s %s\n' "$(ui_green "$S_STEP_DONE")" "$label"
144
+ fi
145
+ }
146
+
147
+ # Step error
148
+ ui_step_error() {
149
+ local message="$1"
150
+ printf '%s %s\n' "$(ui_yellow "$S_STEP_ERROR")" "$(ui_yellow "$message")"
151
+ }
152
+
153
+ # Step cancel
154
+ ui_step_cancel() {
155
+ local message="$1"
156
+ printf '%s %s\n' "$(ui_red "$S_STEP_CANCEL")" "$(ui_red "$message")"
157
+ }
158
+
159
+ # Info line (continuation)
160
+ ui_info() {
161
+ local message="$1"
162
+ printf '%s %s\n' "$(ui_bar)" "$message"
163
+ }
164
+
165
+ # Empty bar line
166
+ ui_bar_line() {
167
+ printf '%s\n' "$(ui_bar)"
168
+ }
169
+
170
+ # --- Interactive Components ---
171
+
172
+ # Text input
173
+ # Usage: result=$(ui_text "Prompt" "default")
174
+ # Returns: user input or default
175
+ ui_text() {
176
+ local prompt="$1"
177
+ local default="${2:-}"
178
+ local input
179
+
180
+ ui_step "$prompt"
181
+ if [[ -n "$default" ]]; then
182
+ printf '%s ' "$(ui_bar)"
183
+ read -r -p "" -e -i "$default" input
184
+ else
185
+ printf '%s ' "$(ui_bar)"
186
+ read -r input
187
+ fi
188
+
189
+ # Return default if empty
190
+ if [[ -z "$input" ]]; then
191
+ input="$default"
192
+ fi
193
+
194
+ printf '%s' "$input"
195
+ }
196
+
197
+ # Confirm (y/n)
198
+ # Usage: if ui_confirm "Question?"; then ... fi
199
+ ui_confirm() {
200
+ local prompt="$1"
201
+ local default="${2:-y}"
202
+ local hint
203
+ local response
204
+
205
+ if [[ "$default" == "y" ]]; then
206
+ hint="Y/n"
207
+ else
208
+ hint="y/N"
209
+ fi
210
+
211
+ ui_step "$prompt ($hint)"
212
+ printf '%s ' "$(ui_bar)"
213
+ read -r -n 1 response
214
+ printf '\n'
215
+
216
+ # Handle empty (use default)
217
+ if [[ -z "$response" ]]; then
218
+ response="$default"
219
+ fi
220
+
221
+ [[ "$response" =~ ^[Yy]$ ]]
222
+ }
223
+
224
+ # Select from options
225
+ # Usage: result=$(ui_select "Prompt" "opt1" "opt2" "opt3")
226
+ # Returns: selected option
227
+ ui_select() {
228
+ local prompt="$1"
229
+ shift
230
+ local options=("$@")
231
+ local selected=0
232
+ local count=${#options[@]}
233
+ local key
234
+
235
+ ui_step "$prompt"
236
+
237
+ # Save cursor position and hide cursor
238
+ printf '\033[?25l'
239
+
240
+ while true; do
241
+ # Draw options
242
+ for i in "${!options[@]}"; do
243
+ if [[ $i -eq $selected ]]; then
244
+ printf '%s %s %s\n' "$(ui_bar)" "$(ui_cyan "$S_RADIO_ON")" "${options[$i]}"
245
+ else
246
+ printf '%s %s %s\n' "$(ui_bar)" "$(ui_dim "$S_RADIO_OFF")" "$(ui_dim "${options[$i]}")"
247
+ fi
248
+ done
249
+
250
+ # Read key
251
+ read -rsn1 key
252
+
253
+ # Handle arrow keys (escape sequences)
254
+ if [[ "$key" == $'\033' ]]; then
255
+ read -rsn2 key
256
+ case "$key" in
257
+ '[A') # Up
258
+ ((selected > 0)) && ((selected--))
259
+ ;;
260
+ '[B') # Down
261
+ ((selected < count - 1)) && selected=$((selected + 1))
262
+ ;;
263
+ esac
264
+ elif [[ "$key" == "" ]]; then
265
+ # Enter pressed
266
+ break
267
+ elif [[ "$key" == "j" ]]; then
268
+ ((selected < count - 1)) && selected=$((selected + 1))
269
+ elif [[ "$key" == "k" ]]; then
270
+ ((selected > 0)) && ((selected--))
271
+ fi
272
+
273
+ # Move cursor up to redraw
274
+ printf '\033[%dA' "$count"
275
+ done
276
+
277
+ # Show cursor
278
+ printf '\033[?25h'
279
+
280
+ printf '%s' "${options[$selected]}"
281
+ }
282
+
283
+ # --- Spinner ---
284
+ # Usage:
285
+ # ui_spinner_start "Loading..."
286
+ # ... do work ...
287
+ # ui_spinner_stop "Done!" # or ui_spinner_stop
288
+ _SPINNER_PID=""
289
+ _SPINNER_MSG=""
290
+
291
+ ui_spinner_start() {
292
+ local message="$1"
293
+ _SPINNER_MSG="$message"
294
+
295
+ # Skip spinner animation when stdout is not a terminal (piped/redirected)
296
+ if [[ ! -t 1 ]]; then
297
+ return
298
+ fi
299
+
300
+ (
301
+ local i=0
302
+ local frame_count=${#S_SPINNER_FRAMES[@]}
303
+
304
+ while true; do
305
+ printf '\r%s %s %s' "$(ui_bar)" "$(ui_cyan "${S_SPINNER_FRAMES[$i]}")" "$message"
306
+ ((i = (i + 1) % frame_count))
307
+ sleep 0.1
308
+ done
309
+ ) &
310
+ _SPINNER_PID=$!
311
+
312
+ # Ensure cleanup on script exit
313
+ trap 'ui_spinner_stop 2>/dev/null' EXIT
314
+ }
315
+
316
+ ui_spinner_stop() {
317
+ local final_message="${1:-}"
318
+
319
+ if [[ -n "$_SPINNER_PID" ]] && kill -0 "$_SPINNER_PID" 2>/dev/null; then
320
+ kill "$_SPINNER_PID" 2>/dev/null
321
+ wait "$_SPINNER_PID" 2>/dev/null || true
322
+ fi
323
+ _SPINNER_PID=""
324
+
325
+ # Clear spinner line only when connected to a terminal
326
+ if [[ -t 1 ]]; then
327
+ printf '\r\033[K'
328
+ fi
329
+
330
+ # Show final message if provided
331
+ if [[ -n "$final_message" ]]; then
332
+ ui_step_done "$final_message"
333
+ fi
334
+ }
335
+
336
+ ui_spinner_error() {
337
+ local message="$1"
338
+
339
+ if [[ -n "$_SPINNER_PID" ]] && kill -0 "$_SPINNER_PID" 2>/dev/null; then
340
+ kill "$_SPINNER_PID" 2>/dev/null
341
+ wait "$_SPINNER_PID" 2>/dev/null || true
342
+ fi
343
+ _SPINNER_PID=""
344
+
345
+ if [[ -t 1 ]]; then
346
+ printf '\r\033[K'
347
+ fi
348
+ ui_step_error "$message"
349
+ }
350
+
351
+ # --- Progress ---
352
+ # Usage: ui_progress "message" current total
353
+ ui_progress() {
354
+ local message="$1"
355
+ local current="$2"
356
+ local total="$3"
357
+ local percent=$((current * 100 / total))
358
+ local bar_width=20
359
+ local filled=$((percent * bar_width / 100))
360
+ local empty=$((bar_width - filled))
361
+
362
+ printf '\r%s ' "$(ui_bar)"
363
+ printf '%s [' "$message"
364
+ printf '%*s' "$filled" '' | tr ' ' '='
365
+ printf '%*s' "$empty" '' | tr ' ' ' '
366
+ printf '] %d%%' "$percent"
367
+ }
368
+
369
+ # --- ANSI-aware padding ---
370
+
371
+ _ui_visible_len() {
372
+ local str="$1"
373
+ local stripped
374
+ stripped=$(printf '%s' "$str" | sed $'s/\033\\[[0-9;]*m//g')
375
+ printf '%d' "${#stripped}"
376
+ }
377
+
378
+ _ui_pad() {
379
+ local str="$1"
380
+ local target_width="$2"
381
+ local visible_len
382
+ visible_len=$(_ui_visible_len "$str")
383
+ local pad=$((target_width - visible_len))
384
+ [[ $pad -lt 0 ]] && pad=0
385
+ printf '%s%*s' "$str" "$pad" ""
386
+ }
387
+
388
+ # --- Table ---
389
+ # Usage: ui_table_widths 24 20 12 14
390
+ # ui_table_header "Col1" "Col2" "Col3" "Col4"
391
+ # ui_table_row "val1" "val2" "val3" "val4"
392
+ _TABLE_COL_WIDTHS=()
393
+
394
+ ui_table_widths() {
395
+ _TABLE_COL_WIDTHS=("$@")
396
+ }
397
+
398
+ ui_table_header() {
399
+ local cols=("$@")
400
+ local i=0
401
+
402
+ printf '%s ' "$(ui_bar)"
403
+ for col in "${cols[@]}"; do
404
+ local w="${_TABLE_COL_WIDTHS[$i]:-20}"
405
+ local padded
406
+ padded=$(printf "%-${w}s" "$col")
407
+ printf '%s' "$(ui_bold "$padded")"
408
+ i=$((i + 1))
409
+ done
410
+ printf '\n'
411
+
412
+ # Separator
413
+ printf '%s ' "$(ui_bar)"
414
+ i=0
415
+ for _ in "${cols[@]}"; do
416
+ local w="${_TABLE_COL_WIDTHS[$i]:-20}"
417
+ local dashes=""
418
+ local j=0
419
+ while [[ $j -lt $w ]]; do
420
+ dashes+="─"
421
+ j=$((j + 1))
422
+ done
423
+ printf '%s' "$(ui_dim "$dashes")"
424
+ i=$((i + 1))
425
+ done
426
+ printf '\n'
427
+ }
428
+
429
+ ui_table_row() {
430
+ local vals=("$@")
431
+ local i=0
432
+
433
+ printf '%s ' "$(ui_bar)"
434
+ for val in "${vals[@]}"; do
435
+ local w="${_TABLE_COL_WIDTHS[$i]:-20}"
436
+ _ui_pad "$val" "$w"
437
+ i=$((i + 1))
438
+ done
439
+ printf '\n'
440
+ }
441
+
442
+ # --- Utilities ---
443
+
444
+ # Clear line
445
+ ui_clear_line() {
446
+ printf '\r\033[K'
447
+ }
448
+
449
+ # Move cursor up N lines
450
+ ui_cursor_up() {
451
+ local n="${1:-1}"
452
+ printf '\033[%dA' "$n"
453
+ }
454
+
455
+ # Hide cursor
456
+ ui_cursor_hide() {
457
+ printf '\033[?25l'
458
+ }
459
+
460
+ # Show cursor
461
+ ui_cursor_show() {
462
+ printf '\033[?25h'
463
+ }
464
+
465
+ # Ensure cursor is shown on exit
466
+ trap 'ui_cursor_show' EXIT
467
+
468
+ # === lib/yaml.sh ===
469
+ # Revo CLI - Minimal YAML Parser
470
+ # Parses revo.yaml (and legacy mars.yaml) format only - not a general YAML parser
471
+ # Compatible with bash 3.2+ (no associative arrays)
472
+
473
+ # Global state - using parallel indexed arrays instead of associative arrays
474
+ YAML_WORKSPACE_NAME=""
475
+ YAML_DEFAULTS_BRANCH=""
476
+ YAML_REPO_COUNT=0
477
+
478
+ # Arrays indexed by repo number (0, 1, 2, ...)
479
+ # Access: ${YAML_REPO_URLS[$i]}
480
+ YAML_REPO_URLS=()
481
+ YAML_REPO_PATHS=()
482
+ YAML_REPO_TAGS=()
483
+ YAML_REPO_DEPS=()
484
+
485
+ yaml_parse() {
486
+ local file="$1"
487
+ local line
488
+ local in_repos=0
489
+ local in_defaults=0
490
+ local current_index=-1
491
+
492
+ # Reset state
493
+ YAML_WORKSPACE_NAME=""
494
+ YAML_DEFAULTS_BRANCH="main"
495
+ YAML_REPO_COUNT=0
496
+ YAML_REPO_URLS=()
497
+ YAML_REPO_PATHS=()
498
+ YAML_REPO_TAGS=()
499
+ YAML_REPO_DEPS=()
500
+
501
+ if [[ ! -f "$file" ]]; then
502
+ return 1
503
+ fi
504
+
505
+ while IFS= read -r line || [[ -n "$line" ]]; do
506
+ # Skip empty lines and comments
507
+ [[ -z "$line" ]] && continue
508
+ [[ "$line" =~ ^[[:space:]]*# ]] && continue
509
+
510
+ # Remove leading/trailing whitespace for comparison
511
+ local trimmed="${line#"${line%%[![:space:]]*}"}"
512
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
513
+
514
+ # Check section markers
515
+ if [[ "$trimmed" == "repos:" ]]; then
516
+ in_repos=1
517
+ in_defaults=0
518
+ continue
519
+ elif [[ "$trimmed" == "defaults:" ]]; then
520
+ in_repos=0
521
+ in_defaults=1
522
+ continue
523
+ elif [[ "$trimmed" == "workspace:" ]]; then
524
+ in_repos=0
525
+ in_defaults=0
526
+ continue
527
+ fi
528
+
529
+ # Parse workspace name
530
+ if [[ "$trimmed" =~ ^name:[[:space:]]*[\"\']?([^\"\']+)[\"\']?$ ]]; then
531
+ YAML_WORKSPACE_NAME="${BASH_REMATCH[1]}"
532
+ continue
533
+ fi
534
+
535
+ # Parse defaults section
536
+ if [[ $in_defaults -eq 1 ]]; then
537
+ if [[ "$trimmed" =~ ^branch:[[:space:]]*(.+)$ ]]; then
538
+ YAML_DEFAULTS_BRANCH="${BASH_REMATCH[1]}"
539
+ fi
540
+ continue
541
+ fi
542
+
543
+ # Parse repos section
544
+ if [[ $in_repos -eq 1 ]]; then
545
+ # New repo entry (starts with -)
546
+ if [[ "$trimmed" =~ ^-[[:space:]]*url:[[:space:]]*(.+)$ ]]; then
547
+ current_index=$((current_index + 1))
548
+ local url="${BASH_REMATCH[1]}"
549
+ YAML_REPO_URLS[$current_index]="$url"
550
+ YAML_REPO_PATHS[$current_index]=$(yaml_path_from_url "$url")
551
+ YAML_REPO_TAGS[$current_index]=""
552
+ YAML_REPO_DEPS[$current_index]=""
553
+ YAML_REPO_COUNT=$((YAML_REPO_COUNT + 1))
554
+ continue
555
+ fi
556
+
557
+ # Continuation of current repo
558
+ if [[ $current_index -ge 0 ]]; then
559
+ if [[ "$trimmed" =~ ^path:[[:space:]]*(.+)$ ]]; then
560
+ YAML_REPO_PATHS[$current_index]="${BASH_REMATCH[1]}"
561
+ elif [[ "$trimmed" =~ ^tags:[[:space:]]*\[([^\]]*)\]$ ]]; then
562
+ # Parse inline array: [tag1, tag2]
563
+ local tags_str="${BASH_REMATCH[1]}"
564
+ # Remove spaces and quotes
565
+ tags_str="${tags_str//[[:space:]]/}"
566
+ tags_str="${tags_str//\"/}"
567
+ tags_str="${tags_str//\'/}"
568
+ YAML_REPO_TAGS[$current_index]="$tags_str"
569
+ elif [[ "$trimmed" =~ ^depends_on:[[:space:]]*\[([^\]]*)\]$ ]]; then
570
+ # Parse inline array: [name1, name2]
571
+ local deps_str="${BASH_REMATCH[1]}"
572
+ deps_str="${deps_str//[[:space:]]/}"
573
+ deps_str="${deps_str//\"/}"
574
+ deps_str="${deps_str//\'/}"
575
+ YAML_REPO_DEPS[$current_index]="$deps_str"
576
+ fi
577
+ fi
578
+ fi
579
+ done < "$file"
580
+
581
+ return 0
582
+ }
583
+
584
+ # Extract repo name from URL
585
+ # Usage: yaml_path_from_url "git@github.com:org/repo.git"
586
+ # Returns: "repo"
587
+ yaml_path_from_url() {
588
+ local url="$1"
589
+ local name
590
+
591
+ # Handle SSH URLs: git@github.com:org/repo.git
592
+ if [[ "$url" =~ ([^/:]+)\.git$ ]]; then
593
+ name="${BASH_REMATCH[1]}"
594
+ # Handle HTTPS URLs: https://github.com/org/repo.git
595
+ elif [[ "$url" =~ /([^/]+)\.git$ ]]; then
596
+ name="${BASH_REMATCH[1]}"
597
+ # Handle URLs without .git
598
+ elif [[ "$url" =~ ([^/:]+)$ ]]; then
599
+ name="${BASH_REMATCH[1]}"
600
+ else
601
+ name="repo"
602
+ fi
603
+
604
+ printf '%s' "$name"
605
+ }
606
+
607
+ # Get list of repo indices, optionally filtered by tag
608
+ # Usage: yaml_get_repos [tag]
609
+ # Returns: newline-separated list of indices (0, 1, 2, ...)
610
+ yaml_get_repos() {
611
+ local filter_tag="${1:-}"
612
+ local i
613
+
614
+ for ((i = 0; i < YAML_REPO_COUNT; i++)); do
615
+ if [[ -z "$filter_tag" ]]; then
616
+ printf '%d\n' "$i"
617
+ else
618
+ local tags="${YAML_REPO_TAGS[$i]}"
619
+ # Check if tag is in comma-separated list
620
+ if [[ ",$tags," == *",$filter_tag,"* ]]; then
621
+ printf '%d\n' "$i"
622
+ fi
623
+ fi
624
+ done
625
+ }
626
+
627
+ # Get repo URL by index
628
+ yaml_get_url() {
629
+ local idx="$1"
630
+ printf '%s' "${YAML_REPO_URLS[$idx]:-}"
631
+ }
632
+
633
+ # Get repo path by index
634
+ yaml_get_path() {
635
+ local idx="$1"
636
+ printf '%s' "${YAML_REPO_PATHS[$idx]:-}"
637
+ }
638
+
639
+ # Get repo tags by index
640
+ yaml_get_tags() {
641
+ local idx="$1"
642
+ printf '%s' "${YAML_REPO_TAGS[$idx]:-}"
643
+ }
644
+
645
+ # Get repo depends_on list by index (comma-separated names)
646
+ yaml_get_deps() {
647
+ local idx="$1"
648
+ printf '%s' "${YAML_REPO_DEPS[$idx]:-}"
649
+ }
650
+
651
+ # Find repo index by name (path basename)
652
+ # Usage: idx=$(yaml_find_by_name "backend")
653
+ # Returns: index or -1 if not found
654
+ yaml_find_by_name() {
655
+ local name="$1"
656
+ local i
657
+ for ((i = 0; i < YAML_REPO_COUNT; i++)); do
658
+ if [[ "${YAML_REPO_PATHS[$i]}" == "$name" ]]; then
659
+ printf '%d' "$i"
660
+ return 0
661
+ fi
662
+ done
663
+ printf '%d' -1
664
+ return 1
665
+ }
666
+
667
+ # Write revo.yaml
668
+ # Usage: yaml_write "path/to/revo.yaml"
669
+ yaml_write() {
670
+ local file="$1"
671
+ local i
672
+
673
+ {
674
+ printf 'version: 1\n\n'
675
+ printf 'workspace:\n'
676
+ printf ' name: "%s"\n\n' "$YAML_WORKSPACE_NAME"
677
+ printf 'repos:\n'
678
+
679
+ for ((i = 0; i < YAML_REPO_COUNT; i++)); do
680
+ local url="${YAML_REPO_URLS[$i]}"
681
+ local path="${YAML_REPO_PATHS[$i]}"
682
+ local tags="${YAML_REPO_TAGS[$i]}"
683
+ local deps="${YAML_REPO_DEPS[$i]:-}"
684
+
685
+ printf ' - url: %s\n' "$url"
686
+
687
+ # Only write path if different from derived
688
+ local derived
689
+ derived=$(yaml_path_from_url "$url")
690
+ if [[ "$path" != "$derived" ]]; then
691
+ printf ' path: %s\n' "$path"
692
+ fi
693
+
694
+ # Write tags if present
695
+ if [[ -n "$tags" ]]; then
696
+ printf ' tags: [%s]\n' "$tags"
697
+ fi
698
+
699
+ # Write depends_on if present
700
+ if [[ -n "$deps" ]]; then
701
+ printf ' depends_on: [%s]\n' "$deps"
702
+ fi
703
+ done
704
+
705
+ printf '\ndefaults:\n'
706
+ printf ' branch: %s\n' "$YAML_DEFAULTS_BRANCH"
707
+ } > "$file"
708
+ }
709
+
710
+ # Add a repo to the config
711
+ # Usage: yaml_add_repo "url" "path" "tags" "deps"
712
+ yaml_add_repo() {
713
+ local url="$1"
714
+ local path="${2:-}"
715
+ local tags="${3:-}"
716
+ local deps="${4:-}"
717
+
718
+ local idx=$YAML_REPO_COUNT
719
+
720
+ YAML_REPO_URLS[$idx]="$url"
721
+
722
+ if [[ -z "$path" ]]; then
723
+ path=$(yaml_path_from_url "$url")
724
+ fi
725
+ YAML_REPO_PATHS[$idx]="$path"
726
+ YAML_REPO_TAGS[$idx]="$tags"
727
+ YAML_REPO_DEPS[$idx]="$deps"
728
+
729
+ YAML_REPO_COUNT=$((YAML_REPO_COUNT + 1))
730
+ }
731
+
732
+ # === lib/config.sh ===
733
+ # Revo CLI - Configuration Management
734
+ # Handles workspace detection and config loading/saving
735
+
736
+ REVO_WORKSPACE_ROOT=""
737
+ REVO_CONFIG_FILE=""
738
+ REVO_REPOS_DIR=""
739
+
740
+ # Find workspace root by searching upward for revo.yaml (or mars.yaml as fallback)
741
+ # Usage: config_find_root [start_dir]
742
+ # Returns: 0 if found (sets REVO_WORKSPACE_ROOT), 1 if not found
743
+ config_find_root() {
744
+ local start_dir="${1:-$PWD}"
745
+ local current="$start_dir"
746
+
747
+ while [[ "$current" != "/" ]]; do
748
+ if [[ -f "$current/revo.yaml" ]]; then
749
+ REVO_WORKSPACE_ROOT="$current"
750
+ REVO_CONFIG_FILE="$current/revo.yaml"
751
+ REVO_REPOS_DIR="$current/repos"
752
+ return 0
753
+ fi
754
+ # Fallback: support mars.yaml for migration from Mars
755
+ if [[ -f "$current/mars.yaml" ]]; then
756
+ REVO_WORKSPACE_ROOT="$current"
757
+ REVO_CONFIG_FILE="$current/mars.yaml"
758
+ REVO_REPOS_DIR="$current/repos"
759
+ return 0
760
+ fi
761
+ current="$(dirname "$current")"
762
+ done
763
+
764
+ return 1
765
+ }
766
+
767
+ # Initialize workspace in current directory
768
+ # Usage: config_init "workspace_name"
769
+ # Returns: 0 on success, 1 if already initialized
770
+ config_init() {
771
+ local workspace_name="$1"
772
+ local dir="${2:-$PWD}"
773
+
774
+ if [[ -f "$dir/revo.yaml" ]] || [[ -f "$dir/mars.yaml" ]]; then
775
+ return 1
776
+ fi
777
+
778
+ REVO_WORKSPACE_ROOT="$dir"
779
+ REVO_CONFIG_FILE="$dir/revo.yaml"
780
+ REVO_REPOS_DIR="$dir/repos"
781
+
782
+ # Set workspace name for yaml module
783
+ YAML_WORKSPACE_NAME="$workspace_name"
784
+ YAML_DEFAULTS_BRANCH="main"
785
+ YAML_REPO_COUNT=0
786
+ YAML_REPO_URLS=()
787
+ YAML_REPO_PATHS=()
788
+ YAML_REPO_TAGS=()
789
+ YAML_REPO_DEPS=()
790
+
791
+ # Create directory structure
792
+ mkdir -p "$REVO_REPOS_DIR"
793
+
794
+ # Write config
795
+ yaml_write "$REVO_CONFIG_FILE"
796
+
797
+ # Create .gitignore
798
+ printf 'repos/\n.revo/\n' > "$dir/.gitignore"
799
+
800
+ return 0
801
+ }
802
+
803
+ # Load configuration
804
+ # Usage: config_load
805
+ # Returns: 0 on success, 1 on failure
806
+ config_load() {
807
+ if [[ -z "$REVO_CONFIG_FILE" ]] || [[ ! -f "$REVO_CONFIG_FILE" ]]; then
808
+ return 1
809
+ fi
810
+
811
+ yaml_parse "$REVO_CONFIG_FILE"
812
+ }
813
+
814
+ # Save configuration
815
+ # Usage: config_save
816
+ config_save() {
817
+ if [[ -z "$REVO_CONFIG_FILE" ]]; then
818
+ return 1
819
+ fi
820
+
821
+ yaml_write "$REVO_CONFIG_FILE"
822
+ }
823
+
824
+ # Get repos (optionally filtered by tag)
825
+ # Usage: repos=$(config_get_repos [tag])
826
+ config_get_repos() {
827
+ local tag="${1:-}"
828
+ yaml_get_repos "$tag"
829
+ }
830
+
831
+ # Get repo count
832
+ # Usage: count=$(config_repo_count [tag])
833
+ config_repo_count() {
834
+ local tag="${1:-}"
835
+ local count=0
836
+ local repos
837
+ repos=$(config_get_repos "$tag")
838
+
839
+ while IFS= read -r repo; do
840
+ [[ -n "$repo" ]] && count=$((count + 1))
841
+ done <<< "$repos"
842
+
843
+ printf '%d' "$count"
844
+ }
845
+
846
+ # Check if repo directory exists
847
+ # Usage: if config_repo_exists "repo_index"; then ...
848
+ config_repo_exists() {
849
+ local idx="$1"
850
+ local path
851
+ path=$(yaml_get_path "$idx")
852
+ [[ -d "$REVO_REPOS_DIR/$path" ]]
853
+ }
854
+
855
+ # Get full path to repo
856
+ # Usage: full_path=$(config_repo_full_path "repo_index")
857
+ config_repo_full_path() {
858
+ local idx="$1"
859
+ local path
860
+ path=$(yaml_get_path "$idx")
861
+ printf '%s/%s' "$REVO_REPOS_DIR" "$path"
862
+ }
863
+
864
+ # Require workspace context
865
+ # Usage: config_require_workspace || return 1
866
+ # Prints error and returns 1 if not in workspace
867
+ config_require_workspace() {
868
+ if ! config_find_root; then
869
+ printf 'Error: Not in a Revo workspace. Run "revo init" first.\n' >&2
870
+ return 1
871
+ fi
872
+ config_load
873
+ }
874
+
875
+ # Check if path is inside workspace
876
+ # Usage: if config_is_in_workspace "/some/path"; then ...
877
+ config_is_in_workspace() {
878
+ local path="$1"
879
+ [[ "$path" == "$REVO_WORKSPACE_ROOT"* ]]
880
+ }
881
+
882
+ # Get workspace name
883
+ config_workspace_name() {
884
+ printf '%s' "$YAML_WORKSPACE_NAME"
885
+ }
886
+
887
+ # Get default branch
888
+ config_default_branch() {
889
+ printf '%s' "$YAML_DEFAULTS_BRANCH"
890
+ }
891
+
892
+ # === lib/git.sh ===
893
+ # Revo CLI - Git Operations
894
+ # Wrapper functions for git commands with consistent error handling
895
+
896
+ # Result pattern: functions return 0 on success, non-zero on failure
897
+ # Output is captured in global variables to avoid subshell issues
898
+
899
+ GIT_OUTPUT=""
900
+ GIT_ERROR=""
901
+
902
+ # Clone a repository
903
+ # Usage: git_clone "url" "target_dir"
904
+ # Returns: 0 on success, 1 on failure
905
+ git_clone() {
906
+ local url="$1"
907
+ local target="$2"
908
+
909
+ GIT_OUTPUT=""
910
+ GIT_ERROR=""
911
+
912
+ if [[ -d "$target" ]]; then
913
+ GIT_ERROR="Directory already exists: $target"
914
+ return 1
915
+ fi
916
+
917
+ if GIT_OUTPUT=$(git clone --progress "$url" "$target" 2>&1); then
918
+ return 0
919
+ else
920
+ GIT_ERROR="$GIT_OUTPUT"
921
+ return 1
922
+ fi
923
+ }
924
+
925
+ # Get repository status
926
+ # Usage: git_status "repo_dir"
927
+ # Sets: GIT_OUTPUT with status info
928
+ # Returns: 0 on success
929
+ git_status() {
930
+ local repo_dir="$1"
931
+
932
+ GIT_OUTPUT=""
933
+ GIT_ERROR=""
934
+
935
+ if [[ ! -d "$repo_dir/.git" ]]; then
936
+ GIT_ERROR="Not a git repository: $repo_dir"
937
+ return 1
938
+ fi
939
+
940
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" status --porcelain 2>&1); then
941
+ GIT_ERROR="$GIT_OUTPUT"
942
+ return 1
943
+ fi
944
+
945
+ return 0
946
+ }
947
+
948
+ # Check if repo has uncommitted changes
949
+ # Usage: if git_is_dirty "repo_dir"; then ...
950
+ git_is_dirty() {
951
+ local repo_dir="$1"
952
+
953
+ git_status "$repo_dir" || return 1
954
+ [[ -n "$GIT_OUTPUT" ]]
955
+ }
956
+
957
+ # Get current branch name
958
+ # Usage: branch=$(git_current_branch "repo_dir")
959
+ git_current_branch() {
960
+ local repo_dir="$1"
961
+
962
+ git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null
963
+ }
964
+
965
+ # Create a new branch
966
+ # Usage: git_branch "repo_dir" "branch_name"
967
+ # Returns: 0 on success
968
+ git_branch() {
969
+ local repo_dir="$1"
970
+ local branch_name="$2"
971
+
972
+ GIT_OUTPUT=""
973
+ GIT_ERROR=""
974
+
975
+ if [[ ! -d "$repo_dir/.git" ]]; then
976
+ GIT_ERROR="Not a git repository: $repo_dir"
977
+ return 1
978
+ fi
979
+
980
+ # Check if branch already exists
981
+ if git -C "$repo_dir" rev-parse --verify "$branch_name" >/dev/null 2>&1; then
982
+ GIT_ERROR="Branch already exists: $branch_name"
983
+ return 1
984
+ fi
985
+
986
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" checkout -b "$branch_name" 2>&1); then
987
+ GIT_ERROR="$GIT_OUTPUT"
988
+ return 1
989
+ fi
990
+
991
+ return 0
992
+ }
993
+
994
+ # Checkout existing branch
995
+ # Usage: git_checkout "repo_dir" "branch_name"
996
+ # Returns: 0 on success
997
+ git_checkout() {
998
+ local repo_dir="$1"
999
+ local branch_name="$2"
1000
+
1001
+ GIT_OUTPUT=""
1002
+ GIT_ERROR=""
1003
+
1004
+ if [[ ! -d "$repo_dir/.git" ]]; then
1005
+ GIT_ERROR="Not a git repository: $repo_dir"
1006
+ return 1
1007
+ fi
1008
+
1009
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" checkout "$branch_name" 2>&1); then
1010
+ GIT_ERROR="$GIT_OUTPUT"
1011
+ return 1
1012
+ fi
1013
+
1014
+ return 0
1015
+ }
1016
+
1017
+ # Pull latest changes
1018
+ # Usage: git_pull "repo_dir" [--rebase]
1019
+ # Returns: 0 on success
1020
+ git_pull() {
1021
+ local repo_dir="$1"
1022
+ local rebase="${2:-}"
1023
+
1024
+ GIT_OUTPUT=""
1025
+ GIT_ERROR=""
1026
+
1027
+ if [[ ! -d "$repo_dir/.git" ]]; then
1028
+ GIT_ERROR="Not a git repository: $repo_dir"
1029
+ return 1
1030
+ fi
1031
+
1032
+ local args=()
1033
+ [[ "$rebase" == "--rebase" ]] && args+=("--rebase")
1034
+
1035
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" pull "${args[@]}" 2>&1); then
1036
+ GIT_ERROR="$GIT_OUTPUT"
1037
+ return 1
1038
+ fi
1039
+
1040
+ return 0
1041
+ }
1042
+
1043
+ # Fetch from remote
1044
+ # Usage: git_fetch "repo_dir"
1045
+ git_fetch() {
1046
+ local repo_dir="$1"
1047
+
1048
+ GIT_OUTPUT=""
1049
+ GIT_ERROR=""
1050
+
1051
+ if [[ ! -d "$repo_dir/.git" ]]; then
1052
+ GIT_ERROR="Not a git repository: $repo_dir"
1053
+ return 1
1054
+ fi
1055
+
1056
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" fetch 2>&1); then
1057
+ GIT_ERROR="$GIT_OUTPUT"
1058
+ return 1
1059
+ fi
1060
+
1061
+ return 0
1062
+ }
1063
+
1064
+ # Get ahead/behind counts relative to upstream
1065
+ # Usage: git_ahead_behind "repo_dir"
1066
+ # Sets: GIT_AHEAD, GIT_BEHIND
1067
+ GIT_AHEAD=0
1068
+ GIT_BEHIND=0
1069
+
1070
+ git_ahead_behind() {
1071
+ local repo_dir="$1"
1072
+
1073
+ GIT_AHEAD=0
1074
+ GIT_BEHIND=0
1075
+
1076
+ if [[ ! -d "$repo_dir/.git" ]]; then
1077
+ return 1
1078
+ fi
1079
+
1080
+ local upstream
1081
+ upstream=$(git -C "$repo_dir" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) || return 0
1082
+
1083
+ local counts
1084
+ counts=$(git -C "$repo_dir" rev-list --left-right --count "$upstream...HEAD" 2>/dev/null) || return 0
1085
+
1086
+ GIT_BEHIND=$(echo "$counts" | cut -f1)
1087
+ GIT_AHEAD=$(echo "$counts" | cut -f2)
1088
+
1089
+ return 0
1090
+ }
1091
+
1092
+ # Get remote URL
1093
+ # Usage: url=$(git_remote_url "repo_dir")
1094
+ git_remote_url() {
1095
+ local repo_dir="$1"
1096
+
1097
+ git -C "$repo_dir" remote get-url origin 2>/dev/null
1098
+ }
1099
+
1100
+ # Check if branch exists (local or remote)
1101
+ # Usage: if git_branch_exists "repo_dir" "branch_name"; then ...
1102
+ git_branch_exists() {
1103
+ local repo_dir="$1"
1104
+ local branch_name="$2"
1105
+
1106
+ git -C "$repo_dir" rev-parse --verify "$branch_name" >/dev/null 2>&1 ||
1107
+ git -C "$repo_dir" rev-parse --verify "origin/$branch_name" >/dev/null 2>&1
1108
+ }
1109
+
1110
+ # Stash changes
1111
+ # Usage: git_stash "repo_dir"
1112
+ git_stash() {
1113
+ local repo_dir="$1"
1114
+
1115
+ GIT_OUTPUT=""
1116
+ GIT_ERROR=""
1117
+
1118
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" stash 2>&1); then
1119
+ GIT_ERROR="$GIT_OUTPUT"
1120
+ return 1
1121
+ fi
1122
+
1123
+ return 0
1124
+ }
1125
+
1126
+ # Pop stash
1127
+ # Usage: git_stash_pop "repo_dir"
1128
+ git_stash_pop() {
1129
+ local repo_dir="$1"
1130
+
1131
+ GIT_OUTPUT=""
1132
+ GIT_ERROR=""
1133
+
1134
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" stash pop 2>&1); then
1135
+ GIT_ERROR="$GIT_OUTPUT"
1136
+ return 1
1137
+ fi
1138
+
1139
+ return 0
1140
+ }
1141
+
1142
+ # Get short commit hash
1143
+ # Usage: hash=$(git_short_hash "repo_dir")
1144
+ git_short_hash() {
1145
+ local repo_dir="$1"
1146
+
1147
+ git -C "$repo_dir" rev-parse --short HEAD 2>/dev/null
1148
+ }
1149
+
1150
+ # Run arbitrary git command
1151
+ # Usage: git_exec "repo_dir" "command" "args..."
1152
+ git_exec() {
1153
+ local repo_dir="$1"
1154
+ shift
1155
+ local cmd=("$@")
1156
+
1157
+ GIT_OUTPUT=""
1158
+ GIT_ERROR=""
1159
+
1160
+ if [[ ! -d "$repo_dir/.git" ]]; then
1161
+ GIT_ERROR="Not a git repository: $repo_dir"
1162
+ return 1
1163
+ fi
1164
+
1165
+ if ! GIT_OUTPUT=$(git -C "$repo_dir" "${cmd[@]}" 2>&1); then
1166
+ GIT_ERROR="$GIT_OUTPUT"
1167
+ return 1
1168
+ fi
1169
+
1170
+ return 0
1171
+ }
1172
+
1173
+ # === lib/scan.sh ===
1174
+ # Revo CLI - Repo Scanner
1175
+ # Detects language, framework, routes, and metadata per repo.
1176
+ # Uses globals to avoid subshells (bash 3.2 compatibility).
1177
+
1178
+ SCAN_NAME=""
1179
+ SCAN_LANG=""
1180
+ SCAN_FRAMEWORK=""
1181
+ SCAN_ROUTES=""
1182
+ SCAN_DESCRIPTION=""
1183
+ SCAN_HAS_CLAUDE_MD=0
1184
+ SCAN_HAS_DOCKER=0
1185
+
1186
+ # Reset all SCAN_* globals
1187
+ scan_reset() {
1188
+ SCAN_NAME=""
1189
+ SCAN_LANG=""
1190
+ SCAN_FRAMEWORK=""
1191
+ SCAN_ROUTES=""
1192
+ SCAN_DESCRIPTION=""
1193
+ SCAN_HAS_CLAUDE_MD=0
1194
+ SCAN_HAS_DOCKER=0
1195
+ }
1196
+
1197
+ # Extract a top-level JSON string value from a file.
1198
+ # Usage: val=$(_scan_json_string "path/to/file.json" "name")
1199
+ # Handles simple cases only; no nested objects.
1200
+ _scan_json_string() {
1201
+ local file="$1"
1202
+ local key="$2"
1203
+ local line
1204
+ # Match: "key": "value" (value may contain escaped chars but no quotes)
1205
+ line=$(grep -m1 -E "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null || true)
1206
+ [[ -z "$line" ]] && return 1
1207
+ # Strip to the value
1208
+ line="${line#*\"$key\"}"
1209
+ line="${line#*:}"
1210
+ line="${line#*\"}"
1211
+ line="${line%%\"*}"
1212
+ printf '%s' "$line"
1213
+ }
1214
+
1215
+ # Returns 0 if the package.json has "dep_name" anywhere in a dependencies block
1216
+ _scan_pkg_has_dep() {
1217
+ local file="$1"
1218
+ local dep="$2"
1219
+ grep -q "\"$dep\"[[:space:]]*:" "$file" 2>/dev/null
1220
+ }
1221
+
1222
+ # Detect framework from package.json dependencies
1223
+ _scan_node_framework() {
1224
+ local file="$1"
1225
+ if _scan_pkg_has_dep "$file" "next"; then
1226
+ printf 'Next.js'
1227
+ elif _scan_pkg_has_dep "$file" "nuxt"; then
1228
+ printf 'Nuxt'
1229
+ elif _scan_pkg_has_dep "$file" "@remix-run/react"; then
1230
+ printf 'Remix'
1231
+ elif _scan_pkg_has_dep "$file" "@sveltejs/kit"; then
1232
+ printf 'SvelteKit'
1233
+ elif _scan_pkg_has_dep "$file" "astro"; then
1234
+ printf 'Astro'
1235
+ elif _scan_pkg_has_dep "$file" "vite"; then
1236
+ printf 'Vite'
1237
+ elif _scan_pkg_has_dep "$file" "nestjs" || _scan_pkg_has_dep "$file" "@nestjs/core"; then
1238
+ printf 'NestJS'
1239
+ elif _scan_pkg_has_dep "$file" "fastify"; then
1240
+ printf 'Fastify'
1241
+ elif _scan_pkg_has_dep "$file" "express"; then
1242
+ printf 'Express'
1243
+ elif _scan_pkg_has_dep "$file" "hono"; then
1244
+ printf 'Hono'
1245
+ elif _scan_pkg_has_dep "$file" "react"; then
1246
+ printf 'React'
1247
+ elif _scan_pkg_has_dep "$file" "vue"; then
1248
+ printf 'Vue'
1249
+ elif _scan_pkg_has_dep "$file" "svelte"; then
1250
+ printf 'Svelte'
1251
+ fi
1252
+ }
1253
+
1254
+ # Detect framework from Python dependency files
1255
+ _scan_python_framework() {
1256
+ local repo_dir="$1"
1257
+ local files=()
1258
+ [[ -f "$repo_dir/requirements.txt" ]] && files+=("$repo_dir/requirements.txt")
1259
+ [[ -f "$repo_dir/pyproject.toml" ]] && files+=("$repo_dir/pyproject.toml")
1260
+ [[ -f "$repo_dir/Pipfile" ]] && files+=("$repo_dir/Pipfile")
1261
+
1262
+ [[ ${#files[@]} -eq 0 ]] && return
1263
+
1264
+ if grep -qiE '(^|[^a-z])django([^a-z]|$)' "${files[@]}" 2>/dev/null; then
1265
+ printf 'Django'
1266
+ elif grep -qiE '(^|[^a-z])fastapi([^a-z]|$)' "${files[@]}" 2>/dev/null; then
1267
+ printf 'FastAPI'
1268
+ elif grep -qiE '(^|[^a-z])flask([^a-z]|$)' "${files[@]}" 2>/dev/null; then
1269
+ printf 'Flask'
1270
+ elif grep -qiE '(^|[^a-z])starlette([^a-z]|$)' "${files[@]}" 2>/dev/null; then
1271
+ printf 'Starlette'
1272
+ fi
1273
+ }
1274
+
1275
+ # List route files in common route directories.
1276
+ # Returns comma-separated list (max 6 files, basename only).
1277
+ _scan_list_routes() {
1278
+ local repo_dir="$1"
1279
+ local dirs=(
1280
+ "src/routes"
1281
+ "src/api"
1282
+ "src/controllers"
1283
+ "app/api"
1284
+ "app/routes"
1285
+ "routes"
1286
+ "api"
1287
+ "pages/api"
1288
+ )
1289
+
1290
+ local found=()
1291
+ local dir
1292
+ for dir in "${dirs[@]}"; do
1293
+ local full="$repo_dir/$dir"
1294
+ [[ ! -d "$full" ]] && continue
1295
+
1296
+ # Find code files (not tests), max depth 2
1297
+ local file
1298
+ while IFS= read -r file; do
1299
+ [[ -z "$file" ]] && continue
1300
+ [[ "$file" == *".test."* ]] && continue
1301
+ [[ "$file" == *".spec."* ]] && continue
1302
+ found+=("$(basename "$file")")
1303
+ # Cap the number of results
1304
+ [[ ${#found[@]} -ge 6 ]] && break
1305
+ done < <(find "$full" -maxdepth 2 -type f \( -name "*.ts" -o -name "*.js" -o -name "*.tsx" -o -name "*.jsx" -o -name "*.py" -o -name "*.go" -o -name "*.rs" \) 2>/dev/null | sort)
1306
+
1307
+ [[ ${#found[@]} -ge 6 ]] && break
1308
+ done
1309
+
1310
+ # Join as comma-separated
1311
+ local result=""
1312
+ local f
1313
+ for f in "${found[@]}"; do
1314
+ if [[ -z "$result" ]]; then
1315
+ result="$f"
1316
+ else
1317
+ result="$result, $f"
1318
+ fi
1319
+ done
1320
+ printf '%s' "$result"
1321
+ }
1322
+
1323
+ # First non-empty, non-heading line of README.md (trimmed, first 100 chars)
1324
+ _scan_readme_description() {
1325
+ local readme="$1"
1326
+ [[ ! -f "$readme" ]] && return
1327
+
1328
+ local line
1329
+ while IFS= read -r line || [[ -n "$line" ]]; do
1330
+ # Skip empty/whitespace
1331
+ [[ -z "${line// }" ]] && continue
1332
+ # Skip heading lines
1333
+ [[ "$line" =~ ^[[:space:]]*# ]] && continue
1334
+ # Skip HTML tags (often used for logos/badges at the top)
1335
+ [[ "$line" =~ ^[[:space:]]*\< ]] && continue
1336
+ # Skip badge lines
1337
+ [[ "$line" =~ ^[[:space:]]*\[!\[ ]] && continue
1338
+
1339
+ # Trim
1340
+ line="${line#"${line%%[![:space:]]*}"}"
1341
+ line="${line%"${line##*[![:space:]]}"}"
1342
+
1343
+ # Cap length
1344
+ if [[ ${#line} -gt 100 ]]; then
1345
+ line="${line:0:100}..."
1346
+ fi
1347
+ printf '%s' "$line"
1348
+ return 0
1349
+ done < "$readme"
1350
+ }
1351
+
1352
+ # Scan a repository directory and populate SCAN_* globals.
1353
+ # Usage: scan_repo "/path/to/repo"
1354
+ scan_repo() {
1355
+ local repo_dir="$1"
1356
+ scan_reset
1357
+
1358
+ [[ ! -d "$repo_dir" ]] && return 1
1359
+
1360
+ # Node.js
1361
+ if [[ -f "$repo_dir/package.json" ]]; then
1362
+ SCAN_LANG="Node.js"
1363
+ SCAN_NAME=$(_scan_json_string "$repo_dir/package.json" "name" || true)
1364
+ SCAN_FRAMEWORK=$(_scan_node_framework "$repo_dir/package.json")
1365
+ fi
1366
+
1367
+ # Python
1368
+ if [[ -z "$SCAN_LANG" ]] && { [[ -f "$repo_dir/pyproject.toml" ]] || [[ -f "$repo_dir/requirements.txt" ]] || [[ -f "$repo_dir/Pipfile" ]]; }; then
1369
+ SCAN_LANG="Python"
1370
+ if [[ -f "$repo_dir/pyproject.toml" ]]; then
1371
+ local pyname
1372
+ pyname=$(grep -m1 -E '^[[:space:]]*name[[:space:]]*=' "$repo_dir/pyproject.toml" 2>/dev/null | sed -E 's/.*=[[:space:]]*"([^"]*)".*/\1/' || true)
1373
+ [[ -n "$pyname" ]] && SCAN_NAME="$pyname"
1374
+ fi
1375
+ SCAN_FRAMEWORK=$(_scan_python_framework "$repo_dir")
1376
+ fi
1377
+
1378
+ # Go
1379
+ if [[ -z "$SCAN_LANG" ]] && [[ -f "$repo_dir/go.mod" ]]; then
1380
+ SCAN_LANG="Go"
1381
+ SCAN_NAME=$(grep -m1 '^module ' "$repo_dir/go.mod" 2>/dev/null | awk '{print $2}' || true)
1382
+ fi
1383
+
1384
+ # Rust
1385
+ if [[ -z "$SCAN_LANG" ]] && [[ -f "$repo_dir/Cargo.toml" ]]; then
1386
+ SCAN_LANG="Rust"
1387
+ SCAN_NAME=$(grep -m1 -E '^[[:space:]]*name[[:space:]]*=' "$repo_dir/Cargo.toml" 2>/dev/null | sed -E 's/.*=[[:space:]]*"([^"]*)".*/\1/' || true)
1388
+ fi
1389
+
1390
+ # Routes
1391
+ SCAN_ROUTES=$(_scan_list_routes "$repo_dir")
1392
+
1393
+ # README description
1394
+ if [[ -f "$repo_dir/README.md" ]]; then
1395
+ SCAN_DESCRIPTION=$(_scan_readme_description "$repo_dir/README.md")
1396
+ elif [[ -f "$repo_dir/readme.md" ]]; then
1397
+ SCAN_DESCRIPTION=$(_scan_readme_description "$repo_dir/readme.md")
1398
+ fi
1399
+
1400
+ # CLAUDE.md
1401
+ if [[ -f "$repo_dir/CLAUDE.md" ]]; then
1402
+ SCAN_HAS_CLAUDE_MD=1
1403
+ fi
1404
+
1405
+ # Docker
1406
+ if [[ -f "$repo_dir/Dockerfile" ]] || [[ -f "$repo_dir/docker-compose.yml" ]] || [[ -f "$repo_dir/docker-compose.yaml" ]] || [[ -f "$repo_dir/compose.yml" ]] || [[ -f "$repo_dir/compose.yaml" ]]; then
1407
+ SCAN_HAS_DOCKER=1
1408
+ fi
1409
+
1410
+ return 0
1411
+ }
1412
+
1413
+ # === lib/commands/init.sh ===
1414
+ # Revo CLI - init command
1415
+ # Interactive workspace initialization
1416
+
1417
+ cmd_init() {
1418
+ local workspace_name=""
1419
+
1420
+ # Check if already initialized
1421
+ if [[ -f "revo.yaml" ]] || [[ -f "mars.yaml" ]]; then
1422
+ ui_step_error "Workspace already initialized in this directory"
1423
+ return 1
1424
+ fi
1425
+
1426
+ ui_intro "Revo - Claude-first Multi-Repo Workspace"
1427
+
1428
+ # Get workspace name
1429
+ ui_step "Workspace name?"
1430
+ printf '%s ' "$(ui_bar)"
1431
+ read -r workspace_name
1432
+
1433
+ if [[ -z "$workspace_name" ]]; then
1434
+ ui_outro_cancel "Cancelled - workspace name is required"
1435
+ return 1
1436
+ fi
1437
+
1438
+ ui_step_done "Workspace:" "$workspace_name"
1439
+ ui_bar_line
1440
+
1441
+ # Initialize workspace
1442
+ if ! config_init "$workspace_name"; then
1443
+ ui_step_error "Failed to initialize workspace"
1444
+ return 1
1445
+ fi
1446
+
1447
+ ui_step_done "Created revo.yaml"
1448
+ ui_step_done "Created .gitignore"
1449
+ ui_step_done "Created repos/ directory"
1450
+
1451
+ ui_outro "Workspace initialized! Run 'revo add <url>' to add repositories."
1452
+
1453
+ return 0
1454
+ }
1455
+
1456
+ # === lib/commands/clone.sh ===
1457
+ # Revo CLI - clone command
1458
+ # Clone configured repositories with per-repo progress
1459
+
1460
+ cmd_clone() {
1461
+ local tag=""
1462
+ local force=0
1463
+
1464
+ # Parse arguments
1465
+ while [[ $# -gt 0 ]]; do
1466
+ case "$1" in
1467
+ --tag)
1468
+ tag="$2"
1469
+ shift 2
1470
+ ;;
1471
+ --force|-f)
1472
+ force=1
1473
+ shift
1474
+ ;;
1475
+ *)
1476
+ ui_step_error "Unknown option: $1"
1477
+ return 1
1478
+ ;;
1479
+ esac
1480
+ done
1481
+
1482
+ config_require_workspace || return 1
1483
+
1484
+ ui_intro "Revo - Clone Repositories"
1485
+
1486
+ local repos
1487
+ repos=$(config_get_repos "$tag")
1488
+
1489
+ if [[ -z "$repos" ]]; then
1490
+ if [[ -n "$tag" ]]; then
1491
+ ui_step_error "No repositories found with tag: $tag"
1492
+ else
1493
+ ui_step_error "No repositories configured. Run 'revo add <url>' first."
1494
+ fi
1495
+ ui_outro_cancel "Nothing to clone"
1496
+ return 1
1497
+ fi
1498
+
1499
+ # Count total repos
1500
+ local total=0
1501
+ while IFS= read -r repo; do
1502
+ [[ -z "$repo" ]] && continue
1503
+ total=$((total + 1))
1504
+ done <<< "$repos"
1505
+
1506
+ local current=0
1507
+ local success_count=0
1508
+ local skip_count=0
1509
+ local fail_count=0
1510
+
1511
+ # Clone each repo with spinner feedback
1512
+ while IFS= read -r repo; do
1513
+ [[ -z "$repo" ]] && continue
1514
+ current=$((current + 1))
1515
+
1516
+ local url
1517
+ url=$(yaml_get_url "$repo")
1518
+ local path
1519
+ path=$(yaml_get_path "$repo")
1520
+ local full_path="$REVO_REPOS_DIR/$path"
1521
+
1522
+ # Already cloned?
1523
+ if [[ -d "$full_path" ]] && [[ $force -eq 0 ]]; then
1524
+ ui_step_done "Already cloned:" "$path"
1525
+ skip_count=$((skip_count + 1))
1526
+ continue
1527
+ fi
1528
+
1529
+ # Remove existing directory if force
1530
+ if [[ -d "$full_path" ]] && [[ $force -eq 1 ]]; then
1531
+ rm -rf "$full_path"
1532
+ fi
1533
+
1534
+ # Show spinner while cloning
1535
+ ui_spinner_start "Cloning $path... ($current/$total)"
1536
+
1537
+ local clone_err
1538
+ if clone_err=$(git clone --quiet "$url" "$full_path" 2>&1); then
1539
+ ui_spinner_stop
1540
+ ui_step_done "Cloned:" "$path"
1541
+ success_count=$((success_count + 1))
1542
+ else
1543
+ ui_spinner_error "Failed to clone: $path"
1544
+ if [[ -n "$clone_err" ]]; then
1545
+ ui_info "$(ui_dim "$clone_err")"
1546
+ fi
1547
+ fail_count=$((fail_count + 1))
1548
+ fi
1549
+ done <<< "$repos"
1550
+
1551
+ # Auto-generate workspace CLAUDE.md on first successful clone
1552
+ if [[ $fail_count -eq 0 ]] && [[ $success_count -gt 0 ]]; then
1553
+ context_autogenerate_if_missing
1554
+ fi
1555
+
1556
+ # Summary
1557
+ ui_bar_line
1558
+
1559
+ if [[ $fail_count -eq 0 ]]; then
1560
+ local msg="Cloned $success_count repositories successfully"
1561
+ if [[ $skip_count -gt 0 ]]; then
1562
+ msg="$msg, $skip_count already cloned"
1563
+ fi
1564
+ ui_outro "$msg"
1565
+ else
1566
+ ui_outro_cancel "Cloned $success_count, failed $fail_count"
1567
+ return 1
1568
+ fi
1569
+
1570
+ return 0
1571
+ }
1572
+
1573
+ # === lib/commands/status.sh ===
1574
+ # Revo CLI - status command
1575
+ # Show git status across all repositories
1576
+
1577
+ cmd_status() {
1578
+ local tag=""
1579
+
1580
+ # Parse arguments
1581
+ while [[ $# -gt 0 ]]; do
1582
+ case "$1" in
1583
+ --tag)
1584
+ tag="$2"
1585
+ shift 2
1586
+ ;;
1587
+ *)
1588
+ ui_step_error "Unknown option: $1"
1589
+ return 1
1590
+ ;;
1591
+ esac
1592
+ done
1593
+
1594
+ config_require_workspace || return 1
1595
+
1596
+ ui_intro "Revo - Repository Status"
1597
+
1598
+ local repos
1599
+ repos=$(config_get_repos "$tag")
1600
+
1601
+ if [[ -z "$repos" ]]; then
1602
+ ui_step_error "No repositories configured"
1603
+ ui_outro_cancel "Nothing to show"
1604
+ return 1
1605
+ fi
1606
+
1607
+ # Table header
1608
+ ui_table_widths 24 20 12 14
1609
+ ui_table_header "Repository" "Branch" "Status" "Sync"
1610
+
1611
+ local not_cloned=0
1612
+
1613
+ while IFS= read -r repo; do
1614
+ [[ -z "$repo" ]] && continue
1615
+
1616
+ local path
1617
+ path=$(yaml_get_path "$repo")
1618
+ local full_path="$REVO_REPOS_DIR/$path"
1619
+
1620
+ if [[ ! -d "$full_path" ]]; then
1621
+ ui_table_row "$path" "$(ui_dim "not cloned")" "-" "-"
1622
+ not_cloned=$((not_cloned + 1))
1623
+ continue
1624
+ fi
1625
+
1626
+ # Get branch
1627
+ local branch
1628
+ branch=$(git_current_branch "$full_path")
1629
+
1630
+ # Get dirty status
1631
+ local status_text
1632
+ if git_is_dirty "$full_path"; then
1633
+ status_text="$(ui_yellow "dirty")"
1634
+ else
1635
+ status_text="$(ui_green "clean")"
1636
+ fi
1637
+
1638
+ # Get ahead/behind
1639
+ git_ahead_behind "$full_path"
1640
+ local sync_text=""
1641
+
1642
+ if [[ $GIT_AHEAD -gt 0 ]] && [[ $GIT_BEHIND -gt 0 ]]; then
1643
+ sync_text="$(ui_yellow "↑$GIT_AHEAD ↓$GIT_BEHIND")"
1644
+ elif [[ $GIT_AHEAD -gt 0 ]]; then
1645
+ sync_text="$(ui_cyan "↑$GIT_AHEAD")"
1646
+ elif [[ $GIT_BEHIND -gt 0 ]]; then
1647
+ sync_text="$(ui_yellow "↓$GIT_BEHIND")"
1648
+ else
1649
+ sync_text="$(ui_green "synced")"
1650
+ fi
1651
+
1652
+ ui_table_row "$path" "$branch" "$status_text" "$sync_text"
1653
+ done <<< "$repos"
1654
+
1655
+ ui_bar_line
1656
+
1657
+ if [[ $not_cloned -gt 0 ]]; then
1658
+ ui_info "$(ui_dim "$not_cloned repository(ies) not cloned. Run 'revo clone' to clone them.")"
1659
+ fi
1660
+
1661
+ ui_outro "Status complete"
1662
+
1663
+ return 0
1664
+ }
1665
+
1666
+ # === lib/commands/branch.sh ===
1667
+ # Revo CLI - branch command
1668
+ # Create a new branch across repositories
1669
+
1670
+ cmd_branch() {
1671
+ local branch_name=""
1672
+ local tag=""
1673
+
1674
+ # Parse arguments
1675
+ while [[ $# -gt 0 ]]; do
1676
+ case "$1" in
1677
+ --tag)
1678
+ tag="$2"
1679
+ shift 2
1680
+ ;;
1681
+ -*)
1682
+ ui_step_error "Unknown option: $1"
1683
+ return 1
1684
+ ;;
1685
+ *)
1686
+ if [[ -z "$branch_name" ]]; then
1687
+ branch_name="$1"
1688
+ else
1689
+ ui_step_error "Unexpected argument: $1"
1690
+ return 1
1691
+ fi
1692
+ shift
1693
+ ;;
1694
+ esac
1695
+ done
1696
+
1697
+ if [[ -z "$branch_name" ]]; then
1698
+ ui_step_error "Usage: revo branch <branch-name> [--tag TAG]"
1699
+ return 1
1700
+ fi
1701
+
1702
+ config_require_workspace || return 1
1703
+
1704
+ ui_intro "Revo - Create Branch: $branch_name"
1705
+
1706
+ local repos
1707
+ repos=$(config_get_repos "$tag")
1708
+
1709
+ if [[ -z "$repos" ]]; then
1710
+ ui_step_error "No repositories configured"
1711
+ ui_outro_cancel "Nothing to do"
1712
+ return 1
1713
+ fi
1714
+
1715
+ local success_count=0
1716
+ local skip_count=0
1717
+ local fail_count=0
1718
+
1719
+ while IFS= read -r repo; do
1720
+ [[ -z "$repo" ]] && continue
1721
+
1722
+ local path
1723
+ path=$(yaml_get_path "$repo")
1724
+ local full_path="$REVO_REPOS_DIR/$path"
1725
+
1726
+ if [[ ! -d "$full_path" ]]; then
1727
+ ui_step_done "Skipped (not cloned):" "$path"
1728
+ skip_count=$((skip_count + 1))
1729
+ continue
1730
+ fi
1731
+
1732
+ # Check if branch already exists
1733
+ if git_branch_exists "$full_path" "$branch_name"; then
1734
+ # Try to checkout instead
1735
+ if git_checkout "$full_path" "$branch_name"; then
1736
+ ui_step_done "Checked out existing:" "$path → $branch_name"
1737
+ success_count=$((success_count + 1))
1738
+ else
1739
+ ui_step_error "Failed to checkout existing branch: $path"
1740
+ fail_count=$((fail_count + 1))
1741
+ fi
1742
+ continue
1743
+ fi
1744
+
1745
+ # Create new branch
1746
+ if git_branch "$full_path" "$branch_name"; then
1747
+ ui_step_done "Created:" "$path → $branch_name"
1748
+ success_count=$((success_count + 1))
1749
+ else
1750
+ ui_step_error "Failed: $path - $GIT_ERROR"
1751
+ fail_count=$((fail_count + 1))
1752
+ fi
1753
+ done <<< "$repos"
1754
+
1755
+ ui_bar_line
1756
+
1757
+ if [[ $fail_count -eq 0 ]]; then
1758
+ local msg="Branch '$branch_name' created on $success_count repo(s)"
1759
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
1760
+ ui_outro "$msg"
1761
+ else
1762
+ ui_outro_cancel "$success_count succeeded, $fail_count failed"
1763
+ return 1
1764
+ fi
1765
+
1766
+ return 0
1767
+ }
1768
+
1769
+ # === lib/commands/checkout.sh ===
1770
+ # Revo CLI - checkout command
1771
+ # Checkout a branch across repositories
1772
+
1773
+ cmd_checkout() {
1774
+ local branch_name=""
1775
+ local tag=""
1776
+ local force=0
1777
+
1778
+ # Parse arguments
1779
+ while [[ $# -gt 0 ]]; do
1780
+ case "$1" in
1781
+ --tag)
1782
+ tag="$2"
1783
+ shift 2
1784
+ ;;
1785
+ --force|-f)
1786
+ force=1
1787
+ shift
1788
+ ;;
1789
+ -*)
1790
+ ui_step_error "Unknown option: $1"
1791
+ return 1
1792
+ ;;
1793
+ *)
1794
+ if [[ -z "$branch_name" ]]; then
1795
+ branch_name="$1"
1796
+ else
1797
+ ui_step_error "Unexpected argument: $1"
1798
+ return 1
1799
+ fi
1800
+ shift
1801
+ ;;
1802
+ esac
1803
+ done
1804
+
1805
+ if [[ -z "$branch_name" ]]; then
1806
+ ui_step_error "Usage: revo checkout <branch-name> [--tag TAG] [--force]"
1807
+ return 1
1808
+ fi
1809
+
1810
+ config_require_workspace || return 1
1811
+
1812
+ ui_intro "Revo - Checkout Branch: $branch_name"
1813
+
1814
+ local repos
1815
+ repos=$(config_get_repos "$tag")
1816
+
1817
+ if [[ -z "$repos" ]]; then
1818
+ ui_step_error "No repositories configured"
1819
+ ui_outro_cancel "Nothing to do"
1820
+ return 1
1821
+ fi
1822
+
1823
+ local success_count=0
1824
+ local skip_count=0
1825
+ local fail_count=0
1826
+ local dirty_repos=()
1827
+
1828
+ while IFS= read -r repo; do
1829
+ [[ -z "$repo" ]] && continue
1830
+
1831
+ local path
1832
+ path=$(yaml_get_path "$repo")
1833
+ local full_path="$REVO_REPOS_DIR/$path"
1834
+
1835
+ if [[ ! -d "$full_path" ]]; then
1836
+ ui_step_done "Skipped (not cloned):" "$path"
1837
+ skip_count=$((skip_count + 1))
1838
+ continue
1839
+ fi
1840
+
1841
+ # Check for uncommitted changes
1842
+ if git_is_dirty "$full_path" && [[ $force -eq 0 ]]; then
1843
+ ui_step_error "Uncommitted changes: $path"
1844
+ dirty_repos+=("$path")
1845
+ fail_count=$((fail_count + 1))
1846
+ continue
1847
+ fi
1848
+
1849
+ # Check if branch exists
1850
+ if ! git_branch_exists "$full_path" "$branch_name"; then
1851
+ ui_step_error "Branch not found: $path"
1852
+ fail_count=$((fail_count + 1))
1853
+ continue
1854
+ fi
1855
+
1856
+ # Checkout
1857
+ if git_checkout "$full_path" "$branch_name"; then
1858
+ ui_step_done "Checked out:" "$path → $branch_name"
1859
+ success_count=$((success_count + 1))
1860
+ else
1861
+ ui_step_error "Failed: $path - $GIT_ERROR"
1862
+ fail_count=$((fail_count + 1))
1863
+ fi
1864
+ done <<< "$repos"
1865
+
1866
+ ui_bar_line
1867
+
1868
+ if [[ ${#dirty_repos[@]} -gt 0 ]]; then
1869
+ ui_info "$(ui_yellow "Hint: Use --force to checkout despite uncommitted changes")"
1870
+ fi
1871
+
1872
+ if [[ $fail_count -eq 0 ]]; then
1873
+ local msg="Checked out '$branch_name' on $success_count repo(s)"
1874
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
1875
+ ui_outro "$msg"
1876
+ else
1877
+ ui_outro_cancel "$success_count succeeded, $fail_count failed"
1878
+ return 1
1879
+ fi
1880
+
1881
+ return 0
1882
+ }
1883
+
1884
+ # === lib/commands/sync.sh ===
1885
+ # Revo CLI - sync command
1886
+ # Pull latest changes across repositories
1887
+
1888
+ cmd_sync() {
1889
+ local tag=""
1890
+ local rebase=0
1891
+
1892
+ # Parse arguments
1893
+ while [[ $# -gt 0 ]]; do
1894
+ case "$1" in
1895
+ --tag)
1896
+ tag="$2"
1897
+ shift 2
1898
+ ;;
1899
+ --rebase|-r)
1900
+ rebase=1
1901
+ shift
1902
+ ;;
1903
+ *)
1904
+ ui_step_error "Unknown option: $1"
1905
+ return 1
1906
+ ;;
1907
+ esac
1908
+ done
1909
+
1910
+ config_require_workspace || return 1
1911
+
1912
+ ui_intro "Revo - Sync Repositories"
1913
+
1914
+ local repos
1915
+ repos=$(config_get_repos "$tag")
1916
+
1917
+ if [[ -z "$repos" ]]; then
1918
+ ui_step_error "No repositories configured"
1919
+ ui_outro_cancel "Nothing to sync"
1920
+ return 1
1921
+ fi
1922
+
1923
+ local success_count=0
1924
+ local skip_count=0
1925
+ local fail_count=0
1926
+ local conflict_repos=()
1927
+
1928
+ while IFS= read -r repo; do
1929
+ [[ -z "$repo" ]] && continue
1930
+
1931
+ local path
1932
+ path=$(yaml_get_path "$repo")
1933
+ local full_path="$REVO_REPOS_DIR/$path"
1934
+
1935
+ if [[ ! -d "$full_path" ]]; then
1936
+ ui_step_done "Skipped (not cloned):" "$path"
1937
+ skip_count=$((skip_count + 1))
1938
+ continue
1939
+ fi
1940
+
1941
+ # Fetch first
1942
+ git_fetch "$full_path"
1943
+
1944
+ # Pull
1945
+ local pull_args=""
1946
+ [[ $rebase -eq 1 ]] && pull_args="--rebase"
1947
+
1948
+ if git_pull "$full_path" $pull_args; then
1949
+ # Check what happened
1950
+ if [[ "$GIT_OUTPUT" == *"Already up to date"* ]]; then
1951
+ ui_step_done "Up to date:" "$path"
1952
+ else
1953
+ ui_step_done "Updated:" "$path"
1954
+ fi
1955
+ success_count=$((success_count + 1))
1956
+ else
1957
+ # Check for conflicts
1958
+ if [[ "$GIT_ERROR" == *"conflict"* ]] || [[ "$GIT_ERROR" == *"CONFLICT"* ]]; then
1959
+ ui_step_error "Conflict: $path"
1960
+ conflict_repos+=("$path")
1961
+ else
1962
+ ui_step_error "Failed: $path"
1963
+ fi
1964
+ fail_count=$((fail_count + 1))
1965
+ fi
1966
+ done <<< "$repos"
1967
+
1968
+ ui_bar_line
1969
+
1970
+ if [[ ${#conflict_repos[@]} -gt 0 ]]; then
1971
+ ui_info "$(ui_yellow "Repositories with conflicts:")"
1972
+ for r in "${conflict_repos[@]}"; do
1973
+ ui_info " $(ui_yellow "$r")"
1974
+ done
1975
+ fi
1976
+
1977
+ if [[ $fail_count -eq 0 ]]; then
1978
+ local msg="Synced $success_count repo(s)"
1979
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
1980
+ ui_outro "$msg"
1981
+ else
1982
+ ui_outro_cancel "$success_count synced, $fail_count failed"
1983
+ return 1
1984
+ fi
1985
+
1986
+ return 0
1987
+ }
1988
+
1989
+ # === lib/commands/exec.sh ===
1990
+ # Revo CLI - exec command
1991
+ # Run command in each repository
1992
+
1993
+ cmd_exec() {
1994
+ local command=""
1995
+ local tag=""
1996
+ local quiet=0
1997
+
1998
+ # Parse arguments
1999
+ while [[ $# -gt 0 ]]; do
2000
+ case "$1" in
2001
+ --tag)
2002
+ tag="$2"
2003
+ shift 2
2004
+ ;;
2005
+ --quiet|-q)
2006
+ quiet=1
2007
+ shift
2008
+ ;;
2009
+ --)
2010
+ shift
2011
+ command="$*"
2012
+ break
2013
+ ;;
2014
+ -*)
2015
+ ui_step_error "Unknown option: $1"
2016
+ return 1
2017
+ ;;
2018
+ *)
2019
+ if [[ -z "$command" ]]; then
2020
+ command="$1"
2021
+ else
2022
+ ui_step_error "Unexpected argument: $1"
2023
+ return 1
2024
+ fi
2025
+ shift
2026
+ ;;
2027
+ esac
2028
+ done
2029
+
2030
+ if [[ -z "$command" ]]; then
2031
+ ui_step_error "Usage: revo exec \"<command>\" [--tag TAG]"
2032
+ return 1
2033
+ fi
2034
+
2035
+ config_require_workspace || return 1
2036
+
2037
+ ui_intro "Revo - Execute: $command"
2038
+
2039
+ local repos
2040
+ repos=$(config_get_repos "$tag")
2041
+
2042
+ if [[ -z "$repos" ]]; then
2043
+ ui_step_error "No repositories configured"
2044
+ ui_outro_cancel "Nothing to do"
2045
+ return 1
2046
+ fi
2047
+
2048
+ local success_count=0
2049
+ local skip_count=0
2050
+ local fail_count=0
2051
+
2052
+ while IFS= read -r repo; do
2053
+ [[ -z "$repo" ]] && continue
2054
+
2055
+ local path
2056
+ path=$(yaml_get_path "$repo")
2057
+ local full_path="$REVO_REPOS_DIR/$path"
2058
+
2059
+ if [[ ! -d "$full_path" ]]; then
2060
+ ui_step_done "Skipped (not cloned):" "$path"
2061
+ skip_count=$((skip_count + 1))
2062
+ continue
2063
+ fi
2064
+
2065
+ ui_step "Running in: $path"
2066
+ ui_bar_line
2067
+
2068
+ # Execute command in repo directory
2069
+ local output
2070
+ local exit_code
2071
+
2072
+ if output=$(cd "$full_path" && eval "$command" 2>&1); then
2073
+ exit_code=0
2074
+ else
2075
+ exit_code=$?
2076
+ fi
2077
+
2078
+ # Show output if not quiet
2079
+ if [[ $quiet -eq 0 ]] && [[ -n "$output" ]]; then
2080
+ while IFS= read -r line; do
2081
+ printf '%s %s\n' "$(ui_bar)" "$(ui_dim "$line")"
2082
+ done <<< "$output"
2083
+ fi
2084
+
2085
+ if [[ $exit_code -eq 0 ]]; then
2086
+ ui_step_done "Success:" "$path"
2087
+ success_count=$((success_count + 1))
2088
+ else
2089
+ ui_step_error "Failed (exit $exit_code): $path"
2090
+ fail_count=$((fail_count + 1))
2091
+ fi
2092
+
2093
+ ui_bar_line
2094
+ done <<< "$repos"
2095
+
2096
+ if [[ $fail_count -eq 0 ]]; then
2097
+ local msg="Executed on $success_count repo(s)"
2098
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
2099
+ ui_outro "$msg"
2100
+ else
2101
+ ui_outro_cancel "$success_count succeeded, $fail_count failed"
2102
+ return 1
2103
+ fi
2104
+
2105
+ return 0
2106
+ }
2107
+
2108
+ # === lib/commands/add.sh ===
2109
+ # Revo CLI - add command
2110
+ # Add a repository to the workspace configuration
2111
+
2112
+ cmd_add() {
2113
+ local url=""
2114
+ local path=""
2115
+ local tags=""
2116
+ local deps=""
2117
+
2118
+ # Parse arguments
2119
+ while [[ $# -gt 0 ]]; do
2120
+ case "$1" in
2121
+ --tags)
2122
+ tags="$2"
2123
+ shift 2
2124
+ ;;
2125
+ --path)
2126
+ path="$2"
2127
+ shift 2
2128
+ ;;
2129
+ --depends-on)
2130
+ deps="$2"
2131
+ shift 2
2132
+ ;;
2133
+ -*)
2134
+ ui_step_error "Unknown option: $1"
2135
+ return 1
2136
+ ;;
2137
+ *)
2138
+ if [[ -z "$url" ]]; then
2139
+ url="$1"
2140
+ else
2141
+ ui_step_error "Unexpected argument: $1"
2142
+ return 1
2143
+ fi
2144
+ shift
2145
+ ;;
2146
+ esac
2147
+ done
2148
+
2149
+ if [[ -z "$url" ]]; then
2150
+ ui_step_error "Usage: revo add <url> [--tags tag1,tag2] [--path custom-path] [--depends-on repo1,repo2]"
2151
+ return 1
2152
+ fi
2153
+
2154
+ config_require_workspace || return 1
2155
+
2156
+ # Derive path from URL if not provided
2157
+ if [[ -z "$path" ]]; then
2158
+ path=$(yaml_path_from_url "$url")
2159
+ fi
2160
+
2161
+ # Check if repo already exists
2162
+ local i
2163
+ for ((i = 0; i < YAML_REPO_COUNT; i++)); do
2164
+ local existing_url
2165
+ existing_url=$(yaml_get_url "$i")
2166
+ if [[ "$existing_url" == "$url" ]]; then
2167
+ ui_step_error "Repository already configured: $url"
2168
+ return 1
2169
+ fi
2170
+
2171
+ local existing_path
2172
+ existing_path=$(yaml_get_path "$i")
2173
+ if [[ "$existing_path" == "$path" ]]; then
2174
+ ui_step_error "Path already in use: $path"
2175
+ return 1
2176
+ fi
2177
+ done
2178
+
2179
+ ui_intro "Revo - Add Repository"
2180
+
2181
+ # Add to config
2182
+ yaml_add_repo "$url" "$path" "$tags" "$deps"
2183
+
2184
+ # Save config
2185
+ config_save
2186
+
2187
+ ui_step_done "Added repository:" "$path"
2188
+
2189
+ if [[ -n "$tags" ]]; then
2190
+ ui_info "Tags: $tags"
2191
+ fi
2192
+
2193
+ if [[ -n "$deps" ]]; then
2194
+ ui_info "Depends on: $deps"
2195
+ fi
2196
+
2197
+ ui_bar_line
2198
+ ui_info "$(ui_dim "Run 'revo clone' to clone the repository")"
2199
+
2200
+ ui_outro "Repository added to workspace"
2201
+
2202
+ return 0
2203
+ }
2204
+
2205
+ # === lib/commands/list.sh ===
2206
+ # Revo CLI - list command
2207
+ # List configured repositories
2208
+
2209
+ cmd_list() {
2210
+ local tag=""
2211
+
2212
+ # Parse arguments
2213
+ while [[ $# -gt 0 ]]; do
2214
+ case "$1" in
2215
+ --tag)
2216
+ tag="$2"
2217
+ shift 2
2218
+ ;;
2219
+ *)
2220
+ ui_step_error "Unknown option: $1"
2221
+ return 1
2222
+ ;;
2223
+ esac
2224
+ done
2225
+
2226
+ config_require_workspace || return 1
2227
+
2228
+ ui_intro "Revo - Configured Repositories"
2229
+
2230
+ local repos
2231
+ repos=$(config_get_repos "$tag")
2232
+
2233
+ if [[ -z "$repos" ]]; then
2234
+ if [[ -n "$tag" ]]; then
2235
+ ui_info "No repositories found with tag: $tag"
2236
+ else
2237
+ ui_info "No repositories configured"
2238
+ ui_bar_line
2239
+ ui_info "$(ui_dim "Run 'revo add <url>' to add a repository")"
2240
+ fi
2241
+ ui_outro "List complete"
2242
+ return 0
2243
+ fi
2244
+
2245
+ # Table header
2246
+ ui_table_widths 24 24 10
2247
+ ui_table_header "Path" "Tags" "Cloned"
2248
+
2249
+ local total=0
2250
+ local cloned=0
2251
+
2252
+ while IFS= read -r repo; do
2253
+ [[ -z "$repo" ]] && continue
2254
+ total=$((total + 1))
2255
+
2256
+ local path
2257
+ path=$(yaml_get_path "$repo")
2258
+ local tags
2259
+ tags=$(yaml_get_tags "$repo")
2260
+ local full_path="$REVO_REPOS_DIR/$path"
2261
+
2262
+ local cloned_text
2263
+ if [[ -d "$full_path" ]]; then
2264
+ cloned_text="$(ui_green "yes")"
2265
+ cloned=$((cloned + 1))
2266
+ else
2267
+ cloned_text="$(ui_dim "no")"
2268
+ fi
2269
+
2270
+ local tags_text
2271
+ if [[ -n "$tags" ]]; then
2272
+ tags_text="$tags"
2273
+ else
2274
+ tags_text="$(ui_dim "-")"
2275
+ fi
2276
+
2277
+ ui_table_row "$path" "$tags_text" "$cloned_text"
2278
+ done <<< "$repos"
2279
+
2280
+ ui_bar_line
2281
+ ui_info "Total: $total repositories, $cloned cloned"
2282
+
2283
+ ui_outro "List complete"
2284
+
2285
+ return 0
2286
+ }
2287
+
2288
+ # === lib/commands/context.sh ===
2289
+ # Revo CLI - context command
2290
+ # Scans all cloned repos and writes a workspace-level CLAUDE.md.
2291
+
2292
+ # Topological sort over YAML_REPO_DEPS.
2293
+ # Sets CONTEXT_ORDER to newline-separated repo indices (roots first).
2294
+ # Sets CONTEXT_CYCLE=1 if a cycle was detected (remaining nodes are appended in input order).
2295
+ CONTEXT_ORDER=""
2296
+ CONTEXT_CYCLE=0
2297
+
2298
+ _context_topo_sort() {
2299
+ CONTEXT_ORDER=""
2300
+ CONTEXT_CYCLE=0
2301
+
2302
+ local n="$YAML_REPO_COUNT"
2303
+ [[ $n -eq 0 ]] && return 0
2304
+
2305
+ # placed[i]=1 once emitted
2306
+ local placed=()
2307
+ local i
2308
+ for ((i = 0; i < n; i++)); do
2309
+ placed[$i]=0
2310
+ done
2311
+
2312
+ local remaining=$n
2313
+ local order=""
2314
+
2315
+ # Repeat: find a node whose deps are all placed (or missing)
2316
+ while [[ $remaining -gt 0 ]]; do
2317
+ local made_progress=0
2318
+ for ((i = 0; i < n; i++)); do
2319
+ [[ ${placed[$i]} -eq 1 ]] && continue
2320
+
2321
+ local deps="${YAML_REPO_DEPS[$i]:-}"
2322
+ local all_ready=1
2323
+
2324
+ if [[ -n "$deps" ]]; then
2325
+ # Iterate over comma-separated dep names
2326
+ local dep
2327
+ local old_ifs="$IFS"
2328
+ IFS=','
2329
+ for dep in $deps; do
2330
+ [[ -z "$dep" ]] && continue
2331
+ local dep_idx
2332
+ dep_idx=$(yaml_find_by_name "$dep")
2333
+ # If the dep isn't in our config, ignore it
2334
+ if [[ "$dep_idx" -lt 0 ]]; then
2335
+ continue
2336
+ fi
2337
+ if [[ ${placed[$dep_idx]} -eq 0 ]]; then
2338
+ all_ready=0
2339
+ break
2340
+ fi
2341
+ done
2342
+ IFS="$old_ifs"
2343
+ fi
2344
+
2345
+ if [[ $all_ready -eq 1 ]]; then
2346
+ if [[ -z "$order" ]]; then
2347
+ order="$i"
2348
+ else
2349
+ order="$order"$'\n'"$i"
2350
+ fi
2351
+ placed[$i]=1
2352
+ remaining=$((remaining - 1))
2353
+ made_progress=1
2354
+ fi
2355
+ done
2356
+
2357
+ if [[ $made_progress -eq 0 ]]; then
2358
+ # Cycle or unresolvable: append remaining in input order and stop
2359
+ CONTEXT_CYCLE=1
2360
+ for ((i = 0; i < n; i++)); do
2361
+ if [[ ${placed[$i]} -eq 0 ]]; then
2362
+ if [[ -z "$order" ]]; then
2363
+ order="$i"
2364
+ else
2365
+ order="$order"$'\n'"$i"
2366
+ fi
2367
+ placed[$i]=1
2368
+ fi
2369
+ done
2370
+ remaining=0
2371
+ fi
2372
+ done
2373
+
2374
+ CONTEXT_ORDER="$order"
2375
+ return 0
2376
+ }
2377
+
2378
+ # Write the workspace CLAUDE.md to the given path.
2379
+ # Usage: _context_write_file "/path/to/CLAUDE.md"
2380
+ _context_write_file() {
2381
+ local output="$1"
2382
+ local workspace
2383
+ workspace=$(config_workspace_name)
2384
+
2385
+ # Start file
2386
+ {
2387
+ printf '# Workspace Context (auto-generated by revo)\n'
2388
+ printf '\n'
2389
+ printf 'This workspace contains multiple repositories managed by revo.\n'
2390
+ if [[ -n "$workspace" ]]; then
2391
+ printf 'Workspace: **%s**\n' "$workspace"
2392
+ fi
2393
+ printf '\n'
2394
+ printf '> This file is regenerated by `revo context`. Manual edits below the\n'
2395
+ printf '> `## Agent Instructions` section will be preserved across regeneration.\n'
2396
+ printf '\n'
2397
+ printf '## Repos\n'
2398
+ printf '\n'
2399
+ } > "$output"
2400
+
2401
+ # Per-repo details
2402
+ local i
2403
+ for ((i = 0; i < YAML_REPO_COUNT; i++)); do
2404
+ local path tags deps url full_path
2405
+ path=$(yaml_get_path "$i")
2406
+ tags=$(yaml_get_tags "$i")
2407
+ deps=$(yaml_get_deps "$i")
2408
+ url=$(yaml_get_url "$i")
2409
+ full_path="$REVO_REPOS_DIR/$path"
2410
+
2411
+ {
2412
+ printf '### %s\n' "$path"
2413
+
2414
+ if [[ -n "$tags" ]]; then
2415
+ printf -- '- **Tags:** %s\n' "$tags"
2416
+ fi
2417
+ printf -- '- **Path:** repos/%s\n' "$path"
2418
+ if [[ -n "$deps" ]]; then
2419
+ printf -- '- **Depends on:** %s\n' "$deps"
2420
+ fi
2421
+ } >> "$output"
2422
+
2423
+ if [[ -d "$full_path" ]]; then
2424
+ scan_repo "$full_path"
2425
+
2426
+ if [[ -n "$SCAN_LANG" ]]; then
2427
+ if [[ -n "$SCAN_NAME" ]]; then
2428
+ printf -- '- **Package:** %s (%s)\n' "$SCAN_NAME" "$SCAN_LANG" >> "$output"
2429
+ else
2430
+ printf -- '- **Language:** %s\n' "$SCAN_LANG" >> "$output"
2431
+ fi
2432
+ fi
2433
+
2434
+ if [[ -n "$SCAN_FRAMEWORK" ]]; then
2435
+ printf -- '- **Framework:** %s\n' "$SCAN_FRAMEWORK" >> "$output"
2436
+ fi
2437
+
2438
+ if [[ -n "$SCAN_ROUTES" ]]; then
2439
+ printf -- '- **API routes:** %s\n' "$SCAN_ROUTES" >> "$output"
2440
+ fi
2441
+
2442
+ if [[ -n "$SCAN_DESCRIPTION" ]]; then
2443
+ printf -- '- **Description:** %s\n' "$SCAN_DESCRIPTION" >> "$output"
2444
+ fi
2445
+
2446
+ if [[ $SCAN_HAS_CLAUDE_MD -eq 1 ]]; then
2447
+ printf -- '- **Has CLAUDE.md:** yes (repo-level context available)\n' >> "$output"
2448
+ fi
2449
+
2450
+ if [[ $SCAN_HAS_DOCKER -eq 1 ]]; then
2451
+ printf -- '- **Containerized:** yes\n' >> "$output"
2452
+ fi
2453
+ else
2454
+ printf -- '- **Status:** not cloned (run `revo clone`)\n' >> "$output"
2455
+ if [[ -n "$url" ]]; then
2456
+ printf -- '- **URL:** %s\n' "$url" >> "$output"
2457
+ fi
2458
+ fi
2459
+
2460
+ printf '\n' >> "$output"
2461
+ done
2462
+
2463
+ # Dependency order
2464
+ {
2465
+ printf '## Dependency Order\n'
2466
+ printf '\n'
2467
+ printf 'When making cross-repo changes, follow this order:\n'
2468
+ printf '\n'
2469
+ } >> "$output"
2470
+
2471
+ _context_topo_sort
2472
+
2473
+ local step=1
2474
+ local idx
2475
+ while IFS= read -r idx; do
2476
+ [[ -z "$idx" ]] && continue
2477
+ local path deps
2478
+ path=$(yaml_get_path "$idx")
2479
+ deps=$(yaml_get_deps "$idx")
2480
+ if [[ -n "$deps" ]]; then
2481
+ printf '%d. **%s** (after: %s)\n' "$step" "$path" "$deps" >> "$output"
2482
+ else
2483
+ printf '%d. **%s**\n' "$step" "$path" >> "$output"
2484
+ fi
2485
+ step=$((step + 1))
2486
+ done <<< "$CONTEXT_ORDER"
2487
+
2488
+ if [[ $CONTEXT_CYCLE -eq 1 ]]; then
2489
+ printf '\n> Warning: a dependency cycle was detected. Listed in best-effort order.\n' >> "$output"
2490
+ fi
2491
+
2492
+ # Agent instructions
2493
+ {
2494
+ printf '\n'
2495
+ printf '## Agent Instructions\n'
2496
+ printf '\n'
2497
+ printf 'When working in this workspace:\n'
2498
+ printf '\n'
2499
+ printf '1. Read this file first to understand the repo structure\n'
2500
+ printf '2. Check `.revo/features/` for active feature contexts\n'
2501
+ printf '3. Follow the dependency order above when making cross-repo changes\n'
2502
+ printf '4. Each repo may have its own CLAUDE.md with repo-specific instructions\n'
2503
+ printf '5. Use `revo status` to check state across all repos\n'
2504
+ printf '6. Use `revo commit "msg"` to commit across all repos at once\n'
2505
+ printf '7. Use `revo feature <name>` to start a coordinated feature workspace\n'
2506
+ printf '8. Use `revo pr "title"` to open coordinated pull requests\n'
2507
+ } >> "$output"
2508
+ }
2509
+
2510
+ cmd_context() {
2511
+ # Parse arguments
2512
+ while [[ $# -gt 0 ]]; do
2513
+ case "$1" in
2514
+ --help|-h)
2515
+ printf 'Usage: revo context\n\n'
2516
+ printf 'Scan repos and regenerate workspace CLAUDE.md with framework,\n'
2517
+ printf 'routes, and dependency order for Claude Code to consume.\n'
2518
+ return 0
2519
+ ;;
2520
+ *)
2521
+ ui_step_error "Unknown option: $1"
2522
+ return 1
2523
+ ;;
2524
+ esac
2525
+ done
2526
+
2527
+ config_require_workspace || return 1
2528
+
2529
+ ui_intro "Revo - Generate Workspace CLAUDE.md"
2530
+
2531
+ if [[ $YAML_REPO_COUNT -eq 0 ]]; then
2532
+ ui_step_error "No repositories configured"
2533
+ ui_outro_cancel "Nothing to scan"
2534
+ return 1
2535
+ fi
2536
+
2537
+ local output="$REVO_WORKSPACE_ROOT/CLAUDE.md"
2538
+
2539
+ ui_spinner_start "Scanning $YAML_REPO_COUNT repositories..."
2540
+ _context_write_file "$output"
2541
+ ui_spinner_stop
2542
+ ui_step_done "Scanned:" "$YAML_REPO_COUNT repositories"
2543
+ ui_step_done "Wrote:" "CLAUDE.md"
2544
+
2545
+ if [[ $CONTEXT_CYCLE -eq 1 ]]; then
2546
+ ui_step_error "Dependency cycle detected - see CLAUDE.md for order"
2547
+ fi
2548
+
2549
+ ui_outro "Context generated. Point Claude Code at this workspace."
2550
+
2551
+ return 0
2552
+ }
2553
+
2554
+ # Called automatically from cmd_clone if CLAUDE.md does not yet exist.
2555
+ context_autogenerate_if_missing() {
2556
+ [[ -z "$REVO_WORKSPACE_ROOT" ]] && return 0
2557
+ local output="$REVO_WORKSPACE_ROOT/CLAUDE.md"
2558
+ [[ -f "$output" ]] && return 0
2559
+ [[ $YAML_REPO_COUNT -eq 0 ]] && return 0
2560
+
2561
+ _context_write_file "$output"
2562
+ ui_info "$(ui_dim "Auto-generated CLAUDE.md for Claude Code")"
2563
+ return 0
2564
+ }
2565
+
2566
+ # === lib/commands/feature.sh ===
2567
+ # Revo CLI - feature command
2568
+ # Creates a coordinated feature branch and writes a feature context file.
2569
+
2570
+ cmd_feature() {
2571
+ local name=""
2572
+ local tag=""
2573
+
2574
+ while [[ $# -gt 0 ]]; do
2575
+ case "$1" in
2576
+ --tag)
2577
+ tag="$2"
2578
+ shift 2
2579
+ ;;
2580
+ --help|-h)
2581
+ printf 'Usage: revo feature <name> [--tag TAG]\n\n'
2582
+ printf 'Creates feature/<name> on matching repos and writes\n'
2583
+ printf '.revo/features/<name>.md as a shared context file.\n'
2584
+ return 0
2585
+ ;;
2586
+ -*)
2587
+ ui_step_error "Unknown option: $1"
2588
+ return 1
2589
+ ;;
2590
+ *)
2591
+ if [[ -z "$name" ]]; then
2592
+ name="$1"
2593
+ else
2594
+ ui_step_error "Unexpected argument: $1"
2595
+ return 1
2596
+ fi
2597
+ shift
2598
+ ;;
2599
+ esac
2600
+ done
2601
+
2602
+ if [[ -z "$name" ]]; then
2603
+ ui_step_error "Usage: revo feature <name> [--tag TAG]"
2604
+ return 1
2605
+ fi
2606
+
2607
+ config_require_workspace || return 1
2608
+
2609
+ local branch="feature/$name"
2610
+ ui_intro "Revo - Feature: $name"
2611
+
2612
+ local repos
2613
+ repos=$(config_get_repos "$tag")
2614
+
2615
+ if [[ -z "$repos" ]]; then
2616
+ ui_step_error "No repositories match"
2617
+ ui_outro_cancel "Nothing to do"
2618
+ return 1
2619
+ fi
2620
+
2621
+ local success_count=0
2622
+ local skip_count=0
2623
+ local fail_count=0
2624
+ local involved_indices=()
2625
+
2626
+ while IFS= read -r repo; do
2627
+ [[ -z "$repo" ]] && continue
2628
+
2629
+ local path
2630
+ path=$(yaml_get_path "$repo")
2631
+ local full_path="$REVO_REPOS_DIR/$path"
2632
+
2633
+ if [[ ! -d "$full_path" ]]; then
2634
+ ui_step_done "Skipped (not cloned):" "$path"
2635
+ skip_count=$((skip_count + 1))
2636
+ continue
2637
+ fi
2638
+
2639
+ involved_indices+=("$repo")
2640
+
2641
+ if git_branch_exists "$full_path" "$branch"; then
2642
+ if git_checkout "$full_path" "$branch"; then
2643
+ ui_step_done "Checked out existing:" "$path → $branch"
2644
+ success_count=$((success_count + 1))
2645
+ else
2646
+ ui_step_error "Failed to checkout existing branch: $path"
2647
+ fail_count=$((fail_count + 1))
2648
+ fi
2649
+ continue
2650
+ fi
2651
+
2652
+ if git_branch "$full_path" "$branch"; then
2653
+ ui_step_done "Created:" "$path → $branch"
2654
+ success_count=$((success_count + 1))
2655
+ else
2656
+ ui_step_error "Failed: $path - $GIT_ERROR"
2657
+ fail_count=$((fail_count + 1))
2658
+ fi
2659
+ done <<< "$repos"
2660
+
2661
+ # Write feature context file
2662
+ local feature_dir="$REVO_WORKSPACE_ROOT/.revo/features"
2663
+ mkdir -p "$feature_dir"
2664
+ local feature_file="$feature_dir/$name.md"
2665
+
2666
+ if [[ -f "$feature_file" ]]; then
2667
+ ui_step_done "Feature file exists:" ".revo/features/$name.md"
2668
+ else
2669
+ local timestamp
2670
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
2671
+
2672
+ {
2673
+ printf '# Feature: %s\n' "$name"
2674
+ printf '\n'
2675
+ printf '## Status\n'
2676
+ printf '- Created: %s\n' "$timestamp"
2677
+ printf '- Branch: %s\n' "$branch"
2678
+ if [[ -n "$tag" ]]; then
2679
+ printf '- Tag filter: %s\n' "$tag"
2680
+ fi
2681
+ printf '\n'
2682
+ printf '## Repos\n'
2683
+
2684
+ local idx
2685
+ for idx in "${involved_indices[@]}"; do
2686
+ local rpath rtags
2687
+ rpath=$(yaml_get_path "$idx")
2688
+ rtags=$(yaml_get_tags "$idx")
2689
+ if [[ -n "$rtags" ]]; then
2690
+ printf -- '- %s (tags: %s)\n' "$rpath" "$rtags"
2691
+ else
2692
+ printf -- '- %s\n' "$rpath"
2693
+ fi
2694
+ done
2695
+
2696
+ printf '\n'
2697
+ printf '## Plan\n'
2698
+ printf '<!-- Describe what this feature does across repos -->\n'
2699
+ printf '<!-- The agent will read this to understand the scope -->\n'
2700
+ printf '\n'
2701
+ printf '## Changes\n'
2702
+ printf '<!-- Track what has been done in each repo -->\n'
2703
+ printf '\n'
2704
+ printf '## Dependencies\n'
2705
+ printf '<!-- Note cross-repo dependencies -->\n'
2706
+ } > "$feature_file"
2707
+
2708
+ ui_step_done "Wrote:" ".revo/features/$name.md"
2709
+ fi
2710
+
2711
+ ui_bar_line
2712
+
2713
+ if [[ $fail_count -eq 0 ]]; then
2714
+ local msg="Feature '$name' ready on $success_count repo(s)"
2715
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
2716
+ ui_info "$(ui_dim "Next: edit .revo/features/$name.md with the plan,")"
2717
+ ui_info "$(ui_dim "then ask Claude Code to work in $REVO_WORKSPACE_ROOT")"
2718
+ ui_outro "$msg"
2719
+ else
2720
+ ui_outro_cancel "$success_count succeeded, $fail_count failed"
2721
+ return 1
2722
+ fi
2723
+
2724
+ return 0
2725
+ }
2726
+
2727
+ # === lib/commands/commit.sh ===
2728
+ # Revo CLI - commit command
2729
+ # Commit across dirty repos with the same message.
2730
+
2731
+ cmd_commit() {
2732
+ local message=""
2733
+ local tag=""
2734
+
2735
+ while [[ $# -gt 0 ]]; do
2736
+ case "$1" in
2737
+ --tag)
2738
+ tag="$2"
2739
+ shift 2
2740
+ ;;
2741
+ --help|-h)
2742
+ printf 'Usage: revo commit <message> [--tag TAG]\n\n'
2743
+ printf 'Stages and commits changes across dirty repos with the same message.\n'
2744
+ return 0
2745
+ ;;
2746
+ -*)
2747
+ ui_step_error "Unknown option: $1"
2748
+ return 1
2749
+ ;;
2750
+ *)
2751
+ if [[ -z "$message" ]]; then
2752
+ message="$1"
2753
+ else
2754
+ ui_step_error "Unexpected argument: $1"
2755
+ return 1
2756
+ fi
2757
+ shift
2758
+ ;;
2759
+ esac
2760
+ done
2761
+
2762
+ if [[ -z "$message" ]]; then
2763
+ ui_step_error "Usage: revo commit <message> [--tag TAG]"
2764
+ return 1
2765
+ fi
2766
+
2767
+ config_require_workspace || return 1
2768
+
2769
+ ui_intro "Revo - Commit: $message"
2770
+
2771
+ local repos
2772
+ repos=$(config_get_repos "$tag")
2773
+
2774
+ if [[ -z "$repos" ]]; then
2775
+ ui_step_error "No repositories configured"
2776
+ ui_outro_cancel "Nothing to commit"
2777
+ return 1
2778
+ fi
2779
+
2780
+ local success_count=0
2781
+ local skip_count=0
2782
+ local fail_count=0
2783
+
2784
+ while IFS= read -r repo; do
2785
+ [[ -z "$repo" ]] && continue
2786
+
2787
+ local path
2788
+ path=$(yaml_get_path "$repo")
2789
+ local full_path="$REVO_REPOS_DIR/$path"
2790
+
2791
+ if [[ ! -d "$full_path" ]]; then
2792
+ ui_step_done "Skipped (not cloned):" "$path"
2793
+ skip_count=$((skip_count + 1))
2794
+ continue
2795
+ fi
2796
+
2797
+ if ! git_is_dirty "$full_path"; then
2798
+ ui_step_done "Clean (skipped):" "$path"
2799
+ skip_count=$((skip_count + 1))
2800
+ continue
2801
+ fi
2802
+
2803
+ # Stage
2804
+ if ! git_exec "$full_path" add -A; then
2805
+ ui_step_error "Failed to stage: $path - $GIT_ERROR"
2806
+ fail_count=$((fail_count + 1))
2807
+ continue
2808
+ fi
2809
+
2810
+ # Commit
2811
+ if git_exec "$full_path" commit -m "$message"; then
2812
+ ui_step_done "Committed:" "$path"
2813
+ success_count=$((success_count + 1))
2814
+ else
2815
+ ui_step_error "Failed: $path - $GIT_ERROR"
2816
+ fail_count=$((fail_count + 1))
2817
+ fi
2818
+ done <<< "$repos"
2819
+
2820
+ ui_bar_line
2821
+
2822
+ if [[ $fail_count -eq 0 ]]; then
2823
+ local msg="Committed on $success_count repo(s)"
2824
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
2825
+ ui_outro "$msg"
2826
+ else
2827
+ ui_outro_cancel "$success_count committed, $fail_count failed"
2828
+ return 1
2829
+ fi
2830
+
2831
+ return 0
2832
+ }
2833
+
2834
+ # === lib/commands/push.sh ===
2835
+ # Revo CLI - push command
2836
+ # Pushes current branch across repositories.
2837
+
2838
+ cmd_push() {
2839
+ local tag=""
2840
+ local set_upstream=1
2841
+
2842
+ while [[ $# -gt 0 ]]; do
2843
+ case "$1" in
2844
+ --tag)
2845
+ tag="$2"
2846
+ shift 2
2847
+ ;;
2848
+ --no-upstream)
2849
+ set_upstream=0
2850
+ shift
2851
+ ;;
2852
+ --help|-h)
2853
+ printf 'Usage: revo push [--tag TAG]\n\n'
2854
+ printf 'Pushes current branch of each repo to origin.\n'
2855
+ printf 'Sets upstream automatically the first time.\n'
2856
+ return 0
2857
+ ;;
2858
+ -*)
2859
+ ui_step_error "Unknown option: $1"
2860
+ return 1
2861
+ ;;
2862
+ *)
2863
+ ui_step_error "Unexpected argument: $1"
2864
+ return 1
2865
+ ;;
2866
+ esac
2867
+ done
2868
+
2869
+ config_require_workspace || return 1
2870
+
2871
+ ui_intro "Revo - Push Repositories"
2872
+
2873
+ local repos
2874
+ repos=$(config_get_repos "$tag")
2875
+
2876
+ if [[ -z "$repos" ]]; then
2877
+ ui_step_error "No repositories configured"
2878
+ ui_outro_cancel "Nothing to push"
2879
+ return 1
2880
+ fi
2881
+
2882
+ local success_count=0
2883
+ local skip_count=0
2884
+ local fail_count=0
2885
+
2886
+ while IFS= read -r repo; do
2887
+ [[ -z "$repo" ]] && continue
2888
+
2889
+ local path
2890
+ path=$(yaml_get_path "$repo")
2891
+ local full_path="$REVO_REPOS_DIR/$path"
2892
+
2893
+ if [[ ! -d "$full_path" ]]; then
2894
+ ui_step_done "Skipped (not cloned):" "$path"
2895
+ skip_count=$((skip_count + 1))
2896
+ continue
2897
+ fi
2898
+
2899
+ local branch
2900
+ branch=$(git_current_branch "$full_path")
2901
+ if [[ -z "$branch" ]] || [[ "$branch" == "HEAD" ]]; then
2902
+ ui_step_error "Detached HEAD: $path"
2903
+ fail_count=$((fail_count + 1))
2904
+ continue
2905
+ fi
2906
+
2907
+ # Check if upstream already exists
2908
+ local has_upstream=0
2909
+ if git -C "$full_path" rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then
2910
+ has_upstream=1
2911
+ fi
2912
+
2913
+ if [[ $has_upstream -eq 1 ]]; then
2914
+ if git_exec "$full_path" push; then
2915
+ ui_step_done "Pushed:" "$path → $branch"
2916
+ success_count=$((success_count + 1))
2917
+ else
2918
+ ui_step_error "Failed: $path - $GIT_ERROR"
2919
+ fail_count=$((fail_count + 1))
2920
+ fi
2921
+ else
2922
+ if [[ $set_upstream -eq 1 ]]; then
2923
+ if git_exec "$full_path" push -u origin "$branch"; then
2924
+ ui_step_done "Pushed (set upstream):" "$path → $branch"
2925
+ success_count=$((success_count + 1))
2926
+ else
2927
+ ui_step_error "Failed: $path - $GIT_ERROR"
2928
+ fail_count=$((fail_count + 1))
2929
+ fi
2930
+ else
2931
+ ui_step_error "No upstream and --no-upstream set: $path"
2932
+ fail_count=$((fail_count + 1))
2933
+ fi
2934
+ fi
2935
+ done <<< "$repos"
2936
+
2937
+ ui_bar_line
2938
+
2939
+ if [[ $fail_count -eq 0 ]]; then
2940
+ local msg="Pushed $success_count repo(s)"
2941
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
2942
+ ui_outro "$msg"
2943
+ else
2944
+ ui_outro_cancel "$success_count pushed, $fail_count failed"
2945
+ return 1
2946
+ fi
2947
+
2948
+ return 0
2949
+ }
2950
+
2951
+ # === lib/commands/pr.sh ===
2952
+ # Revo CLI - pr command
2953
+ # Creates coordinated pull requests across repos via the gh CLI.
2954
+
2955
+ cmd_pr() {
2956
+ local title=""
2957
+ local tag=""
2958
+ local body=""
2959
+
2960
+ while [[ $# -gt 0 ]]; do
2961
+ case "$1" in
2962
+ --tag)
2963
+ tag="$2"
2964
+ shift 2
2965
+ ;;
2966
+ --body)
2967
+ body="$2"
2968
+ shift 2
2969
+ ;;
2970
+ --help|-h)
2971
+ printf 'Usage: revo pr <title> [--tag TAG] [--body BODY]\n\n'
2972
+ printf 'Creates pull requests across repos on non-main branches using gh CLI.\n'
2973
+ printf 'After all PRs are created, appends cross-reference links to each body.\n'
2974
+ return 0
2975
+ ;;
2976
+ -*)
2977
+ ui_step_error "Unknown option: $1"
2978
+ return 1
2979
+ ;;
2980
+ *)
2981
+ if [[ -z "$title" ]]; then
2982
+ title="$1"
2983
+ else
2984
+ ui_step_error "Unexpected argument: $1"
2985
+ return 1
2986
+ fi
2987
+ shift
2988
+ ;;
2989
+ esac
2990
+ done
2991
+
2992
+ if [[ -z "$title" ]]; then
2993
+ ui_step_error "Usage: revo pr <title> [--tag TAG] [--body BODY]"
2994
+ return 1
2995
+ fi
2996
+
2997
+ if ! command -v gh >/dev/null 2>&1; then
2998
+ ui_step_error "gh CLI not found. Install from https://cli.github.com/"
2999
+ return 1
3000
+ fi
3001
+
3002
+ config_require_workspace || return 1
3003
+
3004
+ ui_intro "Revo - Create PRs: $title"
3005
+
3006
+ local repos
3007
+ repos=$(config_get_repos "$tag")
3008
+
3009
+ if [[ -z "$repos" ]]; then
3010
+ ui_step_error "No repositories configured"
3011
+ ui_outro_cancel "Nothing to do"
3012
+ return 1
3013
+ fi
3014
+
3015
+ # Parallel arrays to collect PR results (bash 3.2)
3016
+ local pr_paths=()
3017
+ local pr_urls=()
3018
+ local pr_numbers=()
3019
+
3020
+ local default_body="$body"
3021
+ if [[ -z "$default_body" ]]; then
3022
+ default_body="Coordinated PR created by \`revo pr\`."
3023
+ fi
3024
+
3025
+ local skip_count=0
3026
+ local fail_count=0
3027
+
3028
+ # --- Pass 1: create PRs ---
3029
+ while IFS= read -r repo; do
3030
+ [[ -z "$repo" ]] && continue
3031
+
3032
+ local path
3033
+ path=$(yaml_get_path "$repo")
3034
+ local full_path="$REVO_REPOS_DIR/$path"
3035
+
3036
+ if [[ ! -d "$full_path" ]]; then
3037
+ ui_step_done "Skipped (not cloned):" "$path"
3038
+ skip_count=$((skip_count + 1))
3039
+ continue
3040
+ fi
3041
+
3042
+ local branch
3043
+ branch=$(git_current_branch "$full_path")
3044
+ if [[ -z "$branch" ]] || [[ "$branch" == "main" ]] || [[ "$branch" == "master" ]]; then
3045
+ ui_step_done "Skipped (on main/master):" "$path"
3046
+ skip_count=$((skip_count + 1))
3047
+ continue
3048
+ fi
3049
+
3050
+ # Skip if no commits ahead of upstream (push required first)
3051
+ git_ahead_behind "$full_path"
3052
+ local has_upstream=0
3053
+ if git -C "$full_path" rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then
3054
+ has_upstream=1
3055
+ fi
3056
+
3057
+ if [[ $has_upstream -eq 1 ]] && [[ $GIT_AHEAD -eq 0 ]]; then
3058
+ ui_step_done "Skipped (no changes ahead of upstream):" "$path"
3059
+ skip_count=$((skip_count + 1))
3060
+ continue
3061
+ fi
3062
+
3063
+ # Create PR via gh
3064
+ local pr_title="[revo] $title"
3065
+ local output
3066
+ if output=$(cd "$full_path" && gh pr create --title "$pr_title" --body "$default_body" 2>&1); then
3067
+ local url
3068
+ url=$(printf '%s' "$output" | grep -Eo 'https://github\.com/[^ ]+/pull/[0-9]+' | tail -1)
3069
+ if [[ -z "$url" ]]; then
3070
+ url="$output"
3071
+ fi
3072
+ pr_paths+=("$path")
3073
+ pr_urls+=("$url")
3074
+ local number
3075
+ number="${url##*/}"
3076
+ pr_numbers+=("$number")
3077
+ ui_step_done "Opened PR:" "$path → $url"
3078
+ else
3079
+ # Maybe a PR already exists
3080
+ local existing
3081
+ existing=$(cd "$full_path" && gh pr view --json url -q .url 2>/dev/null || true)
3082
+ if [[ -n "$existing" ]]; then
3083
+ pr_paths+=("$path")
3084
+ pr_urls+=("$existing")
3085
+ local number
3086
+ number="${existing##*/}"
3087
+ pr_numbers+=("$number")
3088
+ ui_step_done "Existing PR:" "$path → $existing"
3089
+ else
3090
+ ui_step_error "Failed: $path"
3091
+ ui_info "$(ui_dim "$output")"
3092
+ fail_count=$((fail_count + 1))
3093
+ fi
3094
+ fi
3095
+ done <<< "$repos"
3096
+
3097
+ # --- Pass 2: append cross-references if >1 PR ---
3098
+ if [[ ${#pr_paths[@]} -gt 1 ]]; then
3099
+ ui_bar_line
3100
+ ui_step "Linking PRs with cross-references..."
3101
+
3102
+ # Build cross-reference block
3103
+ local xref
3104
+ xref=$'\n\n---\n**Coordinated PRs (revo):**\n'
3105
+ local i
3106
+ for ((i = 0; i < ${#pr_paths[@]}; i++)); do
3107
+ xref+="- ${pr_paths[$i]}: ${pr_urls[$i]}"$'\n'
3108
+ done
3109
+
3110
+ for ((i = 0; i < ${#pr_paths[@]}; i++)); do
3111
+ local repo_path="${pr_paths[$i]}"
3112
+ local pr_number="${pr_numbers[$i]}"
3113
+ local full_path="$REVO_REPOS_DIR/$repo_path"
3114
+ local combined_body="$default_body$xref"
3115
+
3116
+ if (cd "$full_path" && gh pr edit "$pr_number" --body "$combined_body" >/dev/null 2>&1); then
3117
+ ui_step_done "Linked:" "$repo_path"
3118
+ else
3119
+ ui_step_error "Failed to link: $repo_path"
3120
+ fi
3121
+ done
3122
+ fi
3123
+
3124
+ ui_bar_line
3125
+
3126
+ if [[ $fail_count -eq 0 ]]; then
3127
+ local msg="Opened ${#pr_paths[@]} PR(s)"
3128
+ [[ $skip_count -gt 0 ]] && msg+=", $skip_count skipped"
3129
+ ui_outro "$msg"
3130
+ else
3131
+ ui_outro_cancel "${#pr_paths[@]} opened, $fail_count failed"
3132
+ return 1
3133
+ fi
3134
+
3135
+ return 0
3136
+ }
3137
+
3138
+ # === Main ===
3139
+ # --- Help ---
3140
+ show_help() {
3141
+ cat << EOF
3142
+ Revo - Claude-first multi-repo workspace manager v$REVO_VERSION
3143
+
3144
+ Usage: revo <command> [options]
3145
+
3146
+ Workspace commands:
3147
+ init Initialize a new workspace
3148
+ add URL [--tags TAGS] Add a repository to the workspace
3149
+ clone [--tag TAG] Clone configured repositories
3150
+ list [--tag TAG] List configured repositories
3151
+ status [--tag TAG] Show status of all repositories
3152
+ sync [--tag TAG] Pull latest changes on repositories
3153
+ branch NAME [--tag TAG] Create a branch on repositories
3154
+ checkout BRANCH [--tag TAG] Checkout a branch on repositories
3155
+ exec "CMD" [--tag TAG] Run a command in each repository
3156
+
3157
+ Claude-first commands:
3158
+ context Scan repos and regenerate workspace CLAUDE.md
3159
+ feature NAME [--tag TAG] Create feature branch + context file across repos
3160
+ commit MSG [--tag TAG] Commit changes across dirty repos
3161
+ push [--tag TAG] Push branches across repositories
3162
+ pr TITLE [--tag TAG] Create coordinated PRs via gh CLI
3163
+
3164
+ Options:
3165
+ --tag TAG Filter repositories by tag
3166
+ --tags TAGS Comma-separated list of tags (for add)
3167
+ --force, -f Force operation (clone, checkout)
3168
+ --rebase, -r Use rebase when syncing
3169
+ --quiet, -q Suppress command output (exec)
3170
+ --body TEXT PR body (pr)
3171
+ --help, -h Show this help
3172
+ --version, -v Show version
3173
+
3174
+ Examples:
3175
+ revo init
3176
+ revo add git@github.com:org/shared-types.git --tags shared
3177
+ revo add git@github.com:org/backend.git --tags backend,api
3178
+ revo clone
3179
+ revo context # regenerate CLAUDE.md
3180
+ revo feature clock-student --tag backend # coordinated branch
3181
+ revo commit "wire up clock endpoint"
3182
+ revo push
3183
+ revo pr "Clock endpoint for students" --tag backend
3184
+
3185
+ Documentation: https://github.com/marcus.salinas/revo
3186
+ EOF
3187
+ }
3188
+
3189
+ show_version() {
3190
+ printf 'Revo v%s\n' "$REVO_VERSION"
3191
+ }
3192
+
3193
+ # --- Main ---
3194
+ main() {
3195
+ local command="${1:-}"
3196
+
3197
+ # Handle no arguments
3198
+ if [[ -z "$command" ]]; then
3199
+ show_help
3200
+ return 0
3201
+ fi
3202
+
3203
+ # Shift off the command
3204
+ shift || true
3205
+
3206
+ case "$command" in
3207
+ init)
3208
+ cmd_init "$@"
3209
+ ;;
3210
+ clone)
3211
+ cmd_clone "$@"
3212
+ ;;
3213
+ status)
3214
+ cmd_status "$@"
3215
+ ;;
3216
+ branch)
3217
+ cmd_branch "$@"
3218
+ ;;
3219
+ checkout)
3220
+ cmd_checkout "$@"
3221
+ ;;
3222
+ sync)
3223
+ cmd_sync "$@"
3224
+ ;;
3225
+ exec)
3226
+ cmd_exec "$@"
3227
+ ;;
3228
+ add)
3229
+ cmd_add "$@"
3230
+ ;;
3231
+ list)
3232
+ cmd_list "$@"
3233
+ ;;
3234
+ context)
3235
+ cmd_context "$@"
3236
+ ;;
3237
+ feature)
3238
+ cmd_feature "$@"
3239
+ ;;
3240
+ commit)
3241
+ cmd_commit "$@"
3242
+ ;;
3243
+ push)
3244
+ cmd_push "$@"
3245
+ ;;
3246
+ pr)
3247
+ cmd_pr "$@"
3248
+ ;;
3249
+ --help|-h|help)
3250
+ show_help
3251
+ ;;
3252
+ --version|-v|version)
3253
+ show_version
3254
+ ;;
3255
+ *)
3256
+ printf 'Unknown command: %s\n\n' "$command" >&2
3257
+ show_help >&2
3258
+ return 1
3259
+ ;;
3260
+ esac
3261
+ }
3262
+
3263
+ main "$@"