@brickhouse-tech/sync-agents 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1596 +0,0 @@
1
- #!/usr/bin/env bash
2
- # ----------------------------------------------------------------------------
3
- # DEPRECATED — bash implementation of sync-agents.
4
- #
5
- # The Go binary shipped via per-platform npm packages is the supported
6
- # implementation. This shell script remains only as:
7
- # - the fallback path in bin/sync-agents.js for unsupported triples
8
- # - the standalone curl install in README (also deprecated)
9
- # Scheduled for removal in a future major after platform-package install
10
- # data shows the fallback is unused. Bug fixes go into go/, not here.
11
- # ----------------------------------------------------------------------------
12
- set -euo pipefail
13
-
14
- AGENTS_DIR=".agents"
15
- AGENTS_MD="AGENTS.md"
16
-
17
- # Resolve the real script directory, following symlinks.
18
- # Needed for `npm i -g` installs where the executable is a symlink in PATH
19
- # (e.g. /usr/local/bin/sync-agents -> .../node_modules/.../src/sh/sync-agents.sh).
20
- # BSD readlink (macOS default) lacks -f, so we walk the chain ourselves.
21
- resolve_script_dir() {
22
- local path="${BASH_SOURCE[0]}"
23
- while [[ -L "$path" ]]; do
24
- local dir
25
- dir="$(cd -P "$(dirname "$path")" && pwd)"
26
- path="$(readlink "$path")"
27
- [[ "$path" != /* ]] && path="$dir/$path"
28
- done
29
- cd -P "$(dirname "$path")" && pwd
30
- }
31
- SCRIPT_DIR="$(resolve_script_dir)"
32
- PACKAGE_JSON="${SCRIPT_DIR}/../../package.json"
33
- TEMPLATES_DIR="${SCRIPT_DIR}/../md"
34
-
35
- # Pull version from package.json via node (we're an npm package, node is present).
36
- # Force CommonJS so this works regardless of the caller's ESM/CJS context
37
- # (e.g. NODE_OPTIONS=--input-type=module). Path is passed via argv to avoid
38
- # shell-quoting issues. Requires Node 20+.
39
- if [[ -f "$PACKAGE_JSON" ]] && command -v node >/dev/null 2>&1; then
40
- VERSION="$(node --input-type=commonjs -e 'process.stdout.write(require(process.argv[1]).version)' "$PACKAGE_JSON" 2>/dev/null || echo unknown)"
41
- else
42
- VERSION="unknown"
43
- fi
44
-
45
- # Agent target directories
46
- TARGETS=("claude" "windsurf" "cursor" "copilot")
47
-
48
- # Colors (disabled if not a terminal)
49
- if [[ -t 1 ]]; then
50
- BOLD='\033[1m'
51
- GREEN='\033[0;32m'
52
- YELLOW='\033[0;33m'
53
- RED='\033[0;31m'
54
- CYAN='\033[0;36m'
55
- RESET='\033[0m'
56
- else
57
- BOLD='' GREEN='' YELLOW='' RED='' CYAN='' RESET=''
58
- fi
59
-
60
- info() { echo -e "${GREEN}[info]${RESET} $*"; }
61
- warn() { echo -e "${YELLOW}[warn]${RESET} $*"; }
62
- error() { echo -e "${RED}[error]${RESET} $*" >&2; }
63
-
64
- # Resolve target directory path (copilot uses .github/copilot/ instead of .copilot/)
65
- resolve_target_dir() {
66
- local target="$1"
67
- local root="$2"
68
- if [[ "$target" == "copilot" ]]; then
69
- echo "$root/.github/copilot"
70
- else
71
- echo "$root/.$target"
72
- fi
73
- }
74
-
75
- # Resolve relative path from target dir back to .agents/ (accounts for depth)
76
- resolve_agents_rel() {
77
- local target="$1"
78
- if [[ "$target" == "copilot" ]]; then
79
- echo "../../$AGENTS_DIR"
80
- else
81
- echo "../$AGENTS_DIR"
82
- fi
83
- }
84
-
85
- usage() {
86
- cat <<EOF
87
- ${BOLD}sync-agents${RESET} v${VERSION} - One set of agent rules to rule them all.
88
-
89
- ${BOLD}USAGE${RESET}
90
- sync-agents <command> [options]
91
-
92
- ${BOLD}COMMANDS${RESET}
93
- init Initialize .agents/ directory structure and AGENTS.md
94
- sync Sync .agents/ to agent directories via symlinks
95
- status Show current sync status
96
- add <type> <name> Add a new rule, skill, or workflow from template
97
- index Regenerate AGENTS.md index from .agents/ contents
98
- clean Remove all synced symlinks (does not remove .agents/)
99
- watch Watch .agents/ for changes and auto-regenerate index
100
- import <url> Import a rule/skill/workflow from a URL
101
- hook Install a pre-commit git hook for auto-sync
102
- fix [type] Migrate legacy dirs + repair broken symlinks
103
- type: skills, rules, workflows, or all
104
- --no-clobber: skip items that already exist in .agents/
105
- inherit <label> <path> Add an inheritance link to AGENTS.md (convention-based)
106
- inherit --list List current inheritance links
107
- inherit --remove <label> Remove an inheritance link by label
108
- version Show version (same as --version)
109
-
110
- ${BOLD}OPTIONS${RESET}
111
- -h, --help Show this help message
112
- -v, --version Show version
113
- -d, --dir <path> Set project root directory (default: current directory)
114
- --targets <list> Comma-separated targets (overrides .agents/config)
115
- --dry-run Show what would be done without making changes
116
- --force Overwrite existing files/symlinks
117
-
118
- ${BOLD}EXAMPLES${RESET}
119
- sync-agents init # Initialize .agents/ structure
120
- sync-agents add rule no-eval # Add a new rule called "no-eval"
121
- sync-agents sync # Sync to .claude/ and .windsurf/
122
- sync-agents sync --targets claude
123
- sync-agents status # Show current state
124
- sync-agents clean # Remove synced symlinks
125
-
126
- EOF
127
- }
128
-
129
- # --------------------------------------------------------------------------
130
- # Helpers
131
- # --------------------------------------------------------------------------
132
-
133
- find_project_root() {
134
- local dir="${1:-.}"
135
- dir="$(cd "$dir" && pwd)"
136
-
137
- # Walk up to find a directory with .agents or .git
138
- while [[ "$dir" != "/" ]]; do
139
- if [[ -d "$dir/$AGENTS_DIR" ]] || [[ -d "$dir/.git" ]]; then
140
- echo "$dir"
141
- return 0
142
- fi
143
- dir="$(dirname "$dir")"
144
- done
145
-
146
- # Fallback to current directory
147
- pwd
148
- }
149
-
150
- ensure_agents_dir() {
151
- if [[ ! -d "$PROJECT_ROOT/$AGENTS_DIR" ]]; then
152
- error ".agents/ directory not found. Run 'sync-agents init' first."
153
- exit 1
154
- fi
155
- }
156
-
157
- create_symlink() {
158
- local source="$1"
159
- local target="$2"
160
- local dry_run="${3:-false}"
161
-
162
- if [[ "$dry_run" == "true" ]]; then
163
- echo " would link: $target -> $source"
164
- return 0
165
- fi
166
-
167
- local target_dir
168
- target_dir="$(dirname "$target")"
169
- mkdir -p "$target_dir"
170
-
171
- if [[ -L "$target" ]]; then
172
- local existing
173
- existing="$(readlink "$target")"
174
- if [[ "$existing" == "$source" ]]; then
175
- return 0 # Already correct
176
- fi
177
- if [[ "$FORCE" == "true" ]]; then
178
- rm "$target"
179
- else
180
- warn "Symlink already exists: $target -> $existing (use --force to overwrite)"
181
- return 1
182
- fi
183
- elif [[ -e "$target" ]]; then
184
- if [[ "$FORCE" == "true" ]]; then
185
- rm -rf "$target"
186
- else
187
- warn "File already exists: $target (use --force to overwrite)"
188
- return 1
189
- fi
190
- fi
191
-
192
- ln -sf "$source" "$target"
193
- info "Linked: $target -> $source"
194
- }
195
-
196
- # --------------------------------------------------------------------------
197
- # Commands
198
- # --------------------------------------------------------------------------
199
-
200
- # Migrate legacy .agents/STATE.md to the new per-file state pattern.
201
- # - Ensures rules/state.md exists (the convention rule).
202
- # - If legacy STATE.md has inline history entries, extracts them into a
203
- # timestamped STATE_legacy-history_YYYYMMDDHHMMSS.md file.
204
- # - Removes the legacy STATE.md.
205
- migrate_legacy_state() {
206
- local agents_dir="$1"
207
- local legacy="$agents_dir/STATE.md"
208
-
209
- [[ -f "$legacy" ]] || return 0
210
-
211
- # Check if legacy file has meaningful content (beyond template boilerplate)
212
- local content_lines
213
- content_lines="$(grep -cvE '^(---|trigger:|#|$|Track project|Update this|Be sure|Description of|Save both|A new file|STATE HISTORY)' "$legacy" 2>/dev/null | tail -1 || echo 0)"
214
- content_lines="${content_lines//[^0-9]/}"
215
- : "${content_lines:=0}"
216
-
217
- if [[ "$content_lines" -gt 0 ]]; then
218
- # Extract history into a timestamped state file
219
- local timestamp
220
- timestamp="$(date +%Y%m%d%H%M%S)"
221
- local migrated="$agents_dir/STATE_legacy-history_${timestamp}.md"
222
- cp "$legacy" "$migrated"
223
- info "Migrated legacy STATE.md history → $(basename "$migrated")"
224
- fi
225
-
226
- rm "$legacy"
227
- info "Removed legacy $AGENTS_DIR/STATE.md (replaced by rules/state.md pattern)"
228
- }
229
-
230
- cmd_init() {
231
- info "Initializing $AGENTS_DIR/ directory structure..."
232
-
233
- mkdir -p "$PROJECT_ROOT/$AGENTS_DIR/rules"
234
- mkdir -p "$PROJECT_ROOT/$AGENTS_DIR/skills"
235
- mkdir -p "$PROJECT_ROOT/$AGENTS_DIR/workflows"
236
-
237
- # Create rules/state.md (the state convention rule) if it doesn't exist
238
- local state_rule="$PROJECT_ROOT/$AGENTS_DIR/rules/state.md"
239
- if [[ ! -f "$state_rule" ]]; then
240
- if [[ -f "$TEMPLATES_DIR/STATE_TEMPLATE.md" ]]; then
241
- cp "$TEMPLATES_DIR/STATE_TEMPLATE.md" "$state_rule"
242
- info "Created $AGENTS_DIR/rules/state.md from template"
243
- else
244
- # Inline fallback if template not found
245
- cat > "$state_rule" <<'STATE_EOF'
246
- ---
247
- trigger: always_on
248
- ---
249
-
250
- # State
251
-
252
- Track project progress, current objectives, and resumption context.
253
- Update this file regularly so agents can pick up where they left off.
254
-
255
- ## Save location
256
-
257
- ../STATE_${CONTEXT_DESCRIPTION}_YYYYMMDDHHMMSS.md
258
-
259
- A new file will be created each time during agent executions as a short summary of the current state, progress, blockers, and next steps.
260
-
261
- ## Format
262
-
263
- ### YYYYMMDDHHMMSS STATE: <objective>
264
-
265
- Description of current state, progress, blockers, and next steps.
266
-
267
- Be sure to indicate whats left and what done via - [ ] checkboxes in state file
268
- STATE_EOF
269
- info "Created $AGENTS_DIR/rules/state.md"
270
- fi
271
- else
272
- warn "$AGENTS_DIR/rules/state.md already exists, skipping"
273
- fi
274
-
275
- # Migrate legacy STATE.md if it exists (from older sync-agents versions)
276
- if [[ -f "$PROJECT_ROOT/$AGENTS_DIR/STATE.md" ]]; then
277
- migrate_legacy_state "$PROJECT_ROOT/$AGENTS_DIR"
278
- fi
279
-
280
- # Create default config if it doesn't exist
281
- if [[ ! -f "$PROJECT_ROOT/$AGENTS_DIR/config" ]]; then
282
- cat > "$PROJECT_ROOT/$AGENTS_DIR/config" <<CONFIG_EOF
283
- # sync-agents configuration
284
- # Comma-separated list of sync targets (available: claude, windsurf, cursor, copilot)
285
- # Override per-command with: sync-agents sync --targets claude,cursor
286
- targets = claude,windsurf,cursor,copilot
287
- CONFIG_EOF
288
- info "Created $AGENTS_DIR/config"
289
- else
290
- warn "$AGENTS_DIR/config already exists, skipping"
291
- fi
292
-
293
- # Generate AGENTS.md if it doesn't exist
294
- if [[ ! -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
295
- generate_agents_md
296
- info "Created $AGENTS_MD"
297
- else
298
- warn "$AGENTS_MD already exists, skipping (run 'sync-agents index' to regenerate)"
299
- fi
300
-
301
- # Add default .gitignore entries for agent tool directories
302
- add_default_gitignore_entries
303
-
304
- info "Initialization complete. Directory structure:"
305
- print_tree "$PROJECT_ROOT/$AGENTS_DIR"
306
- }
307
-
308
- cmd_add() {
309
- local type="${1:-}"
310
- local name="${2:-}"
311
-
312
- if [[ -z "$type" ]] || [[ -z "$name" ]]; then
313
- error "Usage: sync-agents add <rule|skill|workflow> <name>"
314
- exit 1
315
- fi
316
-
317
- case "$type" in
318
- rule|rules) type="rules" ;;
319
- skill|skills) type="skills" ;;
320
- workflow|workflows) type="workflows" ;;
321
- *)
322
- error "Unknown type: $type. Must be one of: rule, skill, workflow"
323
- exit 1
324
- ;;
325
- esac
326
-
327
- ensure_agents_dir
328
-
329
- # Skills use directory layout: skills/name/SKILL.md
330
- # Rules and workflows use flat files: rules/name.md, workflows/name.md
331
- local filepath
332
- if [[ "$type" == "skills" ]]; then
333
- filepath="$PROJECT_ROOT/$AGENTS_DIR/$type/$name/SKILL.md"
334
- else
335
- filepath="$PROJECT_ROOT/$AGENTS_DIR/$type/$name.md"
336
- fi
337
-
338
- if [[ -f "$filepath" ]] && [[ "$FORCE" != "true" ]]; then
339
- error "File already exists: $filepath (use --force to overwrite)"
340
- exit 1
341
- fi
342
-
343
- # Use type-specific template (RULE_TEMPLATE, SKILL_TEMPLATE, WORKFLOW_TEMPLATE)
344
- local template_name
345
- case "$type" in
346
- rules) template_name="RULE_TEMPLATE.md" ;;
347
- skills) template_name="SKILL_TEMPLATE.md" ;;
348
- workflows) template_name="WORKFLOW_TEMPLATE.md" ;;
349
- *) template_name="RULE_TEMPLATE.md" ;;
350
- esac
351
-
352
- # Create parent directory for skills
353
- mkdir -p "$(dirname "$filepath")"
354
-
355
- if [[ -f "$TEMPLATES_DIR/$template_name" ]]; then
356
- sed "s/\${NAME}/$name/g" "$TEMPLATES_DIR/$template_name" > "$filepath"
357
- elif [[ -f "$TEMPLATES_DIR/RULE_TEMPLATE.md" ]]; then
358
- # Fallback to rule template if type-specific template missing
359
- sed "s/\${NAME}/$name/g" "$TEMPLATES_DIR/RULE_TEMPLATE.md" > "$filepath"
360
- else
361
- cat > "$filepath" <<TMPL_EOF
362
- ---
363
- trigger: always_on
364
- ---
365
-
366
- # $name
367
- TMPL_EOF
368
- fi
369
-
370
- info "Created $type: $filepath"
371
-
372
- # Regenerate index
373
- generate_agents_md
374
- info "Updated $AGENTS_MD index"
375
- }
376
-
377
- cmd_fix() {
378
- ensure_agents_dir
379
-
380
- # Parse fix-specific flags
381
- local no_clobber="false"
382
- local fix_args=()
383
- for arg in "$@"; do
384
- case "$arg" in
385
- --no-clobber) no_clobber="true" ;;
386
- *) fix_args+=("$arg") ;;
387
- esac
388
- done
389
-
390
- local fix_type="${fix_args[0]:-all}"
391
- local subdirs=()
392
-
393
- case "$fix_type" in
394
- skills|rules|workflows)
395
- subdirs=("$fix_type")
396
- ;;
397
- all)
398
- subdirs=(skills rules workflows)
399
- ;;
400
- *)
401
- error "Unknown type: $fix_type (expected: skills, rules, workflows, or all)"
402
- exit 1
403
- ;;
404
- esac
405
-
406
- local agents_abs
407
- agents_abs="$(cd "$PROJECT_ROOT/$AGENTS_DIR" && pwd)"
408
- local fixed=0
409
- local skipped=0
410
- local merged=0
411
-
412
- # Helper: compare inodes of two directories (portable across macOS/Linux)
413
- same_inode() {
414
- local inode_a inode_b
415
- if stat --version >/dev/null 2>&1; then
416
- # GNU stat (Linux)
417
- inode_a="$(stat -c '%i' "$1" 2>/dev/null)"
418
- inode_b="$(stat -c '%i' "$2" 2>/dev/null)"
419
- else
420
- # BSD stat (macOS)
421
- inode_a="$(stat -f '%i' "$1" 2>/dev/null)"
422
- inode_b="$(stat -f '%i' "$2" 2>/dev/null)"
423
- fi
424
- [[ -n "$inode_a" ]] && [[ "$inode_a" == "$inode_b" ]]
425
- }
426
-
427
- for subdir in "${subdirs[@]}"; do
428
- local legacy_dir="$PROJECT_ROOT/$subdir"
429
- local agents_subdir="$agents_abs/$subdir"
430
-
431
- # Skip if legacy dir doesn't exist or is already a symlink
432
- if [[ ! -d "$legacy_dir" ]]; then
433
- continue
434
- fi
435
- if [[ -L "$legacy_dir" ]]; then
436
- info "$subdir/ is already a symlink — nothing to do."
437
- continue
438
- fi
439
-
440
- # Detect same-inode (legacy dir IS .agents/subdir — e.g. hardlink or bind mount)
441
- if [[ -d "$agents_subdir" ]] && same_inode "$legacy_dir" "$agents_subdir"; then
442
- warn "$subdir/ and $AGENTS_DIR/$subdir/ are the same directory (same inode)."
443
- warn "Replacing $subdir/ with a symlink to $AGENTS_DIR/$subdir/."
444
- if [[ "$DRY_RUN" == "true" ]]; then
445
- echo " would remove $subdir/ (same inode as $AGENTS_DIR/$subdir/)"
446
- echo " would create symlink $subdir/ -> $AGENTS_DIR/$subdir"
447
- else
448
- rm -rf "$legacy_dir"
449
- ln -s "$AGENTS_DIR/$subdir" "$legacy_dir"
450
- info "Replaced $subdir/ with symlink -> $AGENTS_DIR/$subdir"
451
- fi
452
- fixed=$((fixed + 1))
453
- continue
454
- fi
455
-
456
- info "Found legacy directory: $subdir/"
457
- mkdir -p "$agents_subdir"
458
-
459
- # Move each item from legacy dir into .agents/subdir
460
- for item in "$legacy_dir"/*/; do
461
- [[ -d "$item" ]] || continue
462
- local name
463
- name="$(basename "$item")"
464
-
465
- if [[ -d "$agents_subdir/$name" ]]; then
466
- if [[ "$no_clobber" == "true" ]]; then
467
- warn "Skipping $subdir/$name — already exists in $AGENTS_DIR/$subdir/ (--no-clobber)"
468
- skipped=$((skipped + 1))
469
- continue
470
- fi
471
- # Merge: legacy content wins (overwrite into .agents/)
472
- if [[ "$DRY_RUN" == "true" ]]; then
473
- echo " would merge: $subdir/$name -> $AGENTS_DIR/$subdir/$name (overwrite)"
474
- else
475
- rm -rf "${agents_subdir:?}/${name:?}"
476
- mv "$item" "$agents_subdir/$name"
477
- info "Merged: $subdir/$name -> $AGENTS_DIR/$subdir/$name (overwrote existing)"
478
- fi
479
- merged=$((merged + 1))
480
- fixed=$((fixed + 1))
481
- continue
482
- fi
483
-
484
- if [[ "$DRY_RUN" == "true" ]]; then
485
- echo " would move: $subdir/$name -> $AGENTS_DIR/$subdir/$name"
486
- else
487
- mv "$item" "$agents_subdir/$name"
488
- info "Moved: $subdir/$name -> $AGENTS_DIR/$subdir/$name"
489
- fi
490
- fixed=$((fixed + 1))
491
- done
492
-
493
- # Also move any top-level files (e.g. loose .md rules)
494
- for item in "$legacy_dir"/*; do
495
- [[ -f "$item" ]] || continue
496
- local name
497
- name="$(basename "$item")"
498
-
499
- if [[ -f "$agents_subdir/$name" ]]; then
500
- if [[ "$no_clobber" == "true" ]]; then
501
- warn "Skipping $subdir/$name — already exists in $AGENTS_DIR/$subdir/ (--no-clobber)"
502
- skipped=$((skipped + 1))
503
- continue
504
- fi
505
- # Merge: legacy content wins
506
- if [[ "$DRY_RUN" == "true" ]]; then
507
- echo " would merge: $subdir/$name -> $AGENTS_DIR/$subdir/$name (overwrite)"
508
- else
509
- mv "$item" "$agents_subdir/$name"
510
- info "Merged: $subdir/$name -> $AGENTS_DIR/$subdir/$name (overwrote existing)"
511
- fi
512
- merged=$((merged + 1))
513
- fixed=$((fixed + 1))
514
- continue
515
- fi
516
-
517
- if [[ "$DRY_RUN" == "true" ]]; then
518
- echo " would move: $subdir/$name -> $AGENTS_DIR/$subdir/$name"
519
- else
520
- mv "$item" "$agents_subdir/$name"
521
- info "Moved: $subdir/$name -> $AGENTS_DIR/$subdir/$name"
522
- fi
523
- fixed=$((fixed + 1))
524
- done
525
-
526
- # Remove the now-empty legacy dir and replace with symlink
527
- if [[ "$DRY_RUN" == "true" ]]; then
528
- echo " would replace $subdir/ with symlink -> $AGENTS_DIR/$subdir"
529
- else
530
- # Check if dir is empty (only . and .. remain)
531
- if [[ -z "$(ls -A "$legacy_dir" 2>/dev/null)" ]]; then
532
- rmdir "$legacy_dir"
533
- ln -s "$AGENTS_DIR/$subdir" "$legacy_dir"
534
- info "Replaced $subdir/ with symlink -> $AGENTS_DIR/$subdir"
535
- else
536
- warn "$subdir/ is not empty after migration — skipping symlink replacement"
537
- warn "Remaining items:"
538
- find "$legacy_dir" -mindepth 1 -maxdepth 1 -exec basename {} \; | sed 's/^/ /'
539
- fi
540
- fi
541
- done
542
-
543
- # --- Phase 1b: Convert flat skill files to directory layout ---
544
- # e.g. .agents/skills/foo.md -> .agents/skills/foo/SKILL.md
545
- for subdir in "${subdirs[@]}"; do
546
- [[ "$subdir" == "skills" ]] || continue
547
- local skills_dir="$agents_abs/skills"
548
- [[ -d "$skills_dir" ]] || continue
549
-
550
- for flat_file in "$skills_dir"/*.md; do
551
- [[ -f "$flat_file" ]] || continue
552
- local name
553
- name="$(basename "$flat_file" .md)"
554
- local target_dir="$skills_dir/$name"
555
- local target_file="$target_dir/SKILL.md"
556
-
557
- if [[ -d "$target_dir" ]] && [[ -f "$target_file" ]]; then
558
- if [[ "$no_clobber" == "true" ]]; then
559
- warn "Skipping flat skill $name.md — $name/SKILL.md already exists (--no-clobber)"
560
- skipped=$((skipped + 1))
561
- continue
562
- fi
563
- # Flat file wins (same merge behavior as legacy migration)
564
- if [[ "$DRY_RUN" == "true" ]]; then
565
- echo " would convert: skills/$name.md -> skills/$name/SKILL.md (overwrite)"
566
- else
567
- mv "$flat_file" "$target_file"
568
- info "Converted: skills/$name.md -> skills/$name/SKILL.md (overwrote existing)"
569
- fi
570
- merged=$((merged + 1))
571
- fixed=$((fixed + 1))
572
- continue
573
- fi
574
-
575
- if [[ "$DRY_RUN" == "true" ]]; then
576
- echo " would convert: skills/$name.md -> skills/$name/SKILL.md"
577
- else
578
- mkdir -p "$target_dir"
579
- mv "$flat_file" "$target_file"
580
- info "Converted: skills/$name.md -> skills/$name/SKILL.md"
581
- fi
582
- fixed=$((fixed + 1))
583
- done
584
- done
585
-
586
- # --- Phase 2: Repair broken/missing symlinks ---
587
- local repaired=0
588
-
589
- for target in "${ACTIVE_TARGETS[@]}"; do
590
- local target_dir
591
- target_dir="$(resolve_target_dir "$target" "$PROJECT_ROOT")"
592
- local agents_rel
593
- agents_rel="$(resolve_agents_rel "$target")"
594
-
595
- for subdir in "${subdirs[@]}"; do
596
- if [[ ! -d "$agents_abs/$subdir" ]]; then
597
- continue
598
- fi
599
- local expected_link="$target_dir/$subdir"
600
- local expected_source="$agents_rel/$subdir"
601
-
602
- if [[ -L "$expected_link" ]]; then
603
- local current_target
604
- current_target="$(readlink "$expected_link")"
605
- if [[ "$current_target" == "$expected_source" ]]; then
606
- continue # Already correct
607
- fi
608
- # Symlink exists but points to wrong target
609
- if [[ "$DRY_RUN" == "true" ]]; then
610
- echo " would relink: $expected_link -> $expected_source (was $current_target)"
611
- else
612
- rm "$expected_link"
613
- create_symlink "$expected_source" "$expected_link" "false"
614
- info "Repaired: $expected_link -> $expected_source (was $current_target)"
615
- fi
616
- repaired=$((repaired + 1))
617
- elif [[ -e "$expected_link" ]]; then
618
- # Something exists but isn't a symlink — skip unless --force
619
- if [[ "$FORCE" == "true" ]]; then
620
- if [[ "$DRY_RUN" == "true" ]]; then
621
- echo " would replace: $expected_link with symlink -> $expected_source"
622
- else
623
- rm -rf "$expected_link"
624
- create_symlink "$expected_source" "$expected_link" "false"
625
- info "Repaired: replaced $expected_link with symlink -> $expected_source"
626
- fi
627
- repaired=$((repaired + 1))
628
- else
629
- warn "$expected_link exists but is not a symlink (use --force to replace)"
630
- fi
631
- else
632
- # Missing entirely
633
- if [[ "$DRY_RUN" == "true" ]]; then
634
- echo " would create: $expected_link -> $expected_source"
635
- else
636
- create_symlink "$expected_source" "$expected_link" "false"
637
- fi
638
- repaired=$((repaired + 1))
639
- fi
640
- done
641
- done
642
-
643
- # Repair CLAUDE.md symlink
644
- if [[ -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
645
- if [[ -L "$PROJECT_ROOT/CLAUDE.md" ]]; then
646
- local current_target
647
- current_target="$(readlink "$PROJECT_ROOT/CLAUDE.md")"
648
- if [[ "$current_target" != "$AGENTS_MD" ]]; then
649
- if [[ "$DRY_RUN" == "true" ]]; then
650
- echo " would relink: CLAUDE.md -> $AGENTS_MD (was $current_target)"
651
- else
652
- rm "$PROJECT_ROOT/CLAUDE.md"
653
- create_symlink "$AGENTS_MD" "$PROJECT_ROOT/CLAUDE.md" "false"
654
- fi
655
- repaired=$((repaired + 1))
656
- fi
657
- elif [[ ! -e "$PROJECT_ROOT/CLAUDE.md" ]]; then
658
- if [[ "$DRY_RUN" == "true" ]]; then
659
- echo " would create: CLAUDE.md -> $AGENTS_MD"
660
- else
661
- create_symlink "$AGENTS_MD" "$PROJECT_ROOT/CLAUDE.md" "false"
662
- fi
663
- repaired=$((repaired + 1))
664
- fi
665
- fi
666
-
667
- # --- Phase 3: Migrate legacy STATE.md to per-file state pattern ---
668
- local state_migrated=0
669
- if [[ -f "$agents_abs/STATE.md" ]]; then
670
- # Ensure the state rule exists first
671
- if [[ ! -f "$agents_abs/rules/state.md" ]]; then
672
- if [[ -f "$TEMPLATES_DIR/STATE_TEMPLATE.md" ]]; then
673
- if [[ "$DRY_RUN" == "true" ]]; then
674
- echo " would create: $AGENTS_DIR/rules/state.md from template"
675
- else
676
- mkdir -p "$agents_abs/rules"
677
- cp "$TEMPLATES_DIR/STATE_TEMPLATE.md" "$agents_abs/rules/state.md"
678
- info "Created $AGENTS_DIR/rules/state.md (state convention rule)"
679
- fi
680
- fi
681
- fi
682
-
683
- if [[ "$DRY_RUN" == "true" ]]; then
684
- echo " would migrate: $AGENTS_DIR/STATE.md → per-file state pattern"
685
- else
686
- migrate_legacy_state "$agents_abs"
687
- fi
688
- state_migrated=1
689
- fi
690
-
691
- # Summary
692
- if [[ "$fixed" -eq 0 ]] && [[ "$skipped" -eq 0 ]] && [[ "$repaired" -eq 0 ]] && [[ "$state_migrated" -eq 0 ]]; then
693
- info "Nothing to fix — all directories and symlinks are correct."
694
- else
695
- if [[ "$fixed" -gt 0 ]]; then info "Fixed $fixed item(s)."; fi
696
- if [[ "$merged" -gt 0 ]]; then info "Merged $merged item(s) (legacy overwrote existing)."; fi
697
- if [[ "$skipped" -gt 0 ]]; then warn "Skipped $skipped item(s) (use without --no-clobber to merge)."; fi
698
- if [[ "$repaired" -gt 0 ]]; then info "Repaired $repaired symlink(s)."; fi
699
- if [[ "$state_migrated" -gt 0 ]]; then info "Migrated legacy STATE.md to per-file state pattern."; fi
700
- if [[ "$fixed" -gt 0 ]]; then info "Run 'sync-agents sync' to update agent target symlinks."; fi
701
- fi
702
- }
703
-
704
- cmd_sync() {
705
- ensure_agents_dir
706
-
707
- local agents_abs
708
- agents_abs="$(cd "$PROJECT_ROOT/$AGENTS_DIR" && pwd)"
709
-
710
- info "Syncing $AGENTS_DIR/ to agent directories..."
711
-
712
- for target in "${ACTIVE_TARGETS[@]}"; do
713
- local target_dir
714
- target_dir="$(resolve_target_dir "$target" "$PROJECT_ROOT")"
715
- local agents_rel
716
- agents_rel="$(resolve_agents_rel "$target")"
717
- info "Syncing to ${target_dir#"$PROJECT_ROOT"/}/"
718
-
719
- # Sync subdirectories: rules, skills, workflows
720
- for subdir in rules skills workflows; do
721
- if [[ -d "$agents_abs/$subdir" ]]; then
722
- local source_rel="$agents_rel/$subdir"
723
- create_symlink "$source_rel" "$target_dir/$subdir" "$DRY_RUN"
724
- fi
725
- done
726
- done
727
-
728
- # Symlink AGENTS.md -> CLAUDE.md
729
- if [[ -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
730
- create_symlink "$AGENTS_MD" "$PROJECT_ROOT/CLAUDE.md" "$DRY_RUN"
731
- fi
732
-
733
- # Update .gitignore with synced symlink entries
734
- update_gitignore
735
-
736
- info "Sync complete."
737
- }
738
-
739
- # --------------------------------------------------------------------------
740
- # .gitignore management
741
- # --------------------------------------------------------------------------
742
-
743
- # Add default .gitignore entries for agent tool directories (called during init)
744
- add_default_gitignore_entries() {
745
- local gitignore="$PROJECT_ROOT/.gitignore"
746
-
747
- # Create .gitignore if it doesn't exist
748
- if [[ ! -f "$gitignore" ]]; then
749
- touch "$gitignore"
750
- info "Created .gitignore"
751
- fi
752
-
753
- # Check if .DS_Store is already present (case-insensitive check)
754
- if ! grep -qiE "^\.DS_Store$" "$gitignore" 2>/dev/null; then
755
- # Add .DS_Store if not present
756
- if [[ -s "$gitignore" ]] && ! tail -c1 "$gitignore" | grep -q '^$'; then
757
- echo "" >> "$gitignore"
758
- fi
759
- echo ".DS_Store" >> "$gitignore"
760
- info "Added .DS_Store to .gitignore"
761
- fi
762
-
763
- # Define default entries (tool artifacts, not symlinks)
764
- # Using pattern: ignore everything in dir, except specific files we want to track
765
- local marker="# sync-agents — ignore tool artifacts, keep symlinks"
766
-
767
- # Check if sync-agents section already exists
768
- if grep -qF "$marker" "$gitignore"; then
769
- # Section exists - check if we need to add any missing entries
770
- local needs_update=false
771
-
772
- # Check for each pattern
773
- if ! grep -qF ".cursor/*" "$gitignore"; then needs_update=true; fi
774
- if ! grep -qF "!.cursor/rules" "$gitignore"; then needs_update=true; fi
775
- if ! grep -qF ".codex/*" "$gitignore"; then needs_update=true; fi
776
- if ! grep -qF "!.codex/instructions.md" "$gitignore"; then needs_update=true; fi
777
- if ! grep -qF ".github/copilot/*" "$gitignore"; then needs_update=true; fi
778
- if ! grep -qF "!.github/copilot/instructions.md" "$gitignore"; then needs_update=true; fi
779
-
780
- if [[ "$needs_update" == "true" ]]; then
781
- # Rebuild section by reading the file, preserving everything else
782
- local tmp
783
- tmp="$(mktemp)"
784
- local in_section=false
785
-
786
- while IFS= read -r line; do
787
- if [[ "$line" == "$marker" ]]; then
788
- in_section=true
789
- # Output the marker
790
- {
791
- echo "$line"
792
- echo ".cursor/*"
793
- echo "!.cursor/rules"
794
- echo ".codex/*"
795
- echo "!.codex/instructions.md"
796
- echo ".github/copilot/*"
797
- echo "!.github/copilot/instructions.md"
798
- } >> "$tmp"
799
- continue
800
- fi
801
-
802
- # Skip old entries in the sync-agents section (until we hit empty line or new section)
803
- if [[ "$in_section" == "true" ]]; then
804
- if [[ -z "$line" ]] || [[ "$line" == "#"* ]]; then
805
- in_section=false
806
- echo "$line" >> "$tmp"
807
- fi
808
- # Skip old entry lines (they're replaced above)
809
- continue
810
- fi
811
-
812
- echo "$line" >> "$tmp"
813
- done < "$gitignore"
814
-
815
- mv "$tmp" "$gitignore"
816
- info "Updated sync-agents section in .gitignore"
817
- fi
818
- else
819
- # Section doesn't exist, add entire block
820
- # Add separator if file is non-empty
821
- if [[ -s "$gitignore" ]] && ! tail -c1 "$gitignore" | grep -q '^$'; then
822
- echo "" >> "$gitignore"
823
- fi
824
-
825
- # Add all entries
826
- {
827
- echo "$marker"
828
- echo ".cursor/*"
829
- echo "!.cursor/rules"
830
- echo ".codex/*"
831
- echo "!.codex/instructions.md"
832
- echo ".github/copilot/*"
833
- echo "!.github/copilot/instructions.md"
834
- } >> "$gitignore"
835
-
836
- info "Added sync-agents section to .gitignore with 7 entries"
837
- fi
838
- }
839
-
840
- update_gitignore() {
841
- local gitignore="$PROJECT_ROOT/.gitignore"
842
-
843
- # Build list of entries that should be ignored (synced symlinks)
844
- local entries=()
845
- for target in "${ACTIVE_TARGETS[@]}"; do
846
- local target_dir
847
- target_dir="$(resolve_target_dir "$target" "$PROJECT_ROOT")"
848
- local rel_path="${target_dir#"$PROJECT_ROOT"/}/"
849
- entries+=("$rel_path")
850
- done
851
- entries+=("CLAUDE.md")
852
-
853
- if [[ "$DRY_RUN" == "true" ]]; then
854
- for entry in "${entries[@]}"; do
855
- if [[ ! -f "$gitignore" ]] || ! grep -qxF "$entry" "$gitignore"; then
856
- echo " would add to .gitignore: $entry"
857
- fi
858
- done
859
- return 0
860
- fi
861
-
862
- # Create .gitignore if it doesn't exist
863
- [[ -f "$gitignore" ]] || touch "$gitignore"
864
-
865
- local added=0
866
- for entry in "${entries[@]}"; do
867
- if ! grep -qxF "$entry" "$gitignore"; then
868
- # Add sync-agents header on first addition
869
- if [[ "$added" -eq 0 ]]; then
870
- # Check if header already exists
871
- if ! grep -qF "# sync-agents" "$gitignore"; then
872
- # Add a blank line separator if file is non-empty
873
- if [[ -s "$gitignore" ]]; then
874
- echo "" >> "$gitignore"
875
- fi
876
- echo "# sync-agents (generated symlinks)" >> "$gitignore"
877
- fi
878
- fi
879
- echo "$entry" >> "$gitignore"
880
- added=$((added + 1))
881
- fi
882
- done
883
-
884
- if [[ "$added" -gt 0 ]]; then
885
- info "Added $added entries to .gitignore"
886
- fi
887
- }
888
-
889
- cmd_status() {
890
- echo -e "${BOLD}sync-agents${RESET} v${VERSION}"
891
- echo ""
892
-
893
- # Check .agents/ directory
894
- if [[ -d "$PROJECT_ROOT/$AGENTS_DIR" ]]; then
895
- echo -e "${GREEN}[ok]${RESET} $AGENTS_DIR/ exists"
896
- print_tree "$PROJECT_ROOT/$AGENTS_DIR"
897
- else
898
- echo -e "${RED}[missing]${RESET} $AGENTS_DIR/ not found"
899
- fi
900
-
901
- echo ""
902
-
903
- # Check AGENTS.md
904
- if [[ -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
905
- echo -e "${GREEN}[ok]${RESET} $AGENTS_MD exists"
906
- else
907
- echo -e "${RED}[missing]${RESET} $AGENTS_MD not found"
908
- fi
909
-
910
- # Check CLAUDE.md symlink
911
- if [[ -L "$PROJECT_ROOT/CLAUDE.md" ]]; then
912
- local link_target
913
- link_target="$(readlink "$PROJECT_ROOT/CLAUDE.md")"
914
- echo -e "${GREEN}[ok]${RESET} CLAUDE.md -> $link_target"
915
- elif [[ -f "$PROJECT_ROOT/CLAUDE.md" ]]; then
916
- echo -e "${YELLOW}[warn]${RESET} CLAUDE.md exists but is not a symlink"
917
- else
918
- echo -e "${RED}[missing]${RESET} CLAUDE.md not found"
919
- fi
920
-
921
- echo ""
922
-
923
- # Check each target
924
- for target in "${TARGETS[@]}"; do
925
- local target_dir
926
- target_dir="$(resolve_target_dir "$target" "$PROJECT_ROOT")"
927
- local display_dir="${target_dir#"$PROJECT_ROOT"/}"
928
- if [[ -d "$target_dir" ]] || [[ -L "$target_dir/rules" ]]; then
929
- echo -e "${CYAN}${display_dir}/${RESET}"
930
- for subdir in rules skills workflows; do
931
- if [[ -L "$target_dir/$subdir" ]]; then
932
- local link_target
933
- link_target="$(readlink "$target_dir/$subdir")"
934
- echo -e " ${GREEN}[synced]${RESET} $subdir -> $link_target"
935
- elif [[ -d "$target_dir/$subdir" ]]; then
936
- echo -e " ${YELLOW}[local]${RESET} $subdir (not symlinked)"
937
- else
938
- echo -e " ${RED}[missing]${RESET} $subdir"
939
- fi
940
- done
941
- else
942
- echo -e "${RED}[not synced]${RESET} ${display_dir}/"
943
- fi
944
- done
945
- }
946
-
947
- cmd_index() {
948
- ensure_agents_dir
949
- generate_agents_md
950
- info "Regenerated $AGENTS_MD"
951
- }
952
-
953
- cmd_watch() {
954
- ensure_agents_dir
955
-
956
- local watch_dir="$PROJECT_ROOT/$AGENTS_DIR"
957
-
958
- if command -v fswatch >/dev/null 2>&1; then
959
- info "Watching $AGENTS_DIR/ for changes... (Ctrl+C to stop)"
960
- cmd_index
961
- fswatch -o "$watch_dir" | while read -r _; do
962
- info "Change detected, regenerating index..."
963
- cmd_index
964
- done
965
- elif command -v inotifywait >/dev/null 2>&1; then
966
- info "Watching $AGENTS_DIR/ for changes... (Ctrl+C to stop)"
967
- cmd_index
968
- inotifywait -m -r -e modify,create,delete,move --format '%w%f' "$watch_dir" | while read -r _; do
969
- info "Change detected, regenerating index..."
970
- cmd_index
971
- done
972
- else
973
- error "Neither fswatch (macOS) nor inotifywait (Linux) found."
974
- error "Install with: brew install fswatch OR apt install inotify-tools"
975
- exit 1
976
- fi
977
- }
978
-
979
- cmd_import() {
980
- local url="${1:-}"
981
- if [[ -z "$url" ]]; then
982
- error "Usage: sync-agents import <url>"
983
- exit 1
984
- fi
985
-
986
- ensure_agents_dir
987
-
988
- local filename
989
- filename="$(basename "$url")"
990
- if [[ "$filename" != *.md ]]; then
991
- filename="${filename}.md"
992
- fi
993
-
994
- # Auto-detect type from URL path
995
- local type=""
996
- case "$url" in
997
- */rules/*) type="rules" ;;
998
- */skills/*) type="skills" ;;
999
- */workflows/*) type="workflows" ;;
1000
- esac
1001
-
1002
- if [[ -z "$type" ]]; then
1003
- echo "Could not detect type from URL. Choose:"
1004
- echo " 1) rule"
1005
- echo " 2) skill"
1006
- echo " 3) workflow"
1007
- read -rp "Selection (1-3): " choice
1008
- case "$choice" in
1009
- 1) type="rules" ;;
1010
- 2) type="skills" ;;
1011
- 3) type="workflows" ;;
1012
- *) error "Invalid selection"; exit 1 ;;
1013
- esac
1014
- fi
1015
-
1016
- mkdir -p "$PROJECT_ROOT/$AGENTS_DIR/$type"
1017
- local dest="$PROJECT_ROOT/$AGENTS_DIR/$type/$filename"
1018
-
1019
- info "Importing $url → $AGENTS_DIR/$type/$filename"
1020
-
1021
- if ! curl -fsSL "$url" -o "$dest"; then
1022
- error "Failed to download: $url"
1023
- exit 1
1024
- fi
1025
-
1026
- info "Imported successfully."
1027
- cmd_index
1028
- }
1029
-
1030
- cmd_hook() {
1031
- if [[ ! -d "$PROJECT_ROOT/.git" ]]; then
1032
- error "Not a git repository (no .git/ found)."
1033
- exit 1
1034
- fi
1035
-
1036
- local hook_dir="$PROJECT_ROOT/.git/hooks"
1037
- local hook_file="$hook_dir/pre-commit"
1038
- mkdir -p "$hook_dir"
1039
-
1040
- local marker="sync-agents start"
1041
-
1042
- if [[ -f "$hook_file" ]] && grep -q "$marker" "$hook_file"; then
1043
- info "Git hook already installed in $hook_file"
1044
- return 0
1045
- fi
1046
-
1047
- local hook_block
1048
- hook_block="$(cat <<'HOOK'
1049
-
1050
- # --- sync-agents start ---
1051
- if command -v sync-agents >/dev/null 2>&1; then
1052
- sync-agents sync 2>/dev/null
1053
- sync-agents index 2>/dev/null
1054
- git add AGENTS.md CLAUDE.md .claude/ .windsurf/ .cursor/ .github/copilot/ 2>/dev/null || true
1055
- fi
1056
- # --- sync-agents end ---
1057
- HOOK
1058
- )"
1059
-
1060
- if [[ -f "$hook_file" ]]; then
1061
- echo "$hook_block" >> "$hook_file"
1062
- info "Appended sync-agents hook to existing $hook_file"
1063
- else
1064
- printf '#!/bin/sh\n%s\n' "$hook_block" > "$hook_file"
1065
- chmod +x "$hook_file"
1066
- info "Created git hook: $hook_file"
1067
- fi
1068
- }
1069
-
1070
- cmd_clean() {
1071
- info "Removing synced symlinks..."
1072
-
1073
- for target in "${ACTIVE_TARGETS[@]}"; do
1074
- local target_dir
1075
- target_dir="$(resolve_target_dir "$target" "$PROJECT_ROOT")"
1076
- local display_dir="${target_dir#"$PROJECT_ROOT"/}"
1077
- for subdir in rules skills workflows; do
1078
- if [[ -L "$target_dir/$subdir" ]]; then
1079
- rm "$target_dir/$subdir"
1080
- info "Removed: ${display_dir}/$subdir"
1081
- fi
1082
- done
1083
-
1084
- # Remove target dir if empty
1085
- if [[ -d "$target_dir" ]] && [[ -z "$(ls -A "$target_dir" 2>/dev/null)" ]]; then
1086
- rmdir "$target_dir"
1087
- info "Removed empty directory: ${display_dir}/"
1088
- fi
1089
- done
1090
-
1091
- # Remove CLAUDE.md symlink
1092
- if [[ -L "$PROJECT_ROOT/CLAUDE.md" ]]; then
1093
- rm "$PROJECT_ROOT/CLAUDE.md"
1094
- info "Removed: CLAUDE.md symlink"
1095
- fi
1096
-
1097
- info "Clean complete."
1098
- }
1099
-
1100
- # --------------------------------------------------------------------------
1101
- # Inherit
1102
- # --------------------------------------------------------------------------
1103
-
1104
- cmd_inherit() {
1105
- local action="${1:-}"
1106
-
1107
- # --list: show current inherits
1108
- if [[ "$action" == "--list" ]]; then
1109
- if [[ ! -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
1110
- info "No AGENTS.md found."
1111
- return 0
1112
- fi
1113
- local in_section="false"
1114
- while IFS= read -r line; do
1115
- if [[ "$line" =~ ^##[[:space:]]+Inherits ]]; then
1116
- in_section="true"
1117
- continue
1118
- fi
1119
- if [[ "$in_section" == "true" ]] && [[ "$line" =~ ^## ]]; then
1120
- break
1121
- fi
1122
- if [[ "$in_section" == "true" ]] && [[ "$line" =~ ^-[[:space:]]+\[ ]]; then
1123
- echo "$line"
1124
- fi
1125
- done < "$PROJECT_ROOT/$AGENTS_MD"
1126
- return 0
1127
- fi
1128
-
1129
- # --remove <label>: remove an inherit entry
1130
- if [[ "$action" == "--remove" ]]; then
1131
- local label="${2:-}"
1132
- if [[ -z "$label" ]]; then
1133
- error "Usage: sync-agents inherit --remove <label>"
1134
- exit 1
1135
- fi
1136
- if [[ ! -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
1137
- error "No AGENTS.md found."
1138
- exit 1
1139
- fi
1140
- # Remove the line matching [label](...) from the Inherits section
1141
- local tmp
1142
- tmp="$(mktemp)"
1143
- local in_section="false"
1144
- local removed="false"
1145
- while IFS= read -r line; do
1146
- if [[ "$line" =~ ^##[[:space:]]+Inherits ]]; then
1147
- in_section="true"
1148
- echo "$line" >> "$tmp"
1149
- continue
1150
- fi
1151
- if [[ "$in_section" == "true" ]] && [[ "$line" =~ ^## ]]; then
1152
- in_section="false"
1153
- fi
1154
- if [[ "$in_section" == "true" ]] && [[ "$line" == *"[$label]("* ]]; then
1155
- removed="true"
1156
- continue
1157
- fi
1158
- echo "$line" >> "$tmp"
1159
- done < "$PROJECT_ROOT/$AGENTS_MD"
1160
- mv "$tmp" "$PROJECT_ROOT/$AGENTS_MD"
1161
- if [[ "$removed" == "true" ]]; then
1162
- info "Removed inherit: $label"
1163
- else
1164
- warn "No inherit found with label: $label"
1165
- fi
1166
- return 0
1167
- fi
1168
-
1169
- # Default: add <label> <path>
1170
- local label="$action"
1171
- local path="${2:-}"
1172
-
1173
- if [[ -z "$label" ]] || [[ -z "$path" ]]; then
1174
- error "Usage: sync-agents inherit <label> <path>"
1175
- error " sync-agents inherit --list"
1176
- error " sync-agents inherit --remove <label>"
1177
- exit 1
1178
- fi
1179
-
1180
- # Validate the path exists (resolve relative to PROJECT_ROOT)
1181
- local resolved_path
1182
- if [[ "$path" == /* ]] || [[ "$path" == ~* ]]; then
1183
- resolved_path="${path/#\~/$HOME}"
1184
- else
1185
- resolved_path="$PROJECT_ROOT/$path"
1186
- fi
1187
-
1188
- if [[ ! -f "$resolved_path" ]] && [[ ! -d "$resolved_path" ]]; then
1189
- warn "Path does not exist: $path (link will be added anyway)"
1190
- fi
1191
-
1192
- # Check if AGENTS.md exists
1193
- if [[ ! -f "$PROJECT_ROOT/$AGENTS_MD" ]]; then
1194
- error "No AGENTS.md found. Run 'sync-agents init' first."
1195
- exit 1
1196
- fi
1197
-
1198
- # Check if Inherits section exists; if not, add it after the header
1199
- if ! grep -q "^## Inherits" "$PROJECT_ROOT/$AGENTS_MD"; then
1200
- # Insert Inherits section right after the header block (after first blank line following description)
1201
- local tmp
1202
- tmp="$(mktemp)"
1203
- local header_done="false"
1204
- local inherits_written="false"
1205
- while IFS= read -r line; do
1206
- echo "$line" >> "$tmp"
1207
- # Write inherits section after the description paragraph (first line starting with "This file")
1208
- if [[ "$header_done" == "false" ]] && [[ "$line" == "This file indexes"* ]]; then
1209
- header_done="true"
1210
- {
1211
- echo ""
1212
- echo "## Inherits"
1213
- echo ""
1214
- echo "- [$label]($path)"
1215
- } >> "$tmp"
1216
- inherits_written="true"
1217
- fi
1218
- done < "$PROJECT_ROOT/$AGENTS_MD"
1219
- # Fallback: if header pattern wasn't found, append at the end before ## Rules
1220
- if [[ "$inherits_written" == "false" ]]; then
1221
- rm "$tmp"
1222
- tmp="$(mktemp)"
1223
- while IFS= read -r line; do
1224
- if [[ "$line" == "## Rules" ]] && [[ "$inherits_written" == "false" ]]; then
1225
- {
1226
- echo "## Inherits"
1227
- echo ""
1228
- echo "- [$label]($path)"
1229
- echo ""
1230
- } >> "$tmp"
1231
- inherits_written="true"
1232
- fi
1233
- echo "$line" >> "$tmp"
1234
- done < "$PROJECT_ROOT/$AGENTS_MD"
1235
- fi
1236
- mv "$tmp" "$PROJECT_ROOT/$AGENTS_MD"
1237
- else
1238
- # Inherits section exists — check for duplicate label
1239
- if grep -q "\[$label\](" "$PROJECT_ROOT/$AGENTS_MD"; then
1240
- warn "Inherit with label '$label' already exists. Use --remove first to update."
1241
- return 1
1242
- fi
1243
- # Append to existing Inherits section (after last inherit entry or section header)
1244
- local tmp
1245
- tmp="$(mktemp)"
1246
- local in_section="false"
1247
- local added="false"
1248
- while IFS= read -r line; do
1249
- if [[ "$line" =~ ^##[[:space:]]+Inherits ]]; then
1250
- in_section="true"
1251
- echo "$line" >> "$tmp"
1252
- continue
1253
- fi
1254
- # When we hit the next section or blank line after entries, insert
1255
- if [[ "$in_section" == "true" ]] && [[ "$added" == "false" ]]; then
1256
- if [[ "$line" =~ ^## ]] || [[ -z "$line" ]]; then
1257
- # Check if previous content had entries; add after them
1258
- if [[ "$line" =~ ^## ]]; then
1259
- {
1260
- echo "- [$label]($path)"
1261
- echo ""
1262
- } >> "$tmp"
1263
- added="true"
1264
- in_section="false"
1265
- fi
1266
- fi
1267
- if [[ "$line" =~ ^-[[:space:]]+\[ ]]; then
1268
- echo "$line" >> "$tmp"
1269
- continue
1270
- fi
1271
- if [[ -z "$line" ]] && [[ "$added" == "false" ]]; then
1272
- echo "- [$label]($path)" >> "$tmp"
1273
- added="true"
1274
- in_section="false"
1275
- echo "$line" >> "$tmp"
1276
- continue
1277
- fi
1278
- fi
1279
- echo "$line" >> "$tmp"
1280
- done < "$PROJECT_ROOT/$AGENTS_MD"
1281
- # If we never added (section was at end of file)
1282
- if [[ "$added" == "false" ]]; then
1283
- echo "- [$label]($path)" >> "$tmp"
1284
- echo "" >> "$tmp"
1285
- fi
1286
- mv "$tmp" "$PROJECT_ROOT/$AGENTS_MD"
1287
- fi
1288
-
1289
- info "Added inherit: [$label]($path)"
1290
- }
1291
-
1292
- # --------------------------------------------------------------------------
1293
- # Index generator
1294
- # --------------------------------------------------------------------------
1295
-
1296
- generate_agents_md() {
1297
- local outfile="$PROJECT_ROOT/$AGENTS_MD"
1298
- local agents_dir="$PROJECT_ROOT/$AGENTS_DIR"
1299
-
1300
- # Preserve existing Inherits section before regenerating
1301
- local inherits_block=""
1302
- if [[ -f "$outfile" ]]; then
1303
- local in_section="false"
1304
- while IFS= read -r line; do
1305
- if [[ "$line" =~ ^##[[:space:]]+Inherits ]]; then
1306
- in_section="true"
1307
- inherits_block+="$line"$'\n'
1308
- continue
1309
- fi
1310
- if [[ "$in_section" == "true" ]] && [[ "$line" =~ ^## ]]; then
1311
- break
1312
- fi
1313
- if [[ "$in_section" == "true" ]]; then
1314
- inherits_block+="$line"$'\n'
1315
- fi
1316
- done < "$outfile"
1317
- fi
1318
-
1319
- cat > "$outfile" <<'HEADER'
1320
- ---
1321
- trigger: always_on
1322
- ---
1323
-
1324
- # AGENTS
1325
-
1326
- > Auto-generated by [sync-agents](https://github.com/brickhouse-tech/sync-agents). Do not edit manually.
1327
- > Run `sync-agents index` to regenerate.
1328
-
1329
- This file indexes all rules, skills, and workflows defined in `.agents/`.
1330
-
1331
- HEADER
1332
-
1333
- {
1334
- # Inherits (preserved from previous AGENTS.md)
1335
- if [[ -n "$inherits_block" ]]; then
1336
- printf '%s\n' "$inherits_block"
1337
- fi
1338
-
1339
- # Rules
1340
- echo "## Rules"
1341
- echo ""
1342
- if compgen -G "$agents_dir/rules/*.md" > /dev/null 2>&1; then
1343
- for f in "$agents_dir/rules/"*.md; do
1344
- local name
1345
- name="$(basename "$f" .md)"
1346
- echo "- [$name](.agents/rules/$name.md)"
1347
- done
1348
- else
1349
- echo "_No rules defined yet. Add one with \`sync-agents add rule <name>\`._"
1350
- fi
1351
- echo ""
1352
-
1353
- # Skills (directory layout: skills/name/SKILL.md, or legacy flat: skills/name.md)
1354
- echo "## Skills"
1355
- echo ""
1356
- local has_skills="false"
1357
- # Directory skills: skills/name/SKILL.md
1358
- for d in "$agents_dir/skills/"*/; do
1359
- [[ -d "$d" ]] || continue
1360
- local name
1361
- name="$(basename "$d")"
1362
- if [[ -f "$d/SKILL.md" ]]; then
1363
- echo "- [$name](.agents/skills/$name/SKILL.md)"
1364
- has_skills="true"
1365
- fi
1366
- done
1367
- # Legacy flat skills: skills/name.md
1368
- if compgen -G "$agents_dir/skills/*.md" > /dev/null 2>&1; then
1369
- for f in "$agents_dir/skills/"*.md; do
1370
- local name
1371
- name="$(basename "$f" .md)"
1372
- echo "- [$name](.agents/skills/$name.md)"
1373
- has_skills="true"
1374
- done
1375
- fi
1376
- if [[ "$has_skills" == "false" ]]; then
1377
- echo "_No skills defined yet. Add one with \`sync-agents add skill <name>\`._"
1378
- fi
1379
- echo ""
1380
-
1381
- # Workflows
1382
- echo "## Workflows"
1383
- echo ""
1384
- if compgen -G "$agents_dir/workflows/*.md" > /dev/null 2>&1; then
1385
- for f in "$agents_dir/workflows/"*.md; do
1386
- local name
1387
- name="$(basename "$f" .md)"
1388
- echo "- [$name](.agents/workflows/$name.md)"
1389
- done
1390
- else
1391
- echo "_No workflows defined yet. Add one with \`sync-agents add workflow <name>\`._"
1392
- fi
1393
- echo ""
1394
-
1395
- # State (list individual state snapshot files)
1396
- echo "## State"
1397
- echo ""
1398
- local has_state="false"
1399
- if compgen -G "$agents_dir/STATE_*.md" > /dev/null 2>&1; then
1400
- for f in "$agents_dir"/STATE_*.md; do
1401
- local name
1402
- name="$(basename "$f" .md)"
1403
- echo "- [$name](.agents/$(basename "$f"))"
1404
- has_state="true"
1405
- done
1406
- fi
1407
- # Legacy fallback: still list STATE.md if it exists (pre-migration)
1408
- if [[ -f "$agents_dir/STATE.md" ]]; then
1409
- echo "- [STATE.md](.agents/STATE.md)"
1410
- has_state="true"
1411
- fi
1412
- if [[ "$has_state" == "false" ]]; then
1413
- echo "_No state snapshots yet. Agents will create STATE_*context*_*timestamp*.md files as they work._"
1414
- fi
1415
- echo ""
1416
- } >> "$outfile"
1417
- }
1418
-
1419
- # --------------------------------------------------------------------------
1420
- # Tree printer (lightweight, no dependency on `tree`)
1421
- # --------------------------------------------------------------------------
1422
-
1423
- print_tree() {
1424
- local dir="$1"
1425
- local prefix="${2:-}"
1426
- local entries=()
1427
-
1428
- # Collect entries
1429
- while IFS= read -r entry; do
1430
- entries+=("$entry")
1431
- done < <(find "$dir" -maxdepth 1 -mindepth 1 -exec basename {} \; 2>/dev/null | sort)
1432
-
1433
- local count=${#entries[@]}
1434
- if [[ $count -eq 0 ]]; then
1435
- return 0
1436
- fi
1437
- local i=0
1438
-
1439
- for entry in "${entries[@]}"; do
1440
- i=$((i + 1))
1441
- local connector="├── "
1442
- local child_prefix="│ "
1443
- if [[ $i -eq $count ]]; then
1444
- connector="└── "
1445
- child_prefix=" "
1446
- fi
1447
-
1448
- if [[ -d "$dir/$entry" ]]; then
1449
- echo "${prefix}${connector}${entry}/"
1450
- print_tree "$dir/$entry" "${prefix}${child_prefix}"
1451
- elif [[ -L "$dir/$entry" ]]; then
1452
- local link_target
1453
- link_target="$(readlink "$dir/$entry")"
1454
- echo "${prefix}${connector}${entry} -> ${link_target}"
1455
- else
1456
- echo "${prefix}${connector}${entry}"
1457
- fi
1458
- done
1459
- }
1460
-
1461
- # --------------------------------------------------------------------------
1462
- # Main
1463
- # --------------------------------------------------------------------------
1464
-
1465
- main() {
1466
- local command=""
1467
- local custom_dir=""
1468
- local custom_targets=""
1469
- DRY_RUN="false"
1470
- FORCE="false"
1471
-
1472
- # Parse arguments
1473
- while [[ $# -gt 0 ]]; do
1474
- case "$1" in
1475
- -h|--help)
1476
- usage
1477
- exit 0
1478
- ;;
1479
- -v|--version)
1480
- echo "sync-agents v${VERSION}"
1481
- exit 0
1482
- ;;
1483
- -d|--dir)
1484
- custom_dir="$2"
1485
- shift 2
1486
- ;;
1487
- --targets)
1488
- custom_targets="$2"
1489
- shift 2
1490
- ;;
1491
- --dry-run)
1492
- DRY_RUN="true"
1493
- shift
1494
- ;;
1495
- --force)
1496
- FORCE="true"
1497
- shift
1498
- ;;
1499
- -*)
1500
- if [[ -n "$command" ]]; then
1501
- # Unknown flag after command — pass to subcommand
1502
- break
1503
- fi
1504
- error "Unknown option: $1"
1505
- usage
1506
- exit 1
1507
- ;;
1508
- *)
1509
- if [[ -z "$command" ]]; then
1510
- command="$1"
1511
- else
1512
- # Collect remaining args for subcommands
1513
- break
1514
- fi
1515
- shift
1516
- ;;
1517
- esac
1518
- done
1519
-
1520
- # Resolve project root
1521
- if [[ -n "$custom_dir" ]]; then
1522
- PROJECT_ROOT="$(cd "$custom_dir" && pwd)"
1523
- else
1524
- PROJECT_ROOT="$(find_project_root)"
1525
- fi
1526
-
1527
- # Resolve active targets (priority: --targets flag > .agents/config > built-in defaults)
1528
- if [[ -n "$custom_targets" ]]; then
1529
- IFS=',' read -ra ACTIVE_TARGETS <<< "$custom_targets"
1530
- else
1531
- local config_file="$PROJECT_ROOT/$AGENTS_DIR/config"
1532
- if [[ -f "$config_file" ]]; then
1533
- local config_targets
1534
- config_targets="$(sed -n 's/^targets *= *//p' "$config_file" | tr -d ' ')"
1535
- if [[ -n "$config_targets" ]]; then
1536
- IFS=',' read -ra ACTIVE_TARGETS <<< "$config_targets"
1537
- else
1538
- ACTIVE_TARGETS=("${TARGETS[@]}")
1539
- fi
1540
- else
1541
- ACTIVE_TARGETS=("${TARGETS[@]}")
1542
- fi
1543
- fi
1544
-
1545
- # Dispatch command
1546
- case "${command:-}" in
1547
- init)
1548
- cmd_init
1549
- ;;
1550
- sync)
1551
- cmd_sync
1552
- ;;
1553
- status)
1554
- cmd_status
1555
- ;;
1556
- add)
1557
- cmd_add "$@"
1558
- ;;
1559
- index)
1560
- cmd_index
1561
- ;;
1562
- clean)
1563
- cmd_clean
1564
- ;;
1565
- watch)
1566
- cmd_watch
1567
- ;;
1568
- import)
1569
- cmd_import "$@"
1570
- ;;
1571
- fix)
1572
- cmd_fix "$@"
1573
- ;;
1574
- hook)
1575
- cmd_hook
1576
- ;;
1577
- inherit)
1578
- cmd_inherit "$@"
1579
- ;;
1580
- version)
1581
- echo "sync-agents v${VERSION}"
1582
- exit 0
1583
- ;;
1584
- "")
1585
- usage
1586
- exit 0
1587
- ;;
1588
- *)
1589
- error "Unknown command: $command"
1590
- usage
1591
- exit 1
1592
- ;;
1593
- esac
1594
- }
1595
-
1596
- main "$@"