aidevops 2.172.17 → 2.172.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,961 @@
1
+ #!/usr/bin/env bash
2
+ # Migration functions: migrate_* and cleanup_* functions
3
+ # Part of aidevops setup.sh modularization (t316.3)
4
+
5
+ # Shell safety baseline
6
+ set -Eeuo pipefail
7
+ IFS=$'\n\t'
8
+ # shellcheck disable=SC2154 # rc is assigned by $? in the trap string
9
+ trap 'rc=$?; echo "[ERROR] ${BASH_SOURCE[0]}:${LINENO} exit $rc" >&2' ERR
10
+ shopt -s inherit_errexit 2>/dev/null || true
11
+
12
+ cleanup_deprecated_paths() {
13
+ local agents_dir="$HOME/.aidevops/agents"
14
+ local cleaned=0
15
+
16
+ # List of deprecated paths (add new ones here when reorganizing)
17
+ local deprecated_paths=(
18
+ # v2.40.7: wordpress moved from root to tools/wordpress
19
+ "$agents_dir/wordpress.md"
20
+ "$agents_dir/wordpress"
21
+ # v2.41.0: build-agent and build-mcp moved from root to tools/
22
+ "$agents_dir/build-agent.md"
23
+ "$agents_dir/build-agent"
24
+ "$agents_dir/build-mcp.md"
25
+ "$agents_dir/build-mcp"
26
+ # v2.93.3: moltbot renamed to openclaw (formerly clawdbot)
27
+ "$agents_dir/tools/ai-assistants/clawdbot.md"
28
+ "$agents_dir/tools/ai-assistants/moltbot.md"
29
+ # Removed non-OpenCode AI tool docs (focus on OpenCode only)
30
+ "$agents_dir/tools/ai-assistants/windsurf.md"
31
+ "$agents_dir/tools/ai-assistants/configuration.md"
32
+ "$agents_dir/tools/ai-assistants/status.md"
33
+ # Removed oh-my-opencode integration (no longer supported)
34
+ "$agents_dir/tools/opencode/oh-my-opencode.md"
35
+ # t199.8: youtube moved from root to content/distribution/youtube/
36
+ "$agents_dir/youtube.md"
37
+ "$agents_dir/youtube"
38
+ # osgrep removed — disproportionate CPU/disk cost vs rg + LLM comprehension
39
+ "$agents_dir/tools/context/osgrep.md"
40
+ )
41
+
42
+ for path in "${deprecated_paths[@]}"; do
43
+ if [[ -e "$path" ]]; then
44
+ rm -rf "$path"
45
+ ((++cleaned))
46
+ fi
47
+ done
48
+
49
+ if [[ $cleaned -gt 0 ]]; then
50
+ print_info "Cleaned up $cleaned deprecated agent path(s)"
51
+ fi
52
+
53
+ # Remove oh-my-opencode remnants (no longer supported) — but respect user preference.
54
+ # Default: preserve user files. Override with --overwrite flag or settings.json.
55
+ # See: ~/.config/aidevops/settings.json { "preserve_oh_my_opencode": true }
56
+ local omo_config="$HOME/.config/opencode/oh-my-opencode.json"
57
+ if [[ -f "$omo_config" ]]; then
58
+ if should_overwrite_user_file "preserve_oh_my_opencode" "oh-my-opencode config ($omo_config)"; then
59
+ rm -f "$omo_config"
60
+ print_info "Removed oh-my-opencode config"
61
+ fi
62
+ fi
63
+
64
+ # Remove osgrep — disproportionate CPU/disk cost (74GB indexes, 4 CPU cores on startup)
65
+ # rg + fd + LLM comprehension covers the same ground at zero resource cost
66
+ cleanup_osgrep
67
+
68
+ # Remove oh-my-opencode from plugin array if present — guarded by same setting
69
+ local opencode_config
70
+ opencode_config=$(find_opencode_config 2>/dev/null) || true
71
+ if [[ -n "$opencode_config" ]] && [[ -f "$opencode_config" ]] && command -v jq &>/dev/null; then
72
+ if jq -e '.plugin | index("oh-my-opencode")' "$opencode_config" >/dev/null 2>&1; then
73
+ if should_overwrite_user_file "preserve_oh_my_opencode" "oh-my-opencode plugin entry in OpenCode config"; then
74
+ local tmp_file
75
+ tmp_file=$(mktemp)
76
+ trap 'rm -f "${tmp_file:-}"' RETURN
77
+ jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' "$opencode_config" >"$tmp_file" && mv "$tmp_file" "$opencode_config"
78
+ print_info "Removed oh-my-opencode from OpenCode plugin list"
79
+ fi
80
+ fi
81
+ fi
82
+
83
+ return 0
84
+ }
85
+
86
+ # Remove osgrep completely — one-time cleanup for all aidevops users
87
+ # osgrep consumed 74GB disk (lancedb indexes) and 4 CPU cores on startup.
88
+ # rg + fd + LLM comprehension covers the same ground at zero resource cost.
89
+ cleanup_osgrep() {
90
+ local cleaned=false
91
+
92
+ # 0. Kill running osgrep processes first (MCP servers, indexers)
93
+ # These are Node.js processes already loaded in memory — removing the
94
+ # binary and data won't stop them, and they may try to rebuild indexes.
95
+ if pgrep -f 'osgrep' >/dev/null 2>&1; then
96
+ print_info "Killing running osgrep processes..."
97
+ pkill -f 'osgrep' 2>/dev/null || true
98
+ # Give processes a moment to exit gracefully
99
+ sleep 1
100
+ # Force-kill any stragglers
101
+ pkill -9 -f 'osgrep' 2>/dev/null || true
102
+ cleaned=true
103
+ fi
104
+
105
+ # 1. Uninstall npm package (global)
106
+ if command -v osgrep &>/dev/null; then
107
+ print_info "Removing osgrep npm package..."
108
+ npm uninstall -g osgrep >/dev/null 2>&1 || true
109
+ cleaned=true
110
+ fi
111
+
112
+ # 2. Remove indexes, models, and config (~74GB)
113
+ if [[ -d "$HOME/.osgrep" ]]; then
114
+ print_info "Removing osgrep data directory (~74GB indexes)..."
115
+ rm -rf "$HOME/.osgrep"
116
+ cleaned=true
117
+ fi
118
+
119
+ # 3. Remove osgrep from OpenCode MCP config
120
+ local opencode_config
121
+ opencode_config=$(find_opencode_config 2>/dev/null) || true
122
+ if [[ -n "$opencode_config" ]] && [[ -f "$opencode_config" ]] && command -v jq &>/dev/null; then
123
+ if jq -e '.mcp["osgrep"]' "$opencode_config" >/dev/null 2>&1; then
124
+ local tmp_file
125
+ tmp_file=$(mktemp)
126
+ if jq 'del(.mcp["osgrep"]) | del(.tools["osgrep_*"])' "$opencode_config" >"$tmp_file" 2>/dev/null; then
127
+ mv "$tmp_file" "$opencode_config"
128
+ print_info "Removed osgrep from OpenCode MCP config"
129
+ else
130
+ rm -f "$tmp_file"
131
+ fi
132
+ cleaned=true
133
+ fi
134
+ fi
135
+
136
+ # 4. Remove osgrep from Claude Code settings
137
+ local claude_settings="$HOME/.claude/settings.json"
138
+ if [[ -f "$claude_settings" ]] && command -v jq &>/dev/null; then
139
+ if jq -e '.mcpServers["osgrep"] // .enabledPlugins["osgrep@osgrep"]' "$claude_settings" >/dev/null 2>&1; then
140
+ local tmp_file
141
+ tmp_file=$(mktemp)
142
+ if jq 'del(.mcpServers["osgrep"]) | del(.enabledPlugins["osgrep@osgrep"])' "$claude_settings" >"$tmp_file" 2>/dev/null; then
143
+ mv "$tmp_file" "$claude_settings"
144
+ print_info "Removed osgrep from Claude Code settings"
145
+ else
146
+ rm -f "$tmp_file"
147
+ fi
148
+ cleaned=true
149
+ fi
150
+ fi
151
+
152
+ # 5. Remove per-repo .osgrep directories in registered repos
153
+ local repos_file="$HOME/.config/aidevops/repos.json"
154
+ if [[ -f "$repos_file" ]] && command -v jq &>/dev/null; then
155
+ while IFS= read -r repo_path; do
156
+ [[ -z "$repo_path" ]] && continue
157
+ [[ ! -d "$repo_path" ]] && continue
158
+ if [[ -d "$repo_path/.osgrep" ]]; then
159
+ rm -rf "$repo_path/.osgrep"
160
+ fi
161
+ done < <(jq -r '.[]' "$repos_file" 2>/dev/null)
162
+ fi
163
+
164
+ if [[ "$cleaned" == "true" ]]; then
165
+ print_success "osgrep removed (freed CPU cores and disk space)"
166
+ fi
167
+
168
+ return 0
169
+ }
170
+
171
+ # Remove stale bun-installed opencode if npm version exists (v2.123.5)
172
+ # Prior to v2.123.1, tool-version-check.sh used `bun install -g opencode-ai`.
173
+ # This left a binary at ~/.bun/bin/opencode that shadows the npm install
174
+ # if ~/.bun/bin is earlier in PATH than the npm bin directory.
175
+ cleanup_stale_bun_opencode() {
176
+ local bun_opencode="$HOME/.bun/bin/opencode"
177
+ local bun_modules="$HOME/.bun/install/global/node_modules/opencode-ai"
178
+
179
+ # Only clean up if the stale bun binary exists
180
+ if [[ ! -f "$bun_opencode" ]] && [[ ! -d "$bun_modules" ]]; then
181
+ return 0
182
+ fi
183
+
184
+ # Only clean up if npm version is installed (don't leave user without opencode)
185
+ local npm_opencode
186
+ npm_opencode=$(npm list -g opencode-ai --json 2>/dev/null | grep -c '"opencode-ai"' || true)
187
+ if [[ "$npm_opencode" -eq 0 ]]; then
188
+ # npm version not installed — install it first, then clean up bun
189
+ if command -v npm >/dev/null 2>&1; then
190
+ print_info "Installing opencode via npm (replacing bun install)..."
191
+ npm_global_install "opencode-ai" >/dev/null 2>&1 || true
192
+ else
193
+ # Can't install npm version — leave bun version in place
194
+ return 0
195
+ fi
196
+ fi
197
+
198
+ # Remove stale bun binary and modules
199
+ if [[ -f "$bun_opencode" ]]; then
200
+ rm -f "$bun_opencode"
201
+ print_info "Removed stale bun opencode binary: $bun_opencode"
202
+ fi
203
+
204
+ if [[ -d "$bun_modules" ]]; then
205
+ rm -rf "$bun_modules"
206
+ print_info "Removed stale bun opencode modules: $bun_modules"
207
+ fi
208
+
209
+ print_success "Cleaned up stale bun opencode install (npm version is canonical)"
210
+
211
+ return 0
212
+ }
213
+
214
+ # Migrate .agent -> .agents in user projects and local config
215
+ # v2.104.0: Industry converging on .agents/ folder convention (aligning with AGENTS.md)
216
+ # This migrates:
217
+ # 1. .agent symlinks in user projects -> .agents
218
+ # 2. .agent/loop-state/ -> .agents/loop-state/ in user projects
219
+ # 3. .gitignore entries in user projects
220
+ # 4. References in user's AI assistant configs
221
+ # 5. References in ~/.aidevops/ config files
222
+ migrate_agent_to_agents_folder() {
223
+ print_info "Checking for .agent -> .agents migration..."
224
+
225
+ local migrated=0
226
+
227
+ # 1. Migrate .agent symlinks in registered repos
228
+ local repos_file="$HOME/.config/aidevops/repos.json"
229
+ if [[ -f "$repos_file" ]] && command -v jq &>/dev/null; then
230
+ while IFS= read -r repo_path; do
231
+ [[ -z "$repo_path" ]] && continue
232
+ [[ ! -d "$repo_path" ]] && continue
233
+
234
+ # Migrate legacy .agent symlink/directory to .agents real directory
235
+ if [[ -L "$repo_path/.agent" ]]; then
236
+ rm -f "$repo_path/.agent"
237
+ if [[ ! -d "$repo_path/.agents" ]]; then
238
+ mkdir -p "$repo_path/.agents"
239
+ fi
240
+ print_info " Removed legacy .agent symlink in $(basename "$repo_path")"
241
+ ((++migrated))
242
+ elif [[ -d "$repo_path/.agent" && ! -L "$repo_path/.agent" ]]; then
243
+ # Real directory (not symlink) - rename it
244
+ if [[ ! -e "$repo_path/.agents" ]]; then
245
+ mv "$repo_path/.agent" "$repo_path/.agents"
246
+ print_info " Renamed directory: $repo_path/.agent -> .agents"
247
+ ((++migrated))
248
+ fi
249
+ fi
250
+
251
+ # Migrate legacy .agents symlink to real directory
252
+ if [[ -L "$repo_path/.agents" ]]; then
253
+ rm -f "$repo_path/.agents"
254
+ mkdir -p "$repo_path/.agents"
255
+ print_info " Replaced .agents symlink with real directory in $(basename "$repo_path")"
256
+ ((++migrated))
257
+ fi
258
+
259
+ # Update .gitignore: remove legacy bare ".agents" (now tracked),
260
+ # add runtime artifact ignores, migrate .agent/ paths.
261
+ # SKIP in non-interactive mode (e.g. auto-update cron) to avoid
262
+ # leaving uncommitted changes in user repos (issue #2570 bug 1).
263
+ local gitignore="$repo_path/.gitignore"
264
+ if [[ "${NON_INTERACTIVE:-false}" == "true" ]]; then
265
+ if [[ -f "$gitignore" ]]; then
266
+ local needs_gitignore_update=false
267
+ if grep -q "^\.agents$" "$gitignore" 2>/dev/null ||
268
+ grep -q "^\.agent$" "$gitignore" 2>/dev/null ||
269
+ grep -q "^\.agent/loop-state/" "$gitignore" 2>/dev/null ||
270
+ ! grep -q "^\.agents/loop-state/" "$gitignore" 2>/dev/null; then
271
+ needs_gitignore_update=true
272
+ fi
273
+ if [[ "$needs_gitignore_update" == "true" ]]; then
274
+ print_warning " $(basename "$repo_path")/.gitignore needs migration (skipped in non-interactive mode)"
275
+ print_info " Run 'aidevops init' in $(basename "$repo_path") or 'setup.sh -i' to apply"
276
+ fi
277
+ fi
278
+ else
279
+ if [[ -f "$gitignore" ]]; then
280
+ # Remove legacy bare ".agents" and ".agent" entries (added by older versions)
281
+ # .agents/ is now a real committed directory, not a symlink to ignore
282
+ if grep -q "^\.agents$" "$gitignore" 2>/dev/null; then
283
+ sed -i '' '/^\.agents$/d' "$gitignore" 2>/dev/null ||
284
+ sed -i '/^\.agents$/d' "$gitignore" 2>/dev/null || true
285
+ print_info " Removed legacy bare .agents from .gitignore in $(basename "$repo_path")"
286
+ fi
287
+ if grep -q "^\.agent$" "$gitignore" 2>/dev/null; then
288
+ sed -i '' '/^\.agent$/d' "$gitignore" 2>/dev/null ||
289
+ sed -i '/^\.agent$/d' "$gitignore" 2>/dev/null || true
290
+ fi
291
+
292
+ # Migrate .agent/loop-state/ -> .agents/loop-state/
293
+ if grep -q "^\.agent/loop-state/" "$gitignore" 2>/dev/null; then
294
+ sed -i '' 's|^\.agent/loop-state/|.agents/loop-state/|' "$gitignore" 2>/dev/null ||
295
+ sed -i 's|^\.agent/loop-state/|.agents/loop-state/|' "$gitignore" 2>/dev/null || true
296
+ fi
297
+
298
+ # Add runtime artifact ignores if not present
299
+ if ! grep -q "^\.agents/loop-state/" "$gitignore" 2>/dev/null; then
300
+ {
301
+ echo ""
302
+ echo "# aidevops runtime artifacts"
303
+ echo ".agents/loop-state/"
304
+ echo ".agents/tmp/"
305
+ echo ".agents/memory/"
306
+ } >>"$gitignore"
307
+ print_info " Added .agents/ runtime artifact ignores in $(basename "$repo_path")"
308
+ fi
309
+ fi
310
+ fi
311
+ done < <(jq -r '.initialized_repos[].path' "$repos_file" 2>/dev/null)
312
+ fi
313
+
314
+ # 2. Also scan ~/Git/ for any .agent symlinks or directories not in repos.json
315
+ if [[ -d "$HOME/Git" ]]; then
316
+ while IFS= read -r -d '' agent_path; do
317
+ local repo_dir
318
+ repo_dir=$(dirname "$agent_path")
319
+
320
+ if [[ -L "$agent_path" ]]; then
321
+ # Symlink: remove and create real directory
322
+ rm -f "$agent_path"
323
+ if [[ ! -d "$repo_dir/.agents" ]]; then
324
+ mkdir -p "$repo_dir/.agents"
325
+ fi
326
+ print_info " Removed legacy .agent symlink: $agent_path"
327
+ ((++migrated))
328
+ elif [[ -d "$agent_path" ]]; then
329
+ # Directory: rename to .agents if .agents doesn't exist
330
+ if [[ ! -e "$repo_dir/.agents" ]]; then
331
+ mv "$agent_path" "$repo_dir/.agents"
332
+ print_info " Renamed directory: $agent_path -> .agents"
333
+ ((++migrated))
334
+ fi
335
+ fi
336
+ done < <(find "$HOME/Git" -maxdepth 3 -name ".agent" \( -type l -o -type d \) -print0 2>/dev/null)
337
+ fi
338
+
339
+ # 3. Update AI assistant config files that reference .agent/
340
+ local ai_config_files=(
341
+ "$HOME/.config/opencode/agent/AGENTS.md"
342
+ "$HOME/.config/Claude/AGENTS.md"
343
+ "$HOME/.claude/commands/AGENTS.md"
344
+ "$HOME/.opencode/AGENTS.md"
345
+ )
346
+
347
+ for config_file in "${ai_config_files[@]}"; do
348
+ if [[ -f "$config_file" ]]; then
349
+ if grep -q '\.agent/' "$config_file" 2>/dev/null; then
350
+ sed -i '' 's|\.agent/|.agents/|g' "$config_file" 2>/dev/null ||
351
+ sed -i 's|\.agent/|.agents/|g' "$config_file" 2>/dev/null || true
352
+ print_info " Updated references in $config_file"
353
+ ((++migrated))
354
+ fi
355
+ fi
356
+ done
357
+
358
+ # 4. Update session greeting cache if it references .agent/
359
+ local greeting_cache="$HOME/.aidevops/cache/session-greeting.txt"
360
+ if [[ -f "$greeting_cache" ]]; then
361
+ if grep -q '\.agent/' "$greeting_cache" 2>/dev/null; then
362
+ sed -i '' 's|\.agent/|.agents/|g' "$greeting_cache" 2>/dev/null ||
363
+ sed -i 's|\.agent/|.agents/|g' "$greeting_cache" 2>/dev/null || true
364
+ fi
365
+ fi
366
+
367
+ if [[ $migrated -gt 0 ]]; then
368
+ print_success "Migrated $migrated .agent -> .agents reference(s)"
369
+ else
370
+ print_info "No .agent -> .agents migration needed"
371
+ fi
372
+
373
+ return 0
374
+ }
375
+
376
+ # Remove deprecated MCP entries from opencode.json
377
+ # These MCPs have been replaced by curl-based subagents (zero context cost)
378
+ cleanup_deprecated_mcps() {
379
+ local opencode_config
380
+ opencode_config=$(find_opencode_config) || return 0
381
+
382
+ if [[ ! -f "$opencode_config" ]]; then
383
+ return 0
384
+ fi
385
+
386
+ if ! command -v jq &>/dev/null; then
387
+ return 0
388
+ fi
389
+
390
+ # MCPs replaced by curl subagents in v2.79.0
391
+ local deprecated_mcps=(
392
+ "hetzner-webapp"
393
+ "hetzner-brandlight"
394
+ "hetzner-marcusquinn"
395
+ "hetzner-storagebox"
396
+ "ahrefs"
397
+ "serper"
398
+ "dataforseo"
399
+ "hostinger-api"
400
+ "shadcn"
401
+ "repomix"
402
+ )
403
+
404
+ # Tool rules to remove (for MCPs that no longer exist)
405
+ local deprecated_tools=(
406
+ "hetzner-*"
407
+ "hostinger-api_*"
408
+ "ahrefs_*"
409
+ "dataforseo_*"
410
+ "serper_*"
411
+ "shadcn_*"
412
+ "repomix_*"
413
+ )
414
+
415
+ local cleaned=0
416
+ local tmp_config
417
+ tmp_config=$(mktemp)
418
+ trap 'rm -f "${tmp_config:-}"' RETURN
419
+
420
+ cp "$opencode_config" "$tmp_config"
421
+
422
+ for mcp in "${deprecated_mcps[@]}"; do
423
+ if jq -e ".mcp[\"$mcp\"]" "$tmp_config" >/dev/null 2>&1; then
424
+ jq "del(.mcp[\"$mcp\"])" "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
425
+ ((++cleaned))
426
+ fi
427
+ done
428
+
429
+ for tool in "${deprecated_tools[@]}"; do
430
+ if jq -e ".tools[\"$tool\"]" "$tmp_config" >/dev/null 2>&1; then
431
+ jq "del(.tools[\"$tool\"])" "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
432
+ fi
433
+ done
434
+
435
+ # Also remove deprecated tool refs from SEO agent
436
+ if jq -e '.agent.SEO.tools["dataforseo_*"]' "$tmp_config" >/dev/null 2>&1; then
437
+ jq 'del(.agent.SEO.tools["dataforseo_*"]) | del(.agent.SEO.tools["serper_*"]) | del(.agent.SEO.tools["ahrefs_*"])' "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
438
+ fi
439
+
440
+ # Migrate npx/pipx commands to full binary paths (faster startup, PATH-independent)
441
+ # Parallel arrays avoid bash associative array issues with @ in package names
442
+ local -a mcp_pkgs=(
443
+ "chrome-devtools-mcp"
444
+ "mcp-server-gsc"
445
+ "playwriter"
446
+ "@steipete/macos-automator-mcp"
447
+ "@steipete/claude-code-mcp"
448
+ "analytics-mcp"
449
+ )
450
+ local -a mcp_bins=(
451
+ "chrome-devtools-mcp"
452
+ "mcp-server-gsc"
453
+ "playwriter"
454
+ "macos-automator-mcp"
455
+ "claude-code-mcp"
456
+ "analytics-mcp"
457
+ )
458
+
459
+ local i
460
+ for i in "${!mcp_pkgs[@]}"; do
461
+ local pkg="${mcp_pkgs[$i]}"
462
+ local bin_name="${mcp_bins[$i]}"
463
+ # Find MCP key using npx/bunx/pipx for this package (single query)
464
+ local mcp_key
465
+ mcp_key=$(jq -r --arg pkg "$pkg" '.mcp | to_entries[] | select(.value.command != null) | select(.value.command | join(" ") | test("npx.*" + $pkg + "|bunx.*" + $pkg + "|pipx.*run.*" + $pkg)) | .key' "$tmp_config" 2>/dev/null | head -1)
466
+
467
+ if [[ -n "$mcp_key" ]]; then
468
+ # Resolve full path for the binary
469
+ local full_path
470
+ full_path=$(resolve_mcp_binary_path "$bin_name")
471
+ if [[ -n "$full_path" ]]; then
472
+ jq --arg k "$mcp_key" --arg p "$full_path" '.mcp[$k].command = [$p]' "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
473
+ ((++cleaned))
474
+ fi
475
+ fi
476
+ done
477
+
478
+ # Migrate outscraper from bash -c wrapper to full binary path
479
+ if jq -e '.mcp.outscraper.command | join(" ") | test("bash.*outscraper")' "$tmp_config" >/dev/null 2>&1; then
480
+ local outscraper_path
481
+ outscraper_path=$(resolve_mcp_binary_path "outscraper-mcp-server")
482
+ if [[ -n "$outscraper_path" ]]; then
483
+ # Source the API key and set it in environment
484
+ local outscraper_key=""
485
+ if [[ -f "$HOME/.config/aidevops/credentials.sh" ]]; then
486
+ # shellcheck source=/dev/null
487
+ outscraper_key=$(source "$HOME/.config/aidevops/credentials.sh" && echo "${OUTSCRAPER_API_KEY:-}")
488
+ fi
489
+ jq --arg p "$outscraper_path" --arg key "$outscraper_key" '.mcp.outscraper.command = [$p] | .mcp.outscraper.environment = {"OUTSCRAPER_API_KEY": $key}' "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
490
+ ((++cleaned))
491
+ fi
492
+ fi
493
+
494
+ if [[ $cleaned -gt 0 ]]; then
495
+ create_backup_with_rotation "$opencode_config" "opencode"
496
+ mv "$tmp_config" "$opencode_config"
497
+ print_info "Updated $cleaned MCP entry/entries in opencode.json (using full binary paths)"
498
+ else
499
+ rm -f "$tmp_config"
500
+ fi
501
+
502
+ # Always resolve bare binary names to full paths (fixes PATH-dependent startup)
503
+ update_mcp_paths_in_opencode
504
+
505
+ return 0
506
+ }
507
+
508
+ # Disable MCPs globally that should only be enabled on-demand via subagents
509
+ # This reduces session startup context by disabling rarely-used MCPs
510
+ # - playwriter: ~3K tokens - enable via @playwriter subagent
511
+ # - augment-context-engine: ~1K tokens - enable via @augment-context-engine subagent
512
+ # - gh_grep: ~600 tokens - replaced by @github-search subagent (uses rg/bash)
513
+ # - google-analytics-mcp: ~800 tokens - enable via @google-analytics subagent
514
+ # - context7: ~800 tokens - enable via @context7 subagent (for library docs lookup)
515
+ disable_ondemand_mcps() {
516
+ local opencode_config
517
+ opencode_config=$(find_opencode_config) || return 0
518
+
519
+ if [[ ! -f "$opencode_config" ]]; then
520
+ return 0
521
+ fi
522
+
523
+ if ! command -v jq &>/dev/null; then
524
+ return 0
525
+ fi
526
+
527
+ # MCPs to disable globally (these have subagent alternatives or are unused)
528
+ # Note: use exact MCP key names from opencode.json
529
+ local -a ondemand_mcps=(
530
+ "playwriter"
531
+ "augment-context-engine"
532
+ "gh_grep"
533
+ "google-analytics-mcp"
534
+ "grep_app"
535
+ "websearch"
536
+ # KEEP ENABLED: context7 (library docs)
537
+ )
538
+
539
+ local disabled=0
540
+ local changed=0
541
+ local tmp_config
542
+ tmp_config=$(mktemp)
543
+ trap 'rm -f "${tmp_config:-}"' RETURN
544
+
545
+ cp "$opencode_config" "$tmp_config"
546
+
547
+ for mcp in "${ondemand_mcps[@]}"; do
548
+ # Only disable MCPs that exist in the config
549
+ # Don't add fake entries - they break OpenCode's config validation
550
+ if jq -e ".mcp[\"$mcp\"]" "$tmp_config" >/dev/null 2>&1; then
551
+ local current_enabled
552
+ current_enabled=$(jq -r ".mcp[\"$mcp\"].enabled // \"true\"" "$tmp_config")
553
+ if [[ "$current_enabled" != "false" ]]; then
554
+ jq ".mcp[\"$mcp\"].enabled = false" "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
555
+ ((++disabled))
556
+ fi
557
+ fi
558
+ done
559
+
560
+ # Remove invalid MCP entries added by v2.100.16 bug
561
+ # These have type "stdio" (invalid - only "local" or "remote" are valid)
562
+ # or command ["echo", "disabled"] which breaks OpenCode
563
+ local invalid_mcps=("grep_app" "websearch" "context7" "augment-context-engine")
564
+ for mcp in "${invalid_mcps[@]}"; do
565
+ # Check for invalid type "stdio" or dummy command
566
+ if jq -e ".mcp[\"$mcp\"].type == \"stdio\" or .mcp[\"$mcp\"].command[0] == \"echo\"" "$tmp_config" >/dev/null 2>&1; then
567
+ jq "del(.mcp[\"$mcp\"])" "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
568
+ print_info "Removed invalid MCP entry: $mcp"
569
+ changed=1
570
+ fi
571
+ done
572
+
573
+ # Re-enable MCPs that were accidentally disabled (v2.100.16-17 bug)
574
+ local -a keep_enabled=("context7")
575
+ for mcp in "${keep_enabled[@]}"; do
576
+ if jq -e ".mcp[\"$mcp\"].enabled == false" "$tmp_config" >/dev/null 2>&1; then
577
+ jq ".mcp[\"$mcp\"].enabled = true" "$tmp_config" >"${tmp_config}.new" && mv "${tmp_config}.new" "$tmp_config"
578
+ print_info "Re-enabled $mcp MCP"
579
+ changed=1
580
+ fi
581
+ done
582
+
583
+ if [[ $disabled -gt 0 || $changed -gt 0 ]]; then
584
+ create_backup_with_rotation "$opencode_config" "opencode"
585
+ mv "$tmp_config" "$opencode_config"
586
+ if [[ $disabled -gt 0 ]]; then
587
+ print_info "Disabled $disabled MCP(s) globally (use subagents to enable on-demand)"
588
+ fi
589
+ else
590
+ rm -f "$tmp_config"
591
+ fi
592
+
593
+ return 0
594
+ }
595
+
596
+ # Validate and repair OpenCode config schema
597
+ # Fixes common issues from manual editing or AI-generated configs:
598
+ # - MCP entries missing "type": "local" field
599
+ # - tools entries as objects {} instead of booleans
600
+ # If invalid, backs up and regenerates using the generator script
601
+ validate_opencode_config() {
602
+ local opencode_config
603
+ opencode_config=$(find_opencode_config) || return 0
604
+
605
+ if [[ ! -f "$opencode_config" ]]; then
606
+ return 0
607
+ fi
608
+
609
+ if ! command -v jq &>/dev/null; then
610
+ return 0
611
+ fi
612
+
613
+ local needs_repair=false
614
+ local issues=""
615
+
616
+ # Check 0: Remove deprecated top-level keys that OpenCode no longer recognizes
617
+ # "compaction" was removed in OpenCode v1.1.x - causes "Unrecognized key" error
618
+ local deprecated_keys=("compaction")
619
+ for key in "${deprecated_keys[@]}"; do
620
+ if jq -e ".[\"$key\"]" "$opencode_config" >/dev/null 2>&1; then
621
+ local tmp_fix
622
+ tmp_fix=$(mktemp)
623
+ trap 'rm -f "${tmp_fix:-}"' RETURN
624
+ if jq "del(.[\"$key\"])" "$opencode_config" >"$tmp_fix" 2>/dev/null; then
625
+ create_backup_with_rotation "$opencode_config" "opencode"
626
+ mv "$tmp_fix" "$opencode_config"
627
+ print_info "Removed deprecated '$key' key from OpenCode config"
628
+ else
629
+ rm -f "$tmp_fix"
630
+ fi
631
+ fi
632
+ done
633
+
634
+ # Check 1: MCP entries must have "type" field (usually "local")
635
+ # Invalid: {"mcp": {"foo": {"command": "..."}}}
636
+ # Valid: {"mcp": {"foo": {"type": "local", "command": "..."}}}
637
+ local mcps_without_type
638
+ mcps_without_type=$(jq -r '.mcp // {} | to_entries[] | select(.value.type == null and .value.command != null) | .key' "$opencode_config" 2>/dev/null | head -5)
639
+ if [[ -n "$mcps_without_type" ]]; then
640
+ needs_repair=true
641
+ issues="${issues}\n - MCP entries missing 'type' field: $(echo "$mcps_without_type" | tr '\n' ', ' | sed 's/,$//')"
642
+ fi
643
+
644
+ # Check 2: tools entries must be booleans, not objects
645
+ # Invalid: {"tools": {"gh_grep": {}}}
646
+ # Valid: {"tools": {"gh_grep": true}}
647
+ local tools_as_objects
648
+ tools_as_objects=$(jq -r '.tools // {} | to_entries[] | select(.value | type == "object") | .key' "$opencode_config" 2>/dev/null | head -5)
649
+ if [[ -n "$tools_as_objects" ]]; then
650
+ needs_repair=true
651
+ issues="${issues}\n - tools entries as objects instead of booleans: $(echo "$tools_as_objects" | tr '\n' ', ' | sed 's/,$//')"
652
+ fi
653
+
654
+ # Check 3: Try to parse with opencode (if available) to catch other schema issues
655
+ if command -v opencode &>/dev/null; then
656
+ local validation_output
657
+ if ! validation_output=$(opencode --version 2>&1); then
658
+ # If opencode fails to start, config might be invalid
659
+ if [[ "$validation_output" == *"Configuration is invalid"* ]]; then
660
+ needs_repair=true
661
+ issues="${issues}\n - OpenCode reports invalid configuration"
662
+ fi
663
+ fi
664
+ fi
665
+
666
+ if [[ "$needs_repair" == "true" ]]; then
667
+ print_warning "OpenCode config has schema issues:$issues"
668
+
669
+ # Backup the invalid config
670
+ create_backup_with_rotation "$opencode_config" "opencode"
671
+ print_info "Backed up invalid config"
672
+
673
+ # Remove the invalid config so generator creates fresh one
674
+ rm -f "$opencode_config"
675
+
676
+ # Regenerate using the generator script
677
+ local generator_script="$HOME/.aidevops/agents/scripts/generate-opencode-agents.sh"
678
+ if [[ -x "$generator_script" ]]; then
679
+ print_info "Regenerating OpenCode config with correct schema..."
680
+ if "$generator_script" >/dev/null 2>&1; then
681
+ print_success "OpenCode config regenerated successfully"
682
+ else
683
+ print_warning "Config regeneration failed - run manually: $generator_script"
684
+ fi
685
+ else
686
+ print_warning "Generator script not found - run setup.sh again after agents are deployed"
687
+ fi
688
+ fi
689
+
690
+ return 0
691
+ }
692
+
693
+ # Migrate mcp-env.sh to credentials.sh (v2.105.0)
694
+ # Renames the credential file and creates backward-compatible symlink
695
+ migrate_mcp_env_to_credentials() {
696
+ local config_dir="$HOME/.config/aidevops"
697
+ local old_file="$config_dir/mcp-env.sh"
698
+ local new_file="$config_dir/credentials.sh"
699
+ local migrated=0
700
+
701
+ # Migrate root-level mcp-env.sh -> credentials.sh
702
+ if [[ -f "$old_file" && ! -L "$old_file" ]]; then
703
+ if [[ ! -f "$new_file" ]]; then
704
+ mv "$old_file" "$new_file"
705
+ chmod 600 "$new_file"
706
+ ((++migrated))
707
+ print_info "Renamed mcp-env.sh to credentials.sh"
708
+ fi
709
+ # Create backward-compatible symlink
710
+ if [[ ! -L "$old_file" ]]; then
711
+ ln -sf "credentials.sh" "$old_file"
712
+ print_info "Created symlink mcp-env.sh -> credentials.sh"
713
+ fi
714
+ fi
715
+
716
+ # Migrate tenant-level mcp-env.sh -> credentials.sh
717
+ local tenants_dir="$config_dir/tenants"
718
+ if [[ -d "$tenants_dir" ]]; then
719
+ for tenant_dir in "$tenants_dir"/*/; do
720
+ [[ -d "$tenant_dir" ]] || continue
721
+ local tenant_old="$tenant_dir/mcp-env.sh"
722
+ local tenant_new="$tenant_dir/credentials.sh"
723
+ if [[ -f "$tenant_old" && ! -L "$tenant_old" ]]; then
724
+ if [[ ! -f "$tenant_new" ]]; then
725
+ mv "$tenant_old" "$tenant_new"
726
+ chmod 600 "$tenant_new"
727
+ ((++migrated))
728
+ fi
729
+ if [[ ! -L "$tenant_old" ]]; then
730
+ ln -sf "credentials.sh" "$tenant_old"
731
+ fi
732
+ fi
733
+ done
734
+ fi
735
+
736
+ # Update shell rc files that source the old path
737
+ for rc_file in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"; do
738
+ if [[ -f "$rc_file" ]] && grep -q 'source.*mcp-env\.sh' "$rc_file" 2>/dev/null; then
739
+ # shellcheck disable=SC2016
740
+ sed -i '' 's|source.*\.config/aidevops/mcp-env\.sh|source "$HOME/.config/aidevops/credentials.sh"|g' "$rc_file" 2>/dev/null ||
741
+ sed -i 's|source.*\.config/aidevops/mcp-env\.sh|source "$HOME/.config/aidevops/credentials.sh"|g' "$rc_file" 2>/dev/null || true
742
+ ((++migrated))
743
+ print_info "Updated $rc_file to source credentials.sh"
744
+ fi
745
+ done
746
+
747
+ if [[ $migrated -gt 0 ]]; then
748
+ print_success "Migrated $migrated mcp-env.sh -> credentials.sh reference(s)"
749
+ fi
750
+
751
+ return 0
752
+ }
753
+
754
+ # Migrate old config-backups to new per-type backup structure
755
+ # This runs once to clean up the legacy backup directory
756
+ migrate_old_backups() {
757
+ local old_backup_dir="$HOME/.aidevops/config-backups"
758
+
759
+ # Skip if old directory doesn't exist
760
+ if [[ ! -d "$old_backup_dir" ]]; then
761
+ return 0
762
+ fi
763
+
764
+ # Count old backups
765
+ local old_count
766
+ old_count=$(find "$old_backup_dir" -maxdepth 1 -type d -name "20*" 2>/dev/null | wc -l | tr -d ' ')
767
+
768
+ if [[ $old_count -eq 0 ]]; then
769
+ # Empty directory, just remove it
770
+ rm -rf "$old_backup_dir"
771
+ return 0
772
+ fi
773
+
774
+ print_info "Migrating $old_count old backups to new structure..."
775
+
776
+ # Create new backup directories
777
+ mkdir -p "$HOME/.aidevops/agents-backups"
778
+ mkdir -p "$HOME/.aidevops/opencode-backups"
779
+
780
+ # Move the most recent backups (up to BACKUP_KEEP_COUNT) to new locations
781
+ # Old backups contained mixed content, so we'll just keep the newest ones as agents backups
782
+ local migrated=0
783
+ for backup in $(find "$old_backup_dir" -maxdepth 1 -type d -name "20*" 2>/dev/null | sort -r | head -n "$BACKUP_KEEP_COUNT"); do
784
+ local backup_name
785
+ backup_name=$(basename "$backup")
786
+
787
+ # Check if it contains agents folder (most common)
788
+ if [[ -d "$backup/agents" ]]; then
789
+ mv "$backup" "$HOME/.aidevops/agents-backups/$backup_name"
790
+ ((++migrated))
791
+ # Check if it contains opencode.json
792
+ elif [[ -f "$backup/opencode.json" ]]; then
793
+ mv "$backup" "$HOME/.aidevops/opencode-backups/$backup_name"
794
+ ((++migrated))
795
+ fi
796
+ done
797
+
798
+ # Remove remaining old backups and the old directory
799
+ rm -rf "$old_backup_dir"
800
+
801
+ if [[ $migrated -gt 0 ]]; then
802
+ print_success "Migrated $migrated recent backups, removed $((old_count - migrated)) old backups"
803
+ else
804
+ print_info "Cleaned up $old_count old backups"
805
+ fi
806
+
807
+ return 0
808
+ }
809
+
810
+ # Migrate loop state from .claude/ to .agents/loop-state/ in user projects
811
+ # Also migrates from legacy .agents/loop-state/ to .agents/loop-state/
812
+ # The migration is non-destructive: moves files, doesn't delete originals until confirmed
813
+ migrate_loop_state_directories() {
814
+ print_info "Checking for legacy loop state directories..."
815
+
816
+ local migrated=0
817
+ local git_dirs=()
818
+
819
+ # Find Git repositories in common locations
820
+ # Check ~/Git/ and current directory's parent
821
+ for search_dir in "$HOME/Git" "$(dirname "$(pwd)")"; do
822
+ if [[ -d "$search_dir" ]]; then
823
+ while IFS= read -r -d '' git_dir; do
824
+ git_dirs+=("$(dirname "$git_dir")")
825
+ done < <(find "$search_dir" -maxdepth 3 -type d -name ".git" -print0 2>/dev/null)
826
+ fi
827
+ done
828
+
829
+ for repo_dir in "${git_dirs[@]}"; do
830
+ local old_state_dir="$repo_dir/.claude"
831
+ local legacy_state_dir="$repo_dir/.agent/loop-state"
832
+ local new_state_dir="$repo_dir/.agents/loop-state"
833
+
834
+ # Migrate from .claude/ (oldest legacy path)
835
+ if [[ -d "$old_state_dir" ]]; then
836
+ local has_loop_state=false
837
+ if [[ -f "$old_state_dir/ralph-loop.local.state" ]] ||
838
+ [[ -f "$old_state_dir/loop-state.json" ]] ||
839
+ [[ -d "$old_state_dir/receipts" ]]; then
840
+ has_loop_state=true
841
+ fi
842
+
843
+ if [[ "$has_loop_state" == "true" ]]; then
844
+ print_info "Found legacy loop state in: $repo_dir/.claude/"
845
+ mkdir -p "$new_state_dir"
846
+
847
+ for file in ralph-loop.local.state loop-state.json re-anchor.md guardrails.md; do
848
+ if [[ -f "$old_state_dir/$file" ]]; then
849
+ mv "$old_state_dir/$file" "$new_state_dir/"
850
+ print_info " Moved $file"
851
+ fi
852
+ done
853
+
854
+ if [[ -d "$old_state_dir/receipts" ]]; then
855
+ mv "$old_state_dir/receipts" "$new_state_dir/"
856
+ print_info " Moved receipts/"
857
+ fi
858
+
859
+ local remaining
860
+ remaining=$(find "$old_state_dir" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
861
+
862
+ if [[ "$remaining" -eq 0 ]]; then
863
+ rmdir "$old_state_dir" 2>/dev/null && print_info " Removed empty .claude/"
864
+ else
865
+ print_warning " .claude/ has other files, not removing"
866
+ fi
867
+
868
+ ((++migrated))
869
+ fi
870
+ fi
871
+
872
+ # Migrate from .agents/loop-state/ (v2.51.0-v2.103.0 path) to .agents/loop-state/
873
+ if [[ -d "$legacy_state_dir" ]] && [[ "$legacy_state_dir" != "$new_state_dir" ]]; then
874
+ print_info "Found legacy loop state in: $repo_dir/.agent/loop-state/"
875
+ mkdir -p "$new_state_dir"
876
+
877
+ # Move all files from old to new
878
+ if [[ -n "$(ls -A "$legacy_state_dir" 2>/dev/null)" ]]; then
879
+ cp -R "$legacy_state_dir"/* "$new_state_dir/" 2>/dev/null || true
880
+ rm -rf "$legacy_state_dir"
881
+ print_info " Migrated .agents/loop-state/ -> .agents/loop-state/"
882
+ ((++migrated))
883
+ fi
884
+ fi
885
+
886
+ # Update .gitignore if needed
887
+ local gitignore="$repo_dir/.gitignore"
888
+ if [[ -f "$gitignore" ]]; then
889
+ if ! grep -q "^\.agents/loop-state/" "$gitignore" 2>/dev/null; then
890
+ echo ".agents/loop-state/" >>"$gitignore"
891
+ print_info " Added .agents/loop-state/ to .gitignore"
892
+ fi
893
+ fi
894
+ done
895
+
896
+ if [[ $migrated -gt 0 ]]; then
897
+ print_success "Migrated loop state in $migrated repositories"
898
+ else
899
+ print_info "No legacy loop state directories found"
900
+ fi
901
+
902
+ return 0
903
+ }
904
+
905
+ # Migrate pulse-repos.json into repos.json
906
+ # pulse-repos.json had slug/path/priority for supervisor-managed repos.
907
+ # Now repos.json is the single source of truth with slug, pulse, and priority fields.
908
+ migrate_pulse_repos_to_repos_json() {
909
+ local pulse_file="$HOME/.config/aidevops/pulse-repos.json"
910
+ local repos_file="$HOME/.config/aidevops/repos.json"
911
+
912
+ if [[ ! -f "$pulse_file" ]]; then
913
+ return 0
914
+ fi
915
+
916
+ if ! command -v jq &>/dev/null; then
917
+ print_warning "jq not installed — skipping pulse-repos.json migration"
918
+ return 0
919
+ fi
920
+
921
+ if [[ ! -f "$repos_file" ]]; then
922
+ print_warning "repos.json not found — skipping pulse-repos.json migration"
923
+ return 0
924
+ fi
925
+
926
+ local migrated=0
927
+ local slug path priority
928
+
929
+ # Read each entry from pulse-repos.json and merge into repos.json
930
+ while IFS=$'\t' read -r slug path priority; do
931
+ [[ -z "$slug" ]] && continue
932
+ # Expand ~ in path
933
+ local expanded_path="${path/#\~/$HOME}"
934
+
935
+ # Check if this repo exists in repos.json by path
936
+ if jq -e --arg path "$expanded_path" '.initialized_repos[] | select(.path == $path)' "$repos_file" &>/dev/null; then
937
+ # Update existing entry: add slug, pulse, priority
938
+ local temp_file="${repos_file}.tmp"
939
+ jq --arg path "$expanded_path" --arg slug "$slug" --arg priority "$priority" \
940
+ '(.initialized_repos[] | select(.path == $path)) |= . + {slug: $slug, pulse: true, priority: $priority}' \
941
+ "$repos_file" >"$temp_file" && mv "$temp_file" "$repos_file"
942
+ ((++migrated))
943
+ else
944
+ # Add new entry from pulse-repos.json
945
+ local temp_file="${repos_file}.tmp"
946
+ jq --arg path "$expanded_path" --arg slug "$slug" --arg priority "$priority" \
947
+ '.initialized_repos += [{path: $path, slug: $slug, pulse: true, priority: $priority}]' \
948
+ "$repos_file" >"$temp_file" && mv "$temp_file" "$repos_file"
949
+ ((++migrated))
950
+ fi
951
+ done < <(jq -r '(.repos? // .)[] | [.slug, .path, .priority] | @tsv' "$pulse_file" 2>/dev/null)
952
+
953
+ if [[ $migrated -gt 0 ]]; then
954
+ print_success "Migrated $migrated repo(s) from pulse-repos.json into repos.json"
955
+ # Rename old file so it's not read again, but keep as backup
956
+ mv "$pulse_file" "${pulse_file}.migrated"
957
+ print_info "Renamed pulse-repos.json to pulse-repos.json.migrated"
958
+ fi
959
+
960
+ return 0
961
+ }