aidevops 3.13.95 → 3.14.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,1035 +0,0 @@
1
- #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
3
- # SPDX-FileCopyrightText: 2025-2026 Marcus Quinn
4
- # Agent deployment functions: deploy_aidevops_agents, deploy_ai_templates, inject_agents_reference
5
- # Part of aidevops setup.sh modularization (t316.3)
6
- # Split from original agent-deploy.sh (t1940): runtime conversion → agent-runtime.sh, beads/hooks → tool-beads.sh
7
-
8
- # Shell safety baseline
9
- set -Eeuo pipefail
10
- IFS=$'\n\t'
11
-
12
- #######################################
13
- # Restart the pulse process if it's running, so it picks up newly deployed
14
- # scripts. Bash processes source files at startup only — file changes on
15
- # disk don't affect a running process. The pulse is long-lived (hours/days)
16
- # so it would run stale code indefinitely without a restart.
17
- #
18
- # The pulse auto-restarts via launchd/cron, so killing it is safe. If no
19
- # auto-restart mechanism exists, we start it manually.
20
- #######################################
21
- _restart_pulse_if_running() {
22
- # Anchor the pattern so it only matches the running script, not editors or
23
- # grep commands that happen to contain "pulse-wrapper.sh" in their argv.
24
- local pattern="(^|/)pulse-wrapper\\.sh( |$)"
25
-
26
- if ! pgrep -f "$pattern" >/dev/null; then
27
- # Not running, nothing to do
28
- return 0
29
- fi
30
-
31
- local old_pid
32
- old_pid=$(pgrep -f "$pattern" | tail -1)
33
- if [[ -z "$old_pid" ]]; then
34
- return 0
35
- fi
36
- print_info "Restarting pulse (PID $old_pid) to load updated scripts..."
37
- pkill -f "$pattern" || true
38
-
39
- # Wait for it to die
40
- local wait_count=0
41
- while pgrep -f "$pattern" >/dev/null && [[ "$wait_count" -lt 10 ]]; do
42
- sleep 1
43
- wait_count=$((wait_count + 1))
44
- done
45
-
46
- # Give launchd/cron a moment to restart it
47
- sleep 5
48
-
49
- # Check if it auto-restarted with a different PID (guards against false
50
- # success if the old process failed to terminate).
51
- if pgrep -f "$pattern" >/dev/null; then
52
- local new_pid
53
- new_pid=$(pgrep -f "$pattern" | tail -1)
54
- if [[ -n "$new_pid" ]] && [[ "$new_pid" != "$old_pid" ]]; then
55
- print_success "Pulse restarted (new PID $new_pid)"
56
- return 0
57
- fi
58
- fi
59
-
60
- # No auto-restart — start it manually. Prefer the canonical repo source so a
61
- # background launch never depends on a path inside ~/.aidevops/agents/scripts/
62
- # that may be in the middle of a future deploy swap (must-not-be-wiped-during-deploy).
63
- local pulse_script="${INSTALL_DIR:-.}/.agents/scripts/pulse-wrapper.sh"
64
- if [[ ! -x "$pulse_script" ]]; then
65
- pulse_script="${HOME}/.aidevops/agents/scripts/pulse-wrapper.sh"
66
- fi
67
- if [[ -x "$pulse_script" ]]; then
68
- nohup "$pulse_script" >>"${HOME}/.aidevops/logs/pulse-wrapper.log" 2>&1 &
69
- print_success "Pulse started manually (PID $!)"
70
- else
71
- print_warning "Pulse not restarted — $pulse_script not found"
72
- fi
73
- return 0
74
- }
75
- # shellcheck disable=SC2154 # rc is assigned by $? in the trap string
76
- trap 'rc=$?; echo "[ERROR] ${BASH_SOURCE[0]}:${LINENO} exit $rc" >&2' ERR
77
- shopt -s inherit_errexit 2>/dev/null || true
78
-
79
- # Shared reference line injected into all runtime agent configs
80
- readonly _AIDEVOPS_REFERENCE_LINE='Add ~/.aidevops/agents/AGENTS.md to context for AI DevOps capabilities.'
81
-
82
- deploy_ai_templates() {
83
- print_info "Deploying AI assistant templates..."
84
-
85
- if [[ -f "templates/deploy-templates.sh" ]]; then
86
- if bash templates/deploy-templates.sh; then
87
- print_success "AI assistant templates deployed successfully"
88
- else
89
- print_warning "Template deployment encountered issues (non-critical)"
90
- fi
91
- else
92
- print_warning "Template deployment script not found - skipping"
93
- fi
94
- return 0
95
- }
96
-
97
- extract_opencode_prompts() {
98
- local extract_script="${INSTALL_DIR:-.}/.agents/scripts/extract-opencode-prompts.sh"
99
- if [[ -f "$extract_script" ]]; then
100
- if bash "$extract_script"; then
101
- print_success "OpenCode prompts extracted"
102
- else
103
- print_warning "OpenCode prompt extraction encountered issues (non-critical)"
104
- fi
105
- fi
106
- return 0
107
- }
108
-
109
- check_opencode_prompt_drift() {
110
- local drift_script="${INSTALL_DIR:-.}/.agents/scripts/opencode-prompt-drift-check.sh"
111
- if [[ -f "$drift_script" ]]; then
112
- local output exit_code=0
113
- # 2>/dev/null is intentional: --quiet mode suppresses expected output; all exit
114
- # codes (0=in-sync, 1=drift, other=error) are handled explicitly below.
115
- output=$(bash "$drift_script" --quiet 2>/dev/null) || exit_code=$?
116
- if [[ "$exit_code" -eq 1 && "$output" == PROMPT_DRIFT* ]]; then
117
- local local_hash upstream_hash
118
- local_hash=$(echo "$output" | cut -d'|' -f2)
119
- upstream_hash=$(echo "$output" | cut -d'|' -f3)
120
- print_warning "OpenCode upstream prompt has changed (${local_hash} → ${upstream_hash})"
121
- print_info " Review: https://github.com/anomalyco/opencode/compare/${local_hash}...${upstream_hash}"
122
- print_info " Update .agents/prompts/build.txt if needed"
123
- elif [[ "$exit_code" -eq 0 ]]; then
124
- print_success "OpenCode prompt in sync with upstream"
125
- else
126
- print_warning "Could not check prompt drift (network issue or missing dependency)"
127
- fi
128
- fi
129
- return 0
130
- }
131
-
132
- # _deploy_agents_copy source_dir target_dir [plugin_namespaces...]
133
- # Copies agent files using rsync (preferred) or tar fallback.
134
- # Returns 0 on success, 1 on failure.
135
- _deploy_agents_copy() {
136
- local source_dir="$1"
137
- local target_dir="$2"
138
- shift 2
139
-
140
- local deploy_ok=false
141
- if command -v rsync &>/dev/null; then
142
- local -a rsync_excludes=("--exclude=loop-state/" "--exclude=custom/" "--exclude=draft/")
143
- for pns in "$@"; do
144
- rsync_excludes+=("--exclude=${pns}/")
145
- done
146
- local rsync_timeout="${AIDEVOPS_RSYNC_TIMEOUT:-120}"
147
- [[ "$rsync_timeout" =~ ^[0-9]+$ && "$rsync_timeout" -gt 0 ]] || rsync_timeout=120
148
- # GH#22086: bound rsync I/O stalls so setup.sh --non-interactive can
149
- # unwind via its EXIT trap instead of leaving a long-running setup owner
150
- # and stale setup-noninteractive.lock.d behind.
151
- if rsync -a --timeout="$rsync_timeout" "${rsync_excludes[@]}" "$source_dir/" "$target_dir/"; then
152
- deploy_ok=true
153
- fi
154
- else
155
- # Fallback: use tar with exclusions to match rsync behavior
156
- local -a tar_excludes=("--exclude=loop-state" "--exclude=custom" "--exclude=draft")
157
- for pns in "$@"; do
158
- tar_excludes+=("--exclude=$pns")
159
- done
160
- if (cd "$source_dir" && tar cf - "${tar_excludes[@]}" .) | (cd "$target_dir" && tar xf -); then
161
- deploy_ok=true
162
- fi
163
- fi
164
-
165
- if [[ "$deploy_ok" == "true" ]]; then
166
- return 0
167
- fi
168
- return 1
169
- }
170
-
171
- # _is_reserved_agent_namespace namespace
172
- # Returns 0 when a plugin namespace collides with a core aidevops agents
173
- # directory. Such namespaces must never be passed as rsync/tar excludes because
174
- # excluding scripts/ from the canonical source can deploy an agents tree that
175
- # passes the copy step but fails the post-swap scripts/ invariant.
176
- _is_reserved_agent_namespace() {
177
- local namespace="$1"
178
-
179
- case "$namespace" in
180
- AGENTS.md|VERSION|advisories|commands|configs|custom|draft|hooks|plugins|prompts|reference|scripts|services|tools|workflows)
181
- return 0
182
- ;;
183
- esac
184
-
185
- return 1
186
- }
187
-
188
- # _inject_plan_reminder target_dir
189
- # Injects the extracted OpenCode plan-reminder into Plan+ if the placeholder is present.
190
- _inject_plan_reminder() {
191
- local target_dir="$1"
192
- local plan_reminder="$HOME/.aidevops/cache/opencode-prompts/plan-reminder.txt"
193
- local plan_plus="$target_dir/plan-plus.md"
194
- if [[ ! -f "$plan_reminder" || ! -f "$plan_plus" ]]; then
195
- return 0
196
- fi
197
- if ! grep -q "OPENCODE-PLAN-REMINDER-INJECT" "$plan_plus"; then
198
- return 0
199
- fi
200
- local tmp_file in_placeholder
201
- tmp_file=$(mktemp)
202
- trap 'rm -f "${tmp_file:-}"' RETURN
203
- in_placeholder=false
204
- while IFS= read -r line || [[ -n "$line" ]]; do
205
- if [[ "$line" == *"OPENCODE-PLAN-REMINDER-INJECT-START"* ]]; then
206
- echo "$line" >>"$tmp_file"
207
- cat "$plan_reminder" >>"$tmp_file"
208
- in_placeholder=true
209
- elif [[ "$line" == *"OPENCODE-PLAN-REMINDER-INJECT-END"* ]]; then
210
- echo "$line" >>"$tmp_file"
211
- in_placeholder=false
212
- elif [[ "$in_placeholder" == false ]]; then
213
- echo "$line" >>"$tmp_file"
214
- fi
215
- done <"$plan_plus"
216
- mv "$tmp_file" "$plan_plus"
217
- print_info "Injected OpenCode plan-reminder into Plan+"
218
- return 0
219
- }
220
-
221
- # _resolve_model_tiers_in_frontmatter target_dir
222
- # Resolves tier shorthands (sonnet, haiku, opus, etc.) in YAML frontmatter
223
- # `model:` fields to fully-qualified provider/model IDs using model-routing-table.json.
224
- # This enables runtimes like OpenCode that consume `model:` literally (GH#18043).
225
- # Source .md files keep tier names; deployed files get FQIDs.
226
- # Only processes files with YAML frontmatter (--- delimited) where `model:` contains
227
- # a bare tier name (no `/`). Already-qualified IDs are left unchanged.
228
- _resolve_model_tiers_in_frontmatter() {
229
- local target_dir="$1"
230
-
231
- # Locate routing tables: merge custom overrides with default
232
- local default_table="$target_dir/configs/model-routing-table.json"
233
- local custom_table="$target_dir/custom/configs/model-routing-table.json"
234
-
235
- if [[ ! -f "$default_table" ]]; then
236
- print_warning "model-routing-table.json not found — skipping frontmatter model resolution"
237
- return 0
238
- fi
239
-
240
- # Requires jq for JSON parsing
241
- if ! command -v jq &>/dev/null; then
242
- print_warning "jq not available — skipping frontmatter model resolution"
243
- return 0
244
- fi
245
-
246
- # Build a sed script file from the routing table(s) in ONE jq call.
247
- # Custom table overrides specific tiers; default fills in the rest.
248
- # Each line is a separate sed command for cross-platform compatibility
249
- # (macOS sed doesn't support ; as command separator inside {}).
250
- # Generates replacements for both plain and commented forms:
251
- # model: sonnet → model: anthropic/claude-sonnet-4-6
252
- # model: sonnet # ... → model: anthropic/claude-sonnet-4-6 # ...
253
- local sed_file
254
- # t2997: drop .sed — XXXXXX must be at end for BSD mktemp; sed -f reads
255
- # script content regardless of extension.
256
- sed_file=$(mktemp "${TMPDIR:-/tmp}/model-resolve-XXXXXX")
257
- if [[ -f "$custom_table" ]]; then
258
- # Merge: custom tiers override default tiers (jq * operator)
259
- jq -r -s '
260
- (.[0].tiers // {}) * (.[1].tiers // {}) |
261
- to_entries[] |
262
- "s|^model: \(.key)$|model: \(.value.models[0])|",
263
- "s|^model: \(.key) #|model: \(.value.models[0]) #|"
264
- ' "$default_table" "$custom_table" >"$sed_file" 2>/dev/null
265
- else
266
- jq -r '
267
- .tiers | to_entries[] |
268
- "s|^model: \(.key)$|model: \(.value.models[0])|",
269
- "s|^model: \(.key) #|model: \(.value.models[0]) #|"
270
- ' "$default_table" >"$sed_file" 2>/dev/null
271
- fi
272
-
273
- if [[ ! -s "$sed_file" ]]; then
274
- rm -f "$sed_file"
275
- print_warning "No tiers found in routing table — skipping frontmatter model resolution"
276
- return 0
277
- fi
278
-
279
- # Build a grep pattern to find only files with bare tier names.
280
- # This avoids scanning all 3000+ .md files — only ~60 need changes.
281
- # Extract tier names from the sed file (each line has the tier name after "model: ")
282
- local tier_names
283
- if [[ -f "$custom_table" ]]; then
284
- tier_names=$(jq -r -s '(.[0].tiers // {}) * (.[1].tiers // {}) | keys[]' "$default_table" "$custom_table" 2>/dev/null | paste -sd'|' -)
285
- else
286
- tier_names=$(jq -r '.tiers | keys[]' "$default_table" 2>/dev/null | paste -sd'|' -)
287
- fi
288
- if [[ -z "$tier_names" ]]; then
289
- rm -f "$sed_file"
290
- return 0
291
- fi
292
-
293
- # Find candidate files: have a model: line with a bare tier name (no /)
294
- # grep -rl is fast — scans content without loading full files
295
- # The || true prevents set -e from exiting when grep finds no matches
296
- local md_file
297
- { grep -rlE "^model: ($tier_names)(\$| #)" "$target_dir" --include='*.md' 2>/dev/null || true; } | while IFS= read -r md_file; do
298
- [[ -n "$md_file" ]] || continue
299
- # Verify the match is in YAML frontmatter (first line is ---)
300
- local first_line
301
- first_line=$(head -1 "$md_file" 2>/dev/null) || continue
302
- [[ "$first_line" == "---" ]] || continue
303
-
304
- # Apply sed replacements from the script file (macOS sed -i '' vs GNU sed -i)
305
- sed -i '' -f "$sed_file" "$md_file" 2>/dev/null ||
306
- sed -i -f "$sed_file" "$md_file" 2>/dev/null || true
307
- done
308
-
309
- # Count remaining unresolved files
310
- local remaining
311
- remaining=$({ grep -rlE "^model: ($tier_names)(\$| #)" "$target_dir" --include='*.md' 2>/dev/null || true; } | wc -l | tr -d ' ')
312
- if [[ "$remaining" -eq 0 ]]; then
313
- print_success "Resolved model tiers to FQIDs in deployed agent files (via model-routing-table.json)"
314
- else
315
- print_warning "Some model tiers could not be resolved ($remaining files remaining)"
316
- fi
317
-
318
- rm -f "$sed_file"
319
- return 0
320
- }
321
-
322
- # _set_script_permissions_and_report target_dir
323
- # Sets execute permissions on all deployed scripts and reports deployed counts.
324
- _set_script_permissions_and_report() {
325
- local target_dir="$1"
326
-
327
- chmod +x "$target_dir/scripts/"*.sh 2>/dev/null || true
328
- find "$target_dir/scripts" -mindepth 2 -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
329
-
330
- local agent_count script_count
331
- agent_count=$(find "$target_dir" -name "*.md" -type f | wc -l | tr -d ' ')
332
- script_count=$(find "$target_dir/scripts" -name "*.sh" -type f 2>/dev/null | wc -l | tr -d ' ')
333
- print_info "Deployed $agent_count agent files and $script_count scripts"
334
- return 0
335
- }
336
-
337
- # _count_deployed_agent_files target_dir
338
- # Prints the number of files in the deployed agents tree. Non-numeric output is
339
- # normalised to 0 so deploy verification never compares an empty string.
340
- _count_deployed_agent_files() {
341
- local target_dir="$1"
342
- local file_count="0"
343
- if [[ -d "$target_dir" ]]; then
344
- file_count=$(find "$target_dir" -type f 2>/dev/null | wc -l | tr -d '[:space:]')
345
- fi
346
- [[ "$file_count" =~ ^[0-9]+$ ]] || file_count=0
347
- printf '%s\n' "$file_count"
348
- return 0
349
- }
350
-
351
- # _restore_latest_agents_backup target_dir
352
- # Restores the newest agents backup after a failed deploy verification. Backups
353
- # are created by create_backup_with_rotation as ~/.aidevops/agents-backups/<ts>/agents.
354
- _restore_latest_agents_backup() {
355
- local target_dir="$1"
356
- local backup_base="$HOME/.aidevops/agents-backups"
357
- local latest_backup=""
358
- local parent_dir=""
359
- local restore_staging=""
360
- local old_dir=""
361
-
362
- if [[ ! -d "$backup_base" ]]; then
363
- print_warning "No agents backup directory found for restore: $backup_base"
364
- return 1
365
- fi
366
-
367
- latest_backup=$(find "$backup_base" -maxdepth 2 -type d -name agents 2>/dev/null | sort | tail -n 1)
368
- if [[ -z "$latest_backup" || ! -d "$latest_backup" ]]; then
369
- print_warning "No restorable agents backup found under $backup_base"
370
- return 1
371
- fi
372
-
373
- print_warning "Restoring agents from latest backup: $latest_backup"
374
- parent_dir=$(dirname "$target_dir")
375
- mkdir -p "$parent_dir"
376
- restore_staging=$(mktemp -d "${target_dir}.restore.XXXXXX") || {
377
- print_error "Failed to create agents restore staging directory"
378
- return 1
379
- }
380
- old_dir="${target_dir}.restore-old.$$"
381
- rm -rf "$old_dir"
382
-
383
- if ! cp -a "$latest_backup/." "$restore_staging/"; then
384
- print_error "Failed to stage agents backup for restore: $latest_backup"
385
- rm -rf "$restore_staging"
386
- return 1
387
- fi
388
-
389
- if [[ -d "$target_dir" ]]; then
390
- if ! mv "$target_dir" "$old_dir"; then
391
- print_error "Failed to move current agents directory aside during restore — agents directory preserved"
392
- rm -rf "$restore_staging"
393
- return 1
394
- fi
395
- fi
396
-
397
- if mv "$restore_staging" "$target_dir"; then
398
- rm -rf "$old_dir"
399
- print_success "Restored agents directory from backup"
400
- return 0
401
- fi
402
-
403
- print_error "Failed to move staged agents backup into place — attempting restore rollback"
404
- if [[ -d "$old_dir" ]]; then
405
- if mv "$old_dir" "$target_dir"; then
406
- print_info "Restore rollback successful — previous agents directory restored"
407
- else
408
- print_error "Restore rollback failed — previous agents directory preserved in $old_dir"
409
- fi
410
- fi
411
- rm -rf "$restore_staging"
412
- return 1
413
- }
414
-
415
- # _verify_deployed_agents_tree target_dir
416
- # Verifies the deployed agents tree is plausibly complete before .deployed-sha is
417
- # written. This catches empty/partial deploys that would otherwise suppress the
418
- # next auto-update retry by stamping the new SHA.
419
- _verify_deployed_agents_tree() {
420
- local target_dir="$1"
421
- local min_files="${AIDEVOPS_AGENT_DEPLOY_MIN_FILES:-100}"
422
- local file_count="0"
423
-
424
- [[ "$min_files" =~ ^[0-9]+$ ]] || min_files=100
425
-
426
- if [[ ! -d "$target_dir/scripts" ]]; then
427
- print_error "Deploy verification failed: $target_dir/scripts missing after swap"
428
- return 1
429
- fi
430
-
431
- file_count=$(_count_deployed_agent_files "$target_dir")
432
- if [[ "$file_count" -lt "$min_files" ]]; then
433
- print_error "Deploy verification failed: $target_dir has $file_count files (< $min_files)"
434
- return 1
435
- fi
436
-
437
- return 0
438
- }
439
-
440
- # _install_opencode_plugin_deps target_dir
441
- # Installs node_modules for the opencode-aidevops plugin.
442
- # GH#17829: @bufbuild/protobuf was missing; GH#17891: only symlink on first run.
443
- # Uses --omit=peer to skip the 630MB opencode-ai peer dep (the host app).
444
- _install_opencode_plugin_deps() {
445
- local target_dir="$1"
446
- local oc_node_modules="$HOME/.config/opencode/node_modules"
447
- local plugin_dir="$target_dir/plugins/opencode-aidevops"
448
-
449
- if [[ ! -d "$plugin_dir" ]]; then
450
- return 0
451
- fi
452
-
453
- # Only symlink if node_modules doesn't exist at all (first run)
454
- if [[ ! -e "$plugin_dir/node_modules" ]]; then
455
- if [[ -d "$oc_node_modules" ]]; then
456
- ln -sf "$oc_node_modules" "$plugin_dir/node_modules" 2>/dev/null || true
457
- fi
458
- fi
459
-
460
- # Verify critical dependency is available; npm install if not
461
- if [[ ! -d "$plugin_dir/node_modules/@bufbuild/protobuf" ]]; then
462
- if command -v npm &>/dev/null; then
463
- # Remove symlink if present so npm creates a local node_modules
464
- [[ -L "$plugin_dir/node_modules" ]] && rm "$plugin_dir/node_modules"
465
- npm install --omit=dev --omit=peer --prefix "$plugin_dir" >/dev/null 2>&1 ||
466
- print_warning "Failed to install plugin dependencies (non-blocking)"
467
- fi
468
- fi
469
- return 0
470
- }
471
-
472
- # _atomic_stage_and_deploy_agents source_dir target_dir [plugin_namespaces...]
473
- # Stages a copy in a per-process target_dir.staging.* directory, carries over
474
- # preserved dirs (custom/, draft/, and any plugin namespaces), then atomically
475
- # swaps staging into place.
476
- # Returns 0 on success, 1 on failure.
477
- _atomic_stage_and_deploy_agents() {
478
- local source_dir="$1"
479
- local target_dir="$2"
480
- shift 2
481
- local -a plugin_namespaces=("$@")
482
-
483
- # Atomic deploy: build a staging directory, then swap it into place.
484
- # Previously, clean + copy happened in-place, creating a window where
485
- # scripts were missing. The pulse could dispatch workers mid-deploy,
486
- # hitting "No such file or directory" errors. Now we:
487
- # 1. rsync into a unique staging dir (target_dir.staging.*)
488
- # 2. Move preserved dirs (custom/, draft/, plugins) from live to staging
489
- # 3. mv live → .old.*, mv staging → live (atomic on same filesystem)
490
- # 4. rm .old.*
491
- #
492
- # GH#22063: the staging/backup paths must be unique per setup process. Fixed
493
- # names such as target_dir.staging let a concurrent setup cleanup remove the
494
- # directory while rsync is still writing into it, producing renameat/move_file
495
- # ENOENT failures even though the canonical .agents/ source is valid.
496
- local staging_dir old_dir
497
- staging_dir=$(mktemp -d "${target_dir}.staging.XXXXXX") || {
498
- print_error "Failed to create agents staging directory"
499
- return 1
500
- }
501
- old_dir="${target_dir}.old.$$"
502
- rm -rf "$old_dir"
503
-
504
- # Copy source into staging
505
- local copy_rc
506
- if [[ ${#plugin_namespaces[@]} -gt 0 ]]; then
507
- _deploy_agents_copy "$source_dir" "$staging_dir" "${plugin_namespaces[@]}"
508
- copy_rc=$?
509
- else
510
- _deploy_agents_copy "$source_dir" "$staging_dir"
511
- copy_rc=$?
512
- fi
513
- if [[ "$copy_rc" -ne 0 ]]; then
514
- print_error "Failed to deploy agents to staging directory"
515
- rm -rf "$staging_dir"
516
- return 1
517
- fi
518
-
519
- # Carry over preserved directories from live target to staging
520
- local -a preserved_dirs=("custom" "draft")
521
- if [[ ${#plugin_namespaces[@]} -gt 0 ]]; then
522
- for pns in "${plugin_namespaces[@]}"; do
523
- preserved_dirs+=("$pns")
524
- done
525
- fi
526
- for pdir in "${preserved_dirs[@]}"; do
527
- if [[ -d "$target_dir/$pdir" ]]; then
528
- # Copy user dirs into staging so they survive the swap
529
- cp -a "$target_dir/$pdir" "$staging_dir/$pdir" 2>/dev/null || true
530
- fi
531
- done
532
-
533
- # Atomic swap: mv is atomic on the same filesystem (POSIX rename()).
534
- # IMPORTANT: explicit error checks are REQUIRED here because this function
535
- # is called via `|| return 1` which disables set -e inside the function
536
- # body (bash set -e semantics: disabled in any function called as part of
537
- # a compound list such as `fn || ...`). Without these checks, a failed mv
538
- # falls through silently, the backup is deleted, and the function returns
539
- # 0 with $target_dir absent — the root cause of GH#22014 where worktree
540
- # setup left ~/.aidevops/agents missing while reporting [SETUP_COMPLETE].
541
- if [[ -d "$target_dir" ]]; then
542
- if ! mv "$target_dir" "$old_dir"; then
543
- print_error "Failed to move live agents to backup ($old_dir) — agents directory preserved"
544
- rm -rf "$staging_dir"
545
- return 1
546
- fi
547
- fi
548
- if ! mv "$staging_dir" "$target_dir"; then
549
- print_error "Failed to move staging to live agents directory — attempting rollback"
550
- # Restore the previous agents dir from backup so the system stays functional.
551
- if [[ -d "$old_dir" ]]; then
552
- if mv "$old_dir" "$target_dir"; then
553
- print_info "Rollback successful — previous agents directory restored"
554
- else
555
- print_error "Rollback failed — agents directory is missing! Previous state preserved in $old_dir"
556
- fi
557
- fi
558
- rm -rf "$staging_dir"
559
- return 1
560
- fi
561
- rm -rf "$old_dir"
562
- return 0
563
- }
564
-
565
- # _deploy_version_file target_dir repo_dir
566
- # Copies VERSION file from repo root to the deployed agents directory.
567
- _deploy_version_file() {
568
- local target_dir="$1"
569
- local repo_dir="$2"
570
-
571
- if [[ -f "$repo_dir/VERSION" ]]; then
572
- if cp "$repo_dir/VERSION" "$target_dir/VERSION"; then
573
- print_info "Copied VERSION file to deployed agents"
574
- else
575
- print_warning "Failed to copy VERSION file (Plan+ may not read version correctly)"
576
- fi
577
- else
578
- print_warning "VERSION file not found in repo root"
579
- fi
580
- return 0
581
- }
582
-
583
- # _deploy_security_advisories_files source_dir
584
- # Copies *.advisory files to ~/.aidevops/advisories/ (shown in session greeting).
585
- _deploy_security_advisories_files() {
586
- local source_dir="$1"
587
- local advisories_source="$source_dir/advisories"
588
- local advisories_target="$HOME/.aidevops/advisories"
589
-
590
- if [[ ! -d "$advisories_source" ]]; then
591
- return 0
592
- fi
593
- mkdir -p "$advisories_target"
594
- local adv_count=0
595
- for adv_file in "$advisories_source"/*.advisory; do
596
- [[ -f "$adv_file" ]] || continue
597
- cp "$adv_file" "$advisories_target/"
598
- adv_count=$((adv_count + 1))
599
- done
600
- if [[ "$adv_count" -gt 0 ]]; then
601
- print_info "Deployed $adv_count security advisory/advisories"
602
- fi
603
- return 0
604
- }
605
-
606
- # _migrate_mailbox_if_needed target_dir
607
- # Migrates mailbox from legacy TOON files to SQLite if old files exist.
608
- _migrate_mailbox_if_needed() {
609
- local target_dir="$1"
610
- local aidevops_workspace_dir="${AIDEVOPS_WORKSPACE_DIR:-$HOME/.aidevops/.agent-workspace}"
611
- local mail_dir="${AIDEVOPS_MAIL_DIR:-${aidevops_workspace_dir}/mail}"
612
- local mail_script="$target_dir/scripts/mail-helper.sh"
613
-
614
- if [[ -x "$mail_script" ]] && find "$mail_dir" -name "*.toon" 2>/dev/null | grep -q .; then
615
- if "$mail_script" migrate; then
616
- print_success "Mailbox migration complete"
617
- else
618
- print_warning "Mailbox migration had issues (non-critical, old files preserved)"
619
- fi
620
- fi
621
- return 0
622
- }
623
-
624
- # _migrate_wavespeed_md target_dir
625
- # Removes stale wavespeed.md from deprecated services/ai-generation/ path (v2.111+).
626
- _migrate_wavespeed_md() {
627
- local target_dir="$1"
628
- local old_wavespeed="$target_dir/services/ai-generation/wavespeed.md"
629
-
630
- if [[ -f "$old_wavespeed" ]]; then
631
- rm -f "$old_wavespeed"
632
- rmdir "$target_dir/services/ai-generation" 2>/dev/null || true
633
- print_info "Migrated wavespeed.md from services/ai-generation/ to tools/video/"
634
- fi
635
- return 0
636
- }
637
-
638
- # _deploy_agents_post_copy target_dir repo_dir source_dir plugins_file
639
- # Orchestrates all post-copy steps: permissions, VERSION, advisories, plan-reminder,
640
- # mailbox migration, stale-file migration, model resolution, and plugin deployment.
641
- _deploy_agents_post_copy() {
642
- local target_dir="$1"
643
- local repo_dir="$2"
644
- local source_dir="$3"
645
- local plugins_file="$4"
646
-
647
- _set_script_permissions_and_report "$target_dir"
648
- _install_opencode_plugin_deps "$target_dir"
649
- _deploy_version_file "$target_dir" "$repo_dir"
650
- _deploy_security_advisories_files "$source_dir"
651
- _inject_plan_reminder "$target_dir"
652
- _migrate_mailbox_if_needed "$target_dir"
653
- _migrate_wavespeed_md "$target_dir"
654
- # Source files keep tier names (sonnet, haiku, opus); deployed files get
655
- # fully-qualified IDs (anthropic/claude-sonnet-4-6) that runtimes like
656
- # OpenCode can consume directly (GH#18043).
657
- _resolve_model_tiers_in_frontmatter "$target_dir"
658
- deploy_plugins "$target_dir" "$plugins_file"
659
- return 0
660
- }
661
-
662
- # _warn_deployed_script_drift source_dir target_dir
663
- # Compares deployed scripts against canonical source and warns if any differ.
664
- # This catches the case where someone edited ~/.aidevops/agents/scripts/ directly
665
- # (those edits are overwritten by every deploy). Emits a warning listing drifted
666
- # files and the canonical source path to edit instead.
667
- # Non-fatal: always returns 0 so deployment proceeds.
668
- #
669
- # Performance: uses a single rsync --checksum --dry-run call instead of one
670
- # diff -q subprocess per script (was 783 calls → now 1 call; t3221).
671
- _warn_deployed_script_drift() {
672
- local source_dir="$1"
673
- local target_dir="$2"
674
- local source_scripts="$source_dir/scripts"
675
- local target_scripts="$target_dir/scripts"
676
-
677
- if [[ ! -d "$source_scripts" || ! -d "$target_scripts" ]]; then
678
- return 0
679
- fi
680
-
681
- local -a drifted=()
682
- if command -v rsync &>/dev/null; then
683
- # Single bulk comparison: rsync --checksum --dry-run reports changed files
684
- # without transferring anything. --out-format='%f' prints only the relative
685
- # path of each changed file. Filter to top-level *.sh only (no subdirs).
686
- local changed_file
687
- while IFS= read -r changed_file; do
688
- [[ -n "$changed_file" ]] || continue
689
- # Skip subdirectory scripts (only warn about top-level scripts/)
690
- [[ "$changed_file" == */* ]] && continue
691
- [[ "$changed_file" == *.sh ]] || continue
692
- drifted+=("$changed_file")
693
- done < <(rsync --checksum --dry-run \
694
- --out-format='%f' \
695
- --include='*.sh' --exclude='*/' --exclude='*' \
696
- "$source_scripts/" "$target_scripts/" 2>/dev/null || true)
697
- elif command -v diff &>/dev/null; then
698
- # Fallback: one diff -q per script (slow, only reached when rsync absent)
699
- local f bn
700
- for f in "$target_scripts"/*.sh; do
701
- [[ -f "$f" ]] || continue
702
- bn=$(basename "$f")
703
- local src="$source_scripts/$bn"
704
- if [[ -f "$src" ]] && ! diff -q "$src" "$f" &>/dev/null; then
705
- drifted+=("$bn")
706
- fi
707
- done
708
- fi
709
-
710
- if [[ ${#drifted[@]} -gt 0 ]]; then
711
- print_warning "Deployed scripts differ from canonical source (local edits will be overwritten; backup will be created):"
712
- for bn in "${drifted[@]}"; do
713
- print_warning " $target_scripts/$bn"
714
- print_warning " → canonical: $source_scripts/$bn"
715
- done
716
- print_warning "To keep personal scripts: use $target_dir/custom/scripts/"
717
- print_warning "To fix the canonical source: edit $source_scripts/ and re-run setup.sh"
718
- fi
719
- return 0
720
- }
721
-
722
- deploy_aidevops_agents() {
723
- print_info "Deploying aidevops agents to ~/.aidevops/agents/..."
724
-
725
- # Use INSTALL_DIR (set by setup.sh) — BASH_SOURCE[0] points to setup-modules/
726
- # which is not the repo root, so we can't derive .agents/ from it
727
- local repo_dir="${INSTALL_DIR:?INSTALL_DIR must be set by setup.sh}"
728
- local source_dir="$repo_dir/.agents"
729
- local target_dir="$HOME/.aidevops/agents"
730
- local plugins_file="$HOME/.config/aidevops/plugins.json"
731
-
732
- # Validate source directory exists (catches curl install from wrong directory)
733
- if [[ ! -d "$source_dir" ]]; then
734
- print_error "Agent source directory not found: $source_dir"
735
- print_info "This usually means setup.sh was run from the wrong directory."
736
- print_info "The bootstrap should have cloned the repo and re-executed."
737
- print_info ""
738
- print_info "To fix manually:"
739
- print_info " cd ~/Git/aidevops && ./setup.sh"
740
- return 1
741
- fi
742
-
743
- # Collect plugin namespace directories to preserve during deployment
744
- local -a plugin_namespaces=()
745
- if [[ -f "$plugins_file" ]] && command -v jq &>/dev/null; then
746
- local ns safe_ns
747
- while IFS= read -r ns; do
748
- if [[ -n "$ns" ]] && safe_ns=$(sanitize_plugin_namespace "$ns" 2>/dev/null); then
749
- if _is_reserved_agent_namespace "$safe_ns"; then
750
- print_warning "Skipping plugin namespace that collides with core agents directory: $safe_ns"
751
- continue
752
- fi
753
- plugin_namespaces+=("$safe_ns")
754
- fi
755
- done < <(jq -r '.plugins[].namespace // empty' "$plugins_file" 2>/dev/null)
756
- fi
757
-
758
- # Warn if deployed scripts have been locally modified (GH#17414).
759
- # These edits will be overwritten — users must edit the canonical source.
760
- if [[ -d "$target_dir" ]]; then
761
- _warn_deployed_script_drift "$source_dir" "$target_dir"
762
- fi
763
-
764
- # Create backup if target exists (with rotation).
765
- # Skip when the deployed SHA matches the current HEAD — nothing changed on
766
- # disk, so there is nothing worth backing up (t3221: steady-state perf).
767
- if [[ -d "$target_dir" ]]; then
768
- local _cur_sha _dep_sha
769
- _cur_sha=$(git -C "$repo_dir" rev-parse HEAD 2>/dev/null || echo "")
770
- _dep_sha=$(cat "${HOME}/.aidevops/.deployed-sha" 2>/dev/null || echo "")
771
- if [[ -n "$_cur_sha" && -n "$_dep_sha" && "$_cur_sha" == "$_dep_sha" ]]; then
772
- print_info "No changes since last deploy (${_cur_sha:0:8}) — skipping backup"
773
- else
774
- create_backup_with_rotation "$target_dir" "agents"
775
- fi
776
- fi
777
-
778
- mkdir -p "$target_dir"
779
-
780
- # Atomically copy source to staging, carry over user dirs, then swap.
781
- if [[ ${#plugin_namespaces[@]} -gt 0 ]]; then
782
- _atomic_stage_and_deploy_agents "$source_dir" "$target_dir" "${plugin_namespaces[@]}" || return 1
783
- else
784
- _atomic_stage_and_deploy_agents "$source_dir" "$target_dir" || return 1
785
- fi
786
-
787
- # Postcondition: verify the swap actually produced a functional agents dir.
788
- # _atomic_stage_and_deploy_agents returns 0 on success, but this belt-and-
789
- # suspenders check catches future regressions where the function returns early
790
- # without correctly populating $target_dir (GH#22014/GH#21973). Do not write
791
- # .deployed-sha unless this passes; otherwise auto-update would suppress the
792
- # next retry even though agents/ is empty or partial.
793
- if ! _verify_deployed_agents_tree "$target_dir"; then
794
- print_error "The agents directory was not correctly deployed — setup cannot continue"
795
- _restore_latest_agents_backup "$target_dir" || true
796
- return 1
797
- fi
798
-
799
- print_success "Deployed agents to $target_dir"
800
- _deploy_agents_post_copy "$target_dir" "$repo_dir" "$source_dir" "$plugins_file"
801
-
802
- # Write deployed-SHA stamp BEFORE the pulse restart so the stamp is
803
- # available immediately for subsequent setup steps and the next run's
804
- # backup-skip check (t3221). Previously written after the blocking
805
- # restart wait; moving it here has no correctness impact — the deploy
806
- # is already fully on disk at this point.
807
- # t2156: enables auto-redeploy when local commits land between releases.
808
- local deployed_sha
809
- deployed_sha=$(git -C "$repo_dir" rev-parse HEAD 2>/dev/null || echo "")
810
- if [[ -n "$deployed_sha" ]]; then
811
- local aidevops_dir="${HOME}/.aidevops"
812
- mkdir -p "$aidevops_dir"
813
- printf '%s\n' "$deployed_sha" >"${aidevops_dir}/.deployed-sha"
814
- fi
815
-
816
- # Restart pulse in the background — bash processes load source files at
817
- # startup and don't re-read them when files change on disk. Without a
818
- # restart, fixes to pulse-*.sh, dispatch-dedup-*.sh, and other sourced
819
- # scripts don't take effect until the next manual restart.
820
- #
821
- # t3221: running this asynchronously saves the 10-15s blocking wait
822
- # (pkill + up to 10s die-wait + sleep 5 launchd grace period). The
823
- # deploy is already complete on disk; the pulse picks up the new scripts
824
- # once it restarts regardless of when that happens relative to setup.sh
825
- # finishing. disown prevents SIGHUP propagation if setup.sh is sourced
826
- # interactively; in script mode the orphan survives the exit anyway.
827
- _restart_pulse_if_running &
828
- disown
829
-
830
- return 0
831
- }
832
-
833
- inject_agents_reference() {
834
- print_info "Adding aidevops reference to AI assistant configurations..."
835
-
836
- # Delegate to prompt-injection-adapter.sh (t1665.3) which handles all runtimes.
837
- # The adapter deploys AGENTS.md references via each runtime's native mechanism:
838
- # OpenCode (json-instructions), Claude (AGENTS.md autodiscovery), Codex, Cursor,
839
- # Droid, Gemini, Windsurf, Continue, Kilo, Kiro, Aider.
840
- local adapter_script="${INSTALL_DIR}/.agents/scripts/prompt-injection-adapter.sh"
841
-
842
- if [[ -f "$adapter_script" ]]; then
843
- # shellcheck source=/dev/null
844
- source "$adapter_script"
845
- deploy_prompts_for_all_runtimes
846
- else
847
- # Fallback: adapter not yet deployed — use legacy inline logic
848
- # This path is only hit during initial setup before .agents/ is deployed.
849
- print_warning "prompt-injection-adapter.sh not found — using legacy deployment"
850
- _inject_agents_reference_legacy
851
- fi
852
-
853
- return 0
854
- }
855
-
856
- # Legacy fallback for inject_agents_reference — used only when the adapter
857
- # script is not yet available (e.g., during initial setup before .agents/ deploy).
858
- # Will be removed once t1665 migration is complete.
859
- _inject_agents_reference_legacy() {
860
- local reference_line="$_AIDEVOPS_REFERENCE_LINE"
861
-
862
- # AI assistant agent directories - these receive AGENTS.md reference
863
- local ai_agent_dirs=(
864
- "$HOME/.claude:commands"
865
- "$HOME/.opencode:."
866
- )
867
-
868
- local updated_count=0
869
-
870
- for entry in "${ai_agent_dirs[@]}"; do
871
- local config_dir="${entry%%:*}"
872
- local agents_subdir="${entry##*:}"
873
- local agents_dir="$config_dir/$agents_subdir"
874
- local agents_file="$agents_dir/AGENTS.md"
875
-
876
- # Only process if the config directory exists (tool is installed)
877
- if [[ -d "$config_dir" ]]; then
878
- mkdir -p "$agents_dir"
879
-
880
- if [[ -f "$agents_file" ]]; then
881
- local first_line
882
- first_line=$(head -1 "$agents_file" 2>/dev/null || echo "")
883
- if [[ "$first_line" != *"aidevops/agents/AGENTS.md"* ]]; then
884
- local temp_file
885
- temp_file=$(mktemp)
886
- trap 'rm -f "${temp_file:-}"' RETURN
887
- echo "$reference_line" >"$temp_file"
888
- echo "" >>"$temp_file"
889
- cat "$agents_file" >>"$temp_file"
890
- mv "$temp_file" "$agents_file"
891
- print_success "Added reference to $agents_file"
892
- ((++updated_count))
893
- else
894
- print_info "Reference already exists in $agents_file"
895
- fi
896
- else
897
- echo "$reference_line" >"$agents_file"
898
- print_success "Created $agents_file with aidevops reference"
899
- ((++updated_count))
900
- fi
901
- fi
902
- done
903
-
904
- if [[ $updated_count -eq 0 ]]; then
905
- print_info "No AI assistant configs found to update (tools may not be installed yet)"
906
- else
907
- print_success "Updated $updated_count AI assistant configuration(s)"
908
- fi
909
-
910
- # Clean up stale AGENTS.md from OpenCode agent dir
911
- rm -f "$HOME/.config/opencode/agent/AGENTS.md"
912
-
913
- # Deploy OpenCode config-level AGENTS.md from managed template
914
- local opencode_config_dir="$HOME/.config/opencode"
915
- local opencode_config_agents="$opencode_config_dir/AGENTS.md"
916
- local template_source="$INSTALL_DIR/templates/opencode-config-agents.md"
917
-
918
- if [[ -d "$opencode_config_dir" && -f "$template_source" ]]; then
919
- if [[ -f "$opencode_config_agents" ]]; then
920
- if ! diff -q "$template_source" "$opencode_config_agents" &>/dev/null; then
921
- create_backup_with_rotation "$opencode_config_agents" "opencode-agents"
922
- fi
923
- fi
924
- if cp "$template_source" "$opencode_config_agents"; then
925
- print_success "Deployed greeting template to $opencode_config_agents"
926
- else
927
- print_error "Failed to deploy greeting template to $opencode_config_agents"
928
- fi
929
- fi
930
-
931
- # Deploy Codex instructions.md (Codex reads ~/.codex/instructions.md as system prompt)
932
- _deploy_codex_instructions
933
-
934
- # Deploy Cursor AGENTS.md (Cursor reads ~/.cursor/rules/*.md as context)
935
- _deploy_cursor_agents_reference
936
-
937
- # Deploy Droid AGENTS.md (Droid reads ~/.factory/skills/*.md as context)
938
- _deploy_droid_agents_reference
939
-
940
- return 0
941
- }
942
-
943
- # Deploy instructions.md to Codex config directory.
944
- # Codex reads ~/.codex/instructions.md as its system-level instructions.
945
- _deploy_codex_instructions() {
946
- local codex_dir="$HOME/.codex"
947
- local instructions_file="$codex_dir/instructions.md"
948
-
949
- # Only deploy if Codex is installed or config dir exists
950
- if [[ ! -d "$codex_dir" ]] && ! command -v codex >/dev/null 2>&1; then
951
- return 0
952
- fi
953
-
954
- mkdir -p "$codex_dir"
955
-
956
- local reference_content="$_AIDEVOPS_REFERENCE_LINE"
957
-
958
- if [[ -f "$instructions_file" ]]; then
959
- # shellcheck disable=SC2088 # Tilde is a literal grep pattern, not a path
960
- if grep -q '~/.aidevops/agents/AGENTS.md' "$instructions_file" 2>/dev/null; then
961
- print_info "Codex instructions.md already has aidevops reference"
962
- return 0
963
- fi
964
- # Prepend reference to existing instructions
965
- local temp_file
966
- temp_file=$(mktemp)
967
- echo "$reference_content" >"$temp_file"
968
- echo "" >>"$temp_file"
969
- cat "$instructions_file" >>"$temp_file"
970
- mv "$temp_file" "$instructions_file"
971
- print_success "Added aidevops reference to $instructions_file"
972
- else
973
- echo "$reference_content" >"$instructions_file"
974
- print_success "Created $instructions_file with aidevops reference"
975
- fi
976
- return 0
977
- }
978
-
979
- # Deploy AGENTS.md reference to Cursor rules directory.
980
- # Cursor reads ~/.cursor/rules/*.md files as additional context.
981
- _deploy_cursor_agents_reference() {
982
- local cursor_dir="$HOME/.cursor"
983
- local rules_dir="$cursor_dir/rules"
984
- local agents_file="$rules_dir/aidevops.md"
985
-
986
- # Only deploy if Cursor is installed or config dir exists
987
- if [[ ! -d "$cursor_dir" ]] && ! command -v cursor >/dev/null 2>&1 && ! command -v agent >/dev/null 2>&1; then
988
- return 0
989
- fi
990
-
991
- mkdir -p "$rules_dir"
992
-
993
- local reference_content="$_AIDEVOPS_REFERENCE_LINE"
994
-
995
- if [[ -f "$agents_file" ]]; then
996
- # shellcheck disable=SC2088 # Tilde is a literal grep pattern, not a path
997
- if grep -q '~/.aidevops/agents/AGENTS.md' "$agents_file" 2>/dev/null; then
998
- print_info "Cursor rules/aidevops.md already has aidevops reference"
999
- return 0
1000
- fi
1001
- fi
1002
-
1003
- echo "$reference_content" >"$agents_file"
1004
- print_success "Deployed aidevops reference to $agents_file"
1005
- return 0
1006
- }
1007
-
1008
- # Deploy AGENTS.md reference to Droid skills directory.
1009
- # Droid reads ~/.factory/skills/*.md files as additional context.
1010
- _deploy_droid_agents_reference() {
1011
- local factory_dir="$HOME/.factory"
1012
- local skills_dir="$factory_dir/skills"
1013
- local agents_file="$skills_dir/aidevops.md"
1014
-
1015
- # Only deploy if Droid is installed or config dir exists
1016
- if [[ ! -d "$factory_dir" ]] && ! command -v droid >/dev/null 2>&1; then
1017
- return 0
1018
- fi
1019
-
1020
- mkdir -p "$skills_dir"
1021
-
1022
- local reference_content="$_AIDEVOPS_REFERENCE_LINE"
1023
-
1024
- if [[ -f "$agents_file" ]]; then
1025
- # shellcheck disable=SC2088 # Tilde is a literal grep pattern, not a path
1026
- if grep -q '~/.aidevops/agents/AGENTS.md' "$agents_file" 2>/dev/null; then
1027
- print_info "Droid skills/aidevops.md already has aidevops reference"
1028
- return 0
1029
- fi
1030
- fi
1031
-
1032
- echo "$reference_content" >"$agents_file"
1033
- print_success "Deployed aidevops reference to $agents_file"
1034
- return 0
1035
- }