ai-agent-rules 0.11.0__py3-none-any.whl

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.

Potentially problematic release.


This version of ai-agent-rules might be problematic. Click here for more details.

Files changed (42) hide show
  1. ai_agent_rules-0.11.0.dist-info/METADATA +390 -0
  2. ai_agent_rules-0.11.0.dist-info/RECORD +42 -0
  3. ai_agent_rules-0.11.0.dist-info/WHEEL +5 -0
  4. ai_agent_rules-0.11.0.dist-info/entry_points.txt +3 -0
  5. ai_agent_rules-0.11.0.dist-info/licenses/LICENSE +22 -0
  6. ai_agent_rules-0.11.0.dist-info/top_level.txt +1 -0
  7. ai_rules/__init__.py +8 -0
  8. ai_rules/agents/__init__.py +1 -0
  9. ai_rules/agents/base.py +68 -0
  10. ai_rules/agents/claude.py +121 -0
  11. ai_rules/agents/goose.py +44 -0
  12. ai_rules/agents/shared.py +35 -0
  13. ai_rules/bootstrap/__init__.py +75 -0
  14. ai_rules/bootstrap/config.py +261 -0
  15. ai_rules/bootstrap/installer.py +249 -0
  16. ai_rules/bootstrap/updater.py +221 -0
  17. ai_rules/bootstrap/version.py +52 -0
  18. ai_rules/cli.py +2292 -0
  19. ai_rules/completions.py +194 -0
  20. ai_rules/config/AGENTS.md +249 -0
  21. ai_rules/config/chat_agent_hints.md +1 -0
  22. ai_rules/config/claude/agents/code-reviewer.md +121 -0
  23. ai_rules/config/claude/commands/annotate-changelog.md +191 -0
  24. ai_rules/config/claude/commands/comment-cleanup.md +161 -0
  25. ai_rules/config/claude/commands/continue-crash.md +38 -0
  26. ai_rules/config/claude/commands/dev-docs.md +169 -0
  27. ai_rules/config/claude/commands/pr-creator.md +247 -0
  28. ai_rules/config/claude/commands/test-cleanup.md +244 -0
  29. ai_rules/config/claude/commands/update-docs.md +324 -0
  30. ai_rules/config/claude/hooks/subagentStop.py +92 -0
  31. ai_rules/config/claude/mcps.json +1 -0
  32. ai_rules/config/claude/settings.json +116 -0
  33. ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
  34. ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
  35. ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
  36. ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
  37. ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
  38. ai_rules/config/goose/config.yaml +55 -0
  39. ai_rules/config.py +635 -0
  40. ai_rules/display.py +40 -0
  41. ai_rules/mcp.py +370 -0
  42. ai_rules/symlinks.py +207 -0
ai_rules/cli.py ADDED
@@ -0,0 +1,2292 @@
1
+ """Command-line interface for ai-rules."""
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ import sys
7
+
8
+ from importlib.metadata import PackageNotFoundError
9
+ from importlib.metadata import version as get_version
10
+ from importlib.resources import files as resource_files
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ from click.shell_completion import CompletionItem
19
+ from rich.console import Console
20
+ from rich.prompt import Confirm
21
+ from rich.table import Table
22
+
23
+ from ai_rules.agents.base import Agent
24
+ from ai_rules.agents.claude import ClaudeAgent
25
+ from ai_rules.agents.goose import GooseAgent
26
+ from ai_rules.agents.shared import SharedAgent
27
+ from ai_rules.config import (
28
+ AGENT_CONFIG_METADATA,
29
+ Config,
30
+ parse_setting_path,
31
+ validate_override_path,
32
+ )
33
+ from ai_rules.symlinks import (
34
+ SymlinkResult,
35
+ check_symlink,
36
+ create_symlink,
37
+ remove_symlink,
38
+ )
39
+
40
+ console = Console()
41
+
42
+ try:
43
+ __version__ = get_version("ai-agent-rules")
44
+ except PackageNotFoundError:
45
+ __version__ = "dev"
46
+
47
+ _NON_INTERACTIVE_FLAGS = frozenset({"--dry-run", "--help", "-h"})
48
+
49
+ GIT_SUBPROCESS_TIMEOUT = 5
50
+
51
+
52
+ def get_config_dir() -> Path:
53
+ """Get the config directory.
54
+
55
+ Works in both development mode (git repo) and installed mode (PyPI package).
56
+ Uses importlib.resources which handles both cases automatically.
57
+ """
58
+ try:
59
+ config_resource = resource_files("ai_rules") / "config"
60
+ return Path(str(config_resource))
61
+ except Exception:
62
+ return Path(__file__).parent / "config"
63
+
64
+
65
+ def get_git_repo_root() -> Path:
66
+ """Get the git repository root directory.
67
+
68
+ This only works in development mode when the code is in a git repository.
69
+ For installed packages, this will fail - use get_config_dir() instead.
70
+ """
71
+ try:
72
+ result = subprocess.run(
73
+ ["git", "rev-parse", "--show-toplevel"],
74
+ capture_output=True,
75
+ text=True,
76
+ check=False,
77
+ timeout=GIT_SUBPROCESS_TIMEOUT,
78
+ )
79
+ if result.returncode == 0:
80
+ return Path(result.stdout.strip())
81
+ except Exception:
82
+ pass
83
+
84
+ raise RuntimeError(
85
+ "Not in a git repository. For config access, use get_config_dir() instead."
86
+ )
87
+
88
+
89
+ def get_repo_root() -> Path:
90
+ """Get repository root - wrapper for backward compatibility.
91
+
92
+ DEPRECATED: Use get_config_dir() for config access or get_git_repo_root() for git operations.
93
+ This function exists only for backward compatibility during migration.
94
+
95
+ Returns:
96
+ Path to repository root (in dev mode) or package parent (in installed mode)
97
+ """
98
+ try:
99
+ return get_git_repo_root()
100
+ except RuntimeError:
101
+ # Not in git repo - fall back to package location
102
+ # This allows commands to work in PyPI-installed mode
103
+ return Path(__file__).parent.parent.parent.absolute()
104
+
105
+
106
+ def get_agents(config_dir: Path, config: Config) -> list[Agent]:
107
+ """Get all agent instances.
108
+
109
+ Args:
110
+ config_dir: Path to the config directory (use get_config_dir())
111
+ config: Configuration object
112
+
113
+ Returns:
114
+ List of all available agent instances
115
+ """
116
+ return [
117
+ ClaudeAgent(config_dir, config),
118
+ GooseAgent(config_dir, config),
119
+ SharedAgent(config_dir, config),
120
+ ]
121
+
122
+
123
+ def complete_agents(
124
+ ctx: click.Context, param: click.Parameter, incomplete: str
125
+ ) -> list[CompletionItem]:
126
+ """Dynamically discover and complete agent names for --agents option."""
127
+ config_dir = get_config_dir()
128
+ config = Config.load()
129
+ agents = get_agents(config_dir, config)
130
+ agent_ids = [agent.agent_id for agent in agents]
131
+
132
+ return [CompletionItem(aid) for aid in agent_ids if aid.startswith(incomplete)]
133
+
134
+
135
+ def detect_old_config_symlinks() -> list[tuple[Path, Path]]:
136
+ """Detect symlinks pointing to old config/ location.
137
+
138
+ Returns list of (symlink_path, old_target) tuples for broken symlinks.
139
+ This is used for migration from v0.4.1 to v0.5.0 when config moved
140
+ from repo root to src/ai_rules/config/.
141
+
142
+ Optimization: For versions >= 0.5.0, only check top-level symlinks
143
+ to avoid expensive recursive directory scanning.
144
+ """
145
+ from packaging.version import Version
146
+
147
+ old_patterns = [
148
+ "/config/AGENTS.md",
149
+ "/config/claude/",
150
+ "/config/goose/",
151
+ "/config/chat_agent_hints.md",
152
+ ]
153
+
154
+ broken_symlinks = []
155
+
156
+ check_paths = [
157
+ Path.home() / ".claude",
158
+ Path.home() / ".config" / "goose",
159
+ Path.home() / "AGENTS.md",
160
+ ]
161
+
162
+ try:
163
+ use_fast_check = Version(__version__) >= Version("0.5.0")
164
+ except Exception:
165
+ use_fast_check = False
166
+
167
+ for base_path in check_paths:
168
+ if not base_path.exists():
169
+ continue
170
+
171
+ if base_path.is_symlink():
172
+ try:
173
+ target = os.readlink(str(base_path))
174
+ if any(pattern in str(target) for pattern in old_patterns):
175
+ if not base_path.resolve().exists():
176
+ broken_symlinks.append((base_path, Path(target)))
177
+ except (OSError, ValueError):
178
+ pass
179
+
180
+ if base_path.is_dir() and not use_fast_check:
181
+ for item in base_path.rglob("*"):
182
+ if item.is_symlink():
183
+ try:
184
+ target = os.readlink(str(item))
185
+ if any(pattern in str(target) for pattern in old_patterns):
186
+ if not item.resolve().exists():
187
+ broken_symlinks.append((item, Path(target)))
188
+ except (OSError, ValueError):
189
+ pass
190
+
191
+ return broken_symlinks
192
+
193
+
194
+ def select_agents(all_agents: list[Agent], filter_string: str | None) -> list[Agent]:
195
+ """Select agents based on filter string.
196
+
197
+ Args:
198
+ all_agents: List of all available agents
199
+ filter_string: Comma-separated agent IDs (e.g., "claude,goose") or None for all
200
+
201
+ Returns:
202
+ List of selected agents
203
+
204
+ Raises:
205
+ SystemExit: If no agents match the filter
206
+ """
207
+ if not filter_string:
208
+ return all_agents
209
+
210
+ requested_ids = {a.strip() for a in filter_string.split(",") if a.strip()}
211
+ selected = [agent for agent in all_agents if agent.agent_id in requested_ids]
212
+
213
+ if not selected:
214
+ invalid_ids = requested_ids - {a.agent_id for a in all_agents}
215
+ available_ids = [a.agent_id for a in all_agents]
216
+ console.print(
217
+ f"[red]Error:[/red] Invalid agent ID(s): {', '.join(sorted(invalid_ids))}\n"
218
+ f"[dim]Available agents: {', '.join(available_ids)}[/dim]"
219
+ )
220
+ sys.exit(1)
221
+
222
+ return selected
223
+
224
+
225
+ def format_summary(
226
+ dry_run: bool,
227
+ created: int,
228
+ updated: int,
229
+ skipped: int,
230
+ excluded: int = 0,
231
+ errors: int = 0,
232
+ ) -> None:
233
+ """Format and print operation summary.
234
+
235
+ Args:
236
+ dry_run: Whether this was a dry run
237
+ created: Number of symlinks created
238
+ updated: Number of symlinks updated
239
+ skipped: Number of symlinks skipped
240
+ excluded: Number of symlinks excluded by config
241
+ errors: Number of errors encountered
242
+ """
243
+ console.print()
244
+ if dry_run:
245
+ console.print(
246
+ f"[bold]Summary:[/bold] Would create {created}, "
247
+ f"update {updated}, skip {skipped}"
248
+ )
249
+ else:
250
+ console.print(
251
+ f"[bold]Summary:[/bold] Created {created}, "
252
+ f"updated {updated}, skipped {skipped}"
253
+ )
254
+
255
+ if excluded > 0:
256
+ console.print(f" ({excluded} excluded by config)")
257
+
258
+ if errors > 0:
259
+ console.print(f" [red]{errors} error(s)[/red]")
260
+
261
+
262
+ def _display_pending_symlink_changes(agents: list[Agent]) -> bool:
263
+ """Display what symlink changes will be made.
264
+
265
+ Returns:
266
+ True if changes were found and displayed, False otherwise
267
+ """
268
+ from ai_rules.symlinks import check_symlink
269
+
270
+ found_changes = False
271
+
272
+ for agent in agents:
273
+ agent_changes = []
274
+ for target, source in agent.get_filtered_symlinks():
275
+ target_path = target.expanduser()
276
+ status_code, _ = check_symlink(target_path, source)
277
+
278
+ if status_code == "correct":
279
+ continue
280
+
281
+ found_changes = True
282
+ if status_code == "missing":
283
+ agent_changes.append(("create", target_path, source))
284
+ elif status_code in ["wrong_target", "broken", "not_symlink"]:
285
+ agent_changes.append(("update", target_path, source))
286
+
287
+ if agent_changes:
288
+ console.print(f"\n[bold]{agent.name}[/bold]")
289
+ for action, target, source in agent_changes:
290
+ if action == "create":
291
+ console.print(f" [green]+[/green] Create: {target} → {source}")
292
+ else:
293
+ console.print(f" [yellow]↻[/yellow] Update: {target} → {source}")
294
+
295
+ return found_changes
296
+
297
+
298
+ def check_first_run(agents: list[Agent], force: bool) -> bool:
299
+ """Check if this is the first run and prompt user if needed.
300
+
301
+ Returns:
302
+ True if should continue, False if should abort
303
+ """
304
+ existing_files = []
305
+
306
+ for agent in agents:
307
+ for target, _ in agent.get_filtered_symlinks():
308
+ target_path = target.expanduser()
309
+ if target_path.exists() and not target_path.is_symlink():
310
+ existing_files.append((agent.name, target_path))
311
+
312
+ if not existing_files:
313
+ return True
314
+
315
+ if force:
316
+ return True
317
+
318
+ console.print("\n[yellow]Warning:[/yellow] Found existing configuration files:\n")
319
+ for agent_name, path in existing_files:
320
+ console.print(f" [{agent_name}] {path}")
321
+
322
+ console.print(
323
+ "\n[dim]These will be replaced with symlinks (originals will be backed up).[/dim]\n"
324
+ )
325
+
326
+ return Confirm.ask("Continue?", default=False)
327
+
328
+
329
+ def _background_update_check() -> None:
330
+ """Run update check in background thread for all registered tools.
331
+
332
+ This runs silently and saves results for display on next CLI invocation.
333
+ """
334
+ from datetime import datetime
335
+
336
+ from ai_rules.bootstrap import (
337
+ UPDATABLE_TOOLS,
338
+ check_tool_updates,
339
+ load_auto_update_config,
340
+ save_auto_update_config,
341
+ save_pending_update,
342
+ )
343
+
344
+ try:
345
+ for tool in UPDATABLE_TOOLS:
346
+ update_info = check_tool_updates(tool)
347
+ if update_info and update_info.has_update:
348
+ save_pending_update(update_info, tool.tool_id)
349
+
350
+ config = load_auto_update_config()
351
+ config.last_check = datetime.now().isoformat()
352
+ save_auto_update_config(config)
353
+ except Exception as e:
354
+ logger.debug(f"Background update check failed: {e}")
355
+
356
+
357
+ def _is_interactive_context() -> bool:
358
+ """Determine if we're in an interactive CLI context.
359
+
360
+ Returns False when prompts should be suppressed:
361
+ - Click is in resilient_parsing mode (--help, shell completion)
362
+ - Non-interactive flags are present (--dry-run, --help, -h)
363
+ - stdin/stdout are not TTYs (piped or scripted)
364
+ """
365
+ import sys
366
+
367
+ try:
368
+ ctx = click.get_current_context(silent=True)
369
+ if ctx and ctx.resilient_parsing:
370
+ return False
371
+ except RuntimeError:
372
+ pass
373
+
374
+ if _NON_INTERACTIVE_FLAGS & set(sys.argv):
375
+ return False
376
+
377
+ return sys.stdin.isatty() and sys.stdout.isatty()
378
+
379
+
380
+ def _check_pending_updates() -> None:
381
+ """Check for and display pending update notifications.
382
+
383
+ Shows interactive prompt in normal TTY contexts, non-interactive message
384
+ for --help, --dry-run, or non-TTY contexts.
385
+ """
386
+ from ai_rules.bootstrap import (
387
+ clear_all_pending_updates,
388
+ get_tool_by_id,
389
+ load_all_pending_updates,
390
+ )
391
+
392
+ try:
393
+ pending = load_all_pending_updates()
394
+ if not pending:
395
+ return
396
+
397
+ updates = []
398
+ for tid, info in pending.items():
399
+ tool = get_tool_by_id(tid)
400
+ if tool:
401
+ updates.append(
402
+ f"{tool.display_name} {info.current_version} → {info.latest_version}"
403
+ )
404
+
405
+ if not updates:
406
+ return
407
+
408
+ update_label = "Update available" if len(updates) == 1 else "Updates available"
409
+ console.print(f"\n[cyan]{update_label}:[/cyan] {', '.join(updates)}")
410
+
411
+ if _is_interactive_context():
412
+ prompt = "Install now?" if len(updates) == 1 else "Install all updates?"
413
+ if Confirm.ask(prompt, default=False):
414
+ ctx = click.get_current_context()
415
+ ctx.invoke(
416
+ upgrade, check=False, force=False, skip_install=False, only=None
417
+ )
418
+ else:
419
+ console.print("[dim]Run 'ai-rules upgrade' when ready[/dim]")
420
+ else:
421
+ console.print("[dim]Run 'ai-rules upgrade' to install[/dim]")
422
+
423
+ clear_all_pending_updates()
424
+ except Exception as e:
425
+ logger.debug(f"Failed to check pending updates: {e}")
426
+
427
+
428
+ def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> None:
429
+ """Custom version callback that also checks for updates.
430
+
431
+ Args:
432
+ ctx: Click context
433
+ param: Click parameter
434
+ value: Whether --version flag was provided
435
+ """
436
+ if not value or ctx.resilient_parsing:
437
+ return
438
+
439
+ console.print(f"ai-rules, version {__version__}")
440
+
441
+ try:
442
+ from ai_rules.bootstrap import check_tool_updates, get_tool_by_id
443
+
444
+ tool = get_tool_by_id("ai-rules")
445
+ if tool:
446
+ update_info = check_tool_updates(tool, timeout=3)
447
+ if update_info and update_info.has_update:
448
+ console.print(
449
+ f"\n[cyan]Update available:[/cyan] {update_info.current_version} → {update_info.latest_version}"
450
+ )
451
+ console.print("[dim]Run 'ai-rules upgrade' to install[/dim]")
452
+ except Exception as e:
453
+ logger.debug(f"Failed to check for updates in version callback: {e}")
454
+
455
+ ctx.exit()
456
+
457
+
458
+ @click.group()
459
+ @click.option(
460
+ "--version",
461
+ is_flag=True,
462
+ callback=version_callback,
463
+ expose_value=False,
464
+ is_eager=True,
465
+ help="Show version and check for updates",
466
+ )
467
+ def main() -> None:
468
+ """AI Rules - Manage user-level AI agent configurations."""
469
+ import threading
470
+
471
+ from ai_rules.bootstrap import load_auto_update_config, should_check_now
472
+
473
+ _check_pending_updates()
474
+
475
+ try:
476
+ import os
477
+
478
+ if "PYTEST_CURRENT_TEST" not in os.environ:
479
+ config = load_auto_update_config()
480
+ if config.enabled and should_check_now(config):
481
+ thread = threading.Thread(target=_background_update_check, daemon=True)
482
+ thread.start()
483
+ except Exception:
484
+ pass
485
+
486
+
487
+ def cleanup_deprecated_symlinks(
488
+ selected_agents: list[Agent], config_dir: Path, force: bool, dry_run: bool
489
+ ) -> int:
490
+ """Remove deprecated symlinks that point to our config files.
491
+
492
+ Args:
493
+ selected_agents: List of agents to check for deprecated symlinks
494
+ config_dir: Path to the config directory (repo/config)
495
+ force: Skip confirmation prompts
496
+ dry_run: Don't actually remove symlinks
497
+
498
+ Returns:
499
+ Count of removed symlinks
500
+ """
501
+ removed_count = 0
502
+ agents_md = config_dir / "AGENTS.md"
503
+
504
+ for agent in selected_agents:
505
+ deprecated_paths = agent.get_deprecated_symlinks()
506
+
507
+ for deprecated_path in deprecated_paths:
508
+ target = deprecated_path.expanduser()
509
+
510
+ if not target.exists() and not target.is_symlink():
511
+ continue
512
+
513
+ if not target.is_symlink():
514
+ continue
515
+
516
+ try:
517
+ resolved = target.resolve()
518
+ if resolved != agents_md:
519
+ continue
520
+ except (OSError, RuntimeError):
521
+ continue
522
+
523
+ if dry_run:
524
+ console.print(
525
+ f" [yellow]Would remove deprecated:[/yellow] {deprecated_path}"
526
+ )
527
+ removed_count += 1
528
+ else:
529
+ success, message = remove_symlink(target, force=True)
530
+ if success:
531
+ console.print(
532
+ f" [dim]Cleaned up deprecated symlink:[/dim] {deprecated_path}"
533
+ )
534
+ removed_count += 1
535
+
536
+ return removed_count
537
+
538
+
539
+ def install_user_symlinks(
540
+ selected_agents: list[Agent], force: bool, dry_run: bool
541
+ ) -> dict[str, int]:
542
+ """Install user-level symlinks for all selected agents.
543
+
544
+ Returns dict with keys: created, updated, skipped, excluded, errors
545
+ """
546
+ console.print("[bold cyan]User-Level Configuration[/bold cyan]")
547
+
548
+ if selected_agents:
549
+ config_dir = selected_agents[0].config_dir
550
+ cleanup_deprecated_symlinks(selected_agents, config_dir, force, dry_run)
551
+
552
+ created = updated = skipped = excluded = errors = 0
553
+
554
+ for agent in selected_agents:
555
+ console.print(f"\n[bold]{agent.name}[/bold]")
556
+
557
+ filtered_symlinks = agent.get_filtered_symlinks()
558
+ excluded_count = len(agent.symlinks) - len(filtered_symlinks)
559
+
560
+ if excluded_count > 0:
561
+ console.print(
562
+ f" [dim]({excluded_count} symlink(s) excluded by config)[/dim]"
563
+ )
564
+ excluded += excluded_count
565
+
566
+ for target, source in filtered_symlinks:
567
+ effective_force = force or not dry_run
568
+ result, message = create_symlink(target, source, effective_force, dry_run)
569
+
570
+ if result == SymlinkResult.CREATED:
571
+ console.print(f" [green]✓[/green] {target} → {source}")
572
+ created += 1
573
+ elif result == SymlinkResult.ALREADY_CORRECT:
574
+ console.print(f" [dim]•[/dim] {target} [dim](already correct)[/dim]")
575
+ elif result == SymlinkResult.UPDATED:
576
+ console.print(f" [yellow]↻[/yellow] {target} → {source}")
577
+ updated += 1
578
+ elif result == SymlinkResult.SKIPPED:
579
+ console.print(f" [yellow]○[/yellow] {target} [dim](skipped)[/dim]")
580
+ skipped += 1
581
+ elif result == SymlinkResult.ERROR:
582
+ console.print(f" [red]✗[/red] {target}: {message}")
583
+ errors += 1
584
+
585
+ return {
586
+ "created": created,
587
+ "updated": updated,
588
+ "skipped": skipped,
589
+ "excluded": excluded,
590
+ "errors": errors,
591
+ }
592
+
593
+
594
+ @main.command()
595
+ @click.option("--force", is_flag=True, help="Skip confirmation prompts")
596
+ @click.option("--dry-run", is_flag=True, help="Show what would be done")
597
+ @click.option("--skip-symlinks", is_flag=True, help="Skip symlink installation step")
598
+ @click.option("--skip-completions", is_flag=True, help="Skip shell completion setup")
599
+ @click.pass_context
600
+ def setup(
601
+ ctx: click.Context,
602
+ force: bool,
603
+ dry_run: bool,
604
+ skip_symlinks: bool,
605
+ skip_completions: bool,
606
+ ) -> None:
607
+ """One-time setup: install symlinks and make ai-rules available system-wide.
608
+
609
+ This is the recommended way to install ai-rules for first-time users.
610
+
611
+ Example:
612
+ uvx ai-agent-rules setup
613
+ """
614
+ from ai_rules.bootstrap import (
615
+ ensure_statusline_installed,
616
+ get_tool_config_dir,
617
+ install_tool,
618
+ )
619
+
620
+ statusline_result = ensure_statusline_installed(dry_run=dry_run)
621
+ if statusline_result == "installed":
622
+ console.print("[green]✓[/green] Installed claude-statusline\n")
623
+ elif statusline_result == "failed":
624
+ console.print(
625
+ "[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)\n"
626
+ )
627
+
628
+ console.print("[bold cyan]Step 1/3: Install ai-rules system-wide[/bold cyan]")
629
+ console.print("This allows you to run 'ai-rules' from any directory.\n")
630
+
631
+ if not force:
632
+ if not Confirm.ask("Install ai-rules permanently?", default=True):
633
+ console.print(
634
+ "\n[yellow]Skipped.[/yellow] You can still run via: uvx ai-rules <command>"
635
+ )
636
+ return
637
+
638
+ tool_install_success = False
639
+ try:
640
+ success, message = install_tool("ai-agent-rules", force=force, dry_run=dry_run)
641
+
642
+ if dry_run:
643
+ console.print(f"\n[dim]{message}[/dim]")
644
+ tool_install_success = True
645
+ elif success:
646
+ console.print("[green]✓ Tool installed successfully[/green]\n")
647
+ tool_install_success = True
648
+ else:
649
+ console.print(f"\n[red]Error:[/red] {message}")
650
+ console.print("\n[yellow]Manual installation:[/yellow]")
651
+ console.print(" uv tool install ai-agent-rules")
652
+ return
653
+ except Exception as e:
654
+ console.print(f"\n[red]Error:[/red] {e}")
655
+ return
656
+
657
+ if not skip_symlinks:
658
+ console.print(
659
+ "[bold cyan]Step 2/3: Installing AI agent configuration symlinks[/bold cyan]\n"
660
+ )
661
+
662
+ config_dir_override = None
663
+ if tool_install_success and not dry_run:
664
+ try:
665
+ tool_config_dir = get_tool_config_dir("ai-agent-rules")
666
+ if tool_config_dir.exists():
667
+ config_dir_override = str(tool_config_dir)
668
+ else:
669
+ console.print(
670
+ f"[yellow]Warning:[/yellow] Tool config not found at expected location: {tool_config_dir}"
671
+ )
672
+ console.print(
673
+ "[dim]Falling back to current config directory[/dim]\n"
674
+ )
675
+ except Exception as e:
676
+ console.print(
677
+ f"[yellow]Warning:[/yellow] Could not determine tool config path: {e}"
678
+ )
679
+ console.print("[dim]Falling back to current config directory[/dim]\n")
680
+
681
+ ctx.invoke(
682
+ install,
683
+ force=force,
684
+ dry_run=dry_run,
685
+ rebuild_cache=False,
686
+ agents=None,
687
+ skip_completions=True,
688
+ config_dir_override=config_dir_override,
689
+ )
690
+
691
+ if not skip_completions:
692
+ from ai_rules.completions import (
693
+ detect_shell,
694
+ get_supported_shells,
695
+ install_completion,
696
+ )
697
+
698
+ console.print("\n[bold cyan]Step 3/3: Shell completion setup[/bold cyan]\n")
699
+
700
+ shell = detect_shell()
701
+ if shell:
702
+ if force or Confirm.ask(f"Install {shell} tab completion?", default=True):
703
+ success, msg = install_completion(shell, dry_run=dry_run)
704
+ if success:
705
+ console.print(f"[green]✓[/green] {msg}")
706
+ else:
707
+ console.print(f"[yellow]⚠[/yellow] {msg}")
708
+ else:
709
+ supported = ", ".join(get_supported_shells())
710
+ console.print(
711
+ f"[dim]Shell completion not available for your shell (only {supported} supported)[/dim]"
712
+ )
713
+
714
+ console.print("\n[green]✓ Setup complete![/green]")
715
+ console.print("You can now run [bold]ai-rules[/bold] from anywhere.")
716
+
717
+
718
+ @main.command()
719
+ @click.option("--force", is_flag=True, help="Skip all confirmations")
720
+ @click.option("--dry-run", is_flag=True, help="Preview changes without applying")
721
+ @click.option(
722
+ "--rebuild-cache",
723
+ is_flag=True,
724
+ help="Rebuild merged settings cache (use after changing overrides)",
725
+ )
726
+ @click.option(
727
+ "--agents",
728
+ help="Comma-separated list of agents to install (default: all)",
729
+ shell_complete=complete_agents,
730
+ )
731
+ @click.option(
732
+ "--skip-completions",
733
+ is_flag=True,
734
+ help="Skip shell completion installation",
735
+ )
736
+ @click.option(
737
+ "--config-dir",
738
+ "config_dir_override",
739
+ hidden=True,
740
+ help="Override config directory (internal use)",
741
+ )
742
+ def install(
743
+ force: bool,
744
+ dry_run: bool,
745
+ rebuild_cache: bool,
746
+ agents: str | None,
747
+ skip_completions: bool,
748
+ config_dir_override: str | None = None,
749
+ ) -> None:
750
+ """Install AI agent configs via symlinks."""
751
+ from ai_rules.bootstrap import ensure_statusline_installed
752
+
753
+ statusline_result = ensure_statusline_installed(dry_run=dry_run)
754
+ if statusline_result == "installed":
755
+ console.print("[green]✓[/green] Installed claude-statusline\n")
756
+ elif statusline_result == "failed":
757
+ console.print(
758
+ "[yellow]⚠[/yellow] Failed to install claude-statusline (continuing anyway)\n"
759
+ )
760
+
761
+ if config_dir_override:
762
+ config_dir = Path(config_dir_override)
763
+ if not config_dir.exists():
764
+ console.print(f"[red]Error:[/red] Config directory not found: {config_dir}")
765
+ sys.exit(1)
766
+ else:
767
+ config_dir = get_config_dir()
768
+
769
+ config = Config.load()
770
+
771
+ if rebuild_cache and not dry_run:
772
+ import shutil
773
+
774
+ cache_dir = Config.get_cache_dir()
775
+ if cache_dir.exists():
776
+ shutil.rmtree(cache_dir)
777
+ console.print("[dim]✓ Cleared settings cache[/dim]")
778
+
779
+ if not dry_run:
780
+ claude_settings = config_dir / "claude" / "settings.json"
781
+ if claude_settings.exists():
782
+ config.build_merged_settings(
783
+ "claude", claude_settings, force_rebuild=rebuild_cache
784
+ )
785
+
786
+ goose_settings = config_dir / "goose" / "config.yaml"
787
+ if goose_settings.exists():
788
+ config.build_merged_settings(
789
+ "goose", goose_settings, force_rebuild=rebuild_cache
790
+ )
791
+
792
+ all_agents = get_agents(config_dir, config)
793
+ selected_agents = select_agents(all_agents, agents)
794
+
795
+ if not dry_run:
796
+ old_symlinks = detect_old_config_symlinks()
797
+ if old_symlinks:
798
+ console.print(
799
+ "\n[yellow]Detected config migration from v0.4.1 → v0.5.0[/yellow]"
800
+ )
801
+ console.print(
802
+ f"Found {len(old_symlinks)} symlink(s) pointing to old config location"
803
+ )
804
+ console.print(
805
+ "[dim]Automatically migrating symlinks to new location...[/dim]\n"
806
+ )
807
+
808
+ for symlink_path, _old_target in old_symlinks:
809
+ try:
810
+ symlink_path.unlink()
811
+ console.print(f" [dim]✓ Removed old symlink: {symlink_path}[/dim]")
812
+ except Exception as e:
813
+ console.print(
814
+ f" [yellow]⚠ Could not remove {symlink_path}: {e}[/yellow]"
815
+ )
816
+
817
+ console.print("\n[green]✓ Migration complete[/green]")
818
+ console.print("[dim]New symlinks will be created below...[/dim]\n")
819
+
820
+ if not dry_run and not force:
821
+ has_changes = _display_pending_symlink_changes(selected_agents)
822
+
823
+ if has_changes:
824
+ console.print()
825
+ if not Confirm.ask("Apply these changes?", default=True):
826
+ console.print("[yellow]Installation cancelled[/yellow]")
827
+ sys.exit(0)
828
+ elif not has_changes:
829
+ if not check_first_run(selected_agents, force):
830
+ console.print("[yellow]Installation cancelled[/yellow]")
831
+ sys.exit(0)
832
+ elif not dry_run and force:
833
+ pass
834
+
835
+ if dry_run:
836
+ console.print("[bold]Dry run mode - no changes will be made[/bold]\n")
837
+
838
+ if not dry_run:
839
+ orphaned = config.cleanup_orphaned_cache()
840
+ if orphaned:
841
+ console.print(
842
+ f"[dim]✓ Cleaned up orphaned cache for: {', '.join(orphaned)}[/dim]"
843
+ )
844
+
845
+ user_results = install_user_symlinks(selected_agents, force, dry_run)
846
+
847
+ claude_agent = next((a for a in selected_agents if a.agent_id == "claude"), None)
848
+ if claude_agent and isinstance(claude_agent, ClaudeAgent):
849
+ from ai_rules.mcp import MCPManager, OperationResult
850
+
851
+ result, message, conflicts = claude_agent.install_mcps(
852
+ force=force, dry_run=dry_run
853
+ )
854
+
855
+ if conflicts and not force:
856
+ console.print("\n[bold yellow]MCP Conflicts Detected:[/bold yellow]")
857
+ mgr = MCPManager()
858
+ expected_mcps = mgr.load_managed_mcps(config_dir, config)
859
+ claude_data = mgr.claude_json
860
+ installed_mcps = claude_data.get("mcpServers", {})
861
+
862
+ for conflict_name in conflicts:
863
+ expected = expected_mcps.get(conflict_name, {})
864
+ installed = installed_mcps.get(conflict_name, {})
865
+ if expected and installed:
866
+ diff = mgr.format_diff(conflict_name, expected, installed)
867
+ console.print(f"\n{diff}\n")
868
+
869
+ if not dry_run and not Confirm.ask(
870
+ "Overwrite local changes?", default=False
871
+ ):
872
+ console.print("[yellow]Skipped MCP installation[/yellow]")
873
+ else:
874
+ result, message, _ = claude_agent.install_mcps(
875
+ force=True, dry_run=dry_run
876
+ )
877
+ console.print(f"[green]✓[/green] {message}")
878
+ elif result == OperationResult.UPDATED:
879
+ console.print(f"[green]✓[/green] {message}")
880
+ elif result == OperationResult.ALREADY_SYNCED:
881
+ console.print(f"[dim]○[/dim] {message}")
882
+ elif result != OperationResult.NOT_FOUND:
883
+ console.print(f"[yellow]⚠[/yellow] {message}")
884
+
885
+ total_created = user_results["created"]
886
+ total_updated = user_results["updated"]
887
+ total_skipped = user_results["skipped"]
888
+ total_excluded = user_results["excluded"]
889
+ total_errors = user_results["errors"]
890
+ if not dry_run:
891
+ try:
892
+ git_repo_root = get_git_repo_root()
893
+ hooks_dir = git_repo_root / ".hooks"
894
+ post_merge_hook = hooks_dir / "post-merge"
895
+ git_dir = git_repo_root / ".git"
896
+
897
+ if post_merge_hook.exists() and git_dir.is_dir():
898
+ import subprocess
899
+
900
+ try:
901
+ subprocess.run(
902
+ ["git", "config", "core.hooksPath", ".hooks"],
903
+ cwd=git_repo_root,
904
+ check=True,
905
+ capture_output=True,
906
+ timeout=GIT_SUBPROCESS_TIMEOUT,
907
+ )
908
+ console.print("\n[dim]✓ Configured git hooks[/dim]")
909
+ except subprocess.CalledProcessError:
910
+ pass
911
+ except RuntimeError:
912
+ pass
913
+
914
+ if not skip_completions:
915
+ from ai_rules.completions import detect_shell, install_completion
916
+
917
+ shell = detect_shell()
918
+ if shell:
919
+ success, msg = install_completion(shell, dry_run=dry_run)
920
+ if success and not dry_run:
921
+ console.print(f"\n[dim]✓ {msg}[/dim]")
922
+
923
+ format_summary(
924
+ dry_run,
925
+ total_created,
926
+ total_updated,
927
+ total_skipped,
928
+ total_excluded,
929
+ total_errors,
930
+ )
931
+
932
+ if total_errors > 0:
933
+ sys.exit(1)
934
+
935
+
936
+ def _display_symlink_status(
937
+ status_code: str,
938
+ target: Path,
939
+ source: Path,
940
+ message: str,
941
+ agent_label: str | None = None,
942
+ ) -> bool:
943
+ """Display symlink status with consistent formatting.
944
+
945
+ Args:
946
+ status_code: Status code from check_symlink()
947
+ target: Target path to display
948
+ source: Source path (to check if it's a directory)
949
+ message: Status message
950
+ agent_label: Optional agent label for project-level display
951
+
952
+ Returns:
953
+ True if status is correct, False otherwise
954
+ """
955
+ target_str = str(target)
956
+ if source.is_dir():
957
+ target_str = target_str.rstrip("/") + "/"
958
+ target_display = f"{agent_label} {target.name}" if agent_label else target_str
959
+
960
+ if status_code == "correct":
961
+ console.print(f" [green]✓[/green] {target_display}")
962
+ return True
963
+ elif status_code == "missing":
964
+ console.print(f" [red]✗[/red] {target_display} [dim](not installed)[/dim]")
965
+ return False
966
+ elif status_code == "broken":
967
+ console.print(f" [red]✗[/red] {target_display} [dim](broken symlink)[/dim]")
968
+ return False
969
+ elif status_code == "wrong_target":
970
+ console.print(f" [yellow]⚠[/yellow] {target_display} [dim]({message})[/dim]")
971
+ return False
972
+ elif status_code == "not_symlink":
973
+ console.print(
974
+ f" [yellow]⚠[/yellow] {target_display} [dim](not a symlink)[/dim]"
975
+ )
976
+ return False
977
+ return True
978
+
979
+
980
+ @main.command()
981
+ @click.option(
982
+ "--agents",
983
+ help="Comma-separated list of agents to check (default: all)",
984
+ shell_complete=complete_agents,
985
+ )
986
+ def status(agents: str | None) -> None:
987
+ """Check status of AI agent symlinks."""
988
+ config_dir = get_config_dir()
989
+ config = Config.load()
990
+ all_agents = get_agents(config_dir, config)
991
+ selected_agents = select_agents(all_agents, agents)
992
+
993
+ console.print("[bold]AI Rules Status[/bold]\n")
994
+
995
+ all_correct = True
996
+
997
+ console.print("[bold cyan]User-Level Configuration[/bold cyan]\n")
998
+ cache_stale = False
999
+ for agent in selected_agents:
1000
+ console.print(f"[bold]{agent.name}:[/bold]")
1001
+
1002
+ all_symlinks = agent.symlinks
1003
+ filtered_symlinks = agent.get_filtered_symlinks()
1004
+ excluded_symlinks = [
1005
+ (t, s) for t, s in all_symlinks if (t, s) not in filtered_symlinks
1006
+ ]
1007
+
1008
+ for target, source in filtered_symlinks:
1009
+ status_code, message = check_symlink(target, source)
1010
+ is_correct = _display_symlink_status(status_code, target, source, message)
1011
+ if not is_correct:
1012
+ all_correct = False
1013
+
1014
+ for target, _ in excluded_symlinks:
1015
+ console.print(f" [dim]○[/dim] {target} [dim](excluded by config)[/dim]")
1016
+ agent_config = AGENT_CONFIG_METADATA.get(agent.agent_id)
1017
+ if agent_config and agent.agent_id in config.settings_overrides:
1018
+ base_settings_path = (
1019
+ config_dir / agent.agent_id / agent_config["config_file"]
1020
+ )
1021
+ if config.is_cache_stale(agent.agent_id, base_settings_path):
1022
+ console.print(" [yellow]⚠[/yellow] Cached settings are stale")
1023
+ diff_output = config.get_cache_diff(agent.agent_id, base_settings_path)
1024
+ if diff_output:
1025
+ console.print(diff_output)
1026
+ all_correct = False
1027
+ cache_stale = True
1028
+
1029
+ if isinstance(agent, ClaudeAgent):
1030
+ mcp_status = agent.get_mcp_status()
1031
+ if (
1032
+ mcp_status.managed_mcps
1033
+ or mcp_status.unmanaged_mcps
1034
+ or mcp_status.pending_mcps
1035
+ or mcp_status.stale_mcps
1036
+ ):
1037
+ console.print(" [bold]MCPs:[/bold]")
1038
+ for name in sorted(mcp_status.managed_mcps.keys()):
1039
+ synced = mcp_status.synced.get(name, False)
1040
+ has_override = mcp_status.has_overrides.get(name, False)
1041
+ status_text = (
1042
+ "[green]Synced[/green]"
1043
+ if synced
1044
+ else "[yellow]Outdated[/yellow]"
1045
+ )
1046
+ override_text = ", override" if has_override else ""
1047
+ console.print(
1048
+ f" {name:<20} {status_text} [dim](managed{override_text})[/dim]"
1049
+ )
1050
+ if not synced:
1051
+ all_correct = False
1052
+ for name in sorted(mcp_status.pending_mcps.keys()):
1053
+ has_override = mcp_status.has_overrides.get(name, False)
1054
+ override_text = ", override" if has_override else ""
1055
+ console.print(
1056
+ f" {name:<20} [yellow]Not installed[/yellow] [dim](managed{override_text})[/dim]"
1057
+ )
1058
+ all_correct = False
1059
+ for name in sorted(mcp_status.stale_mcps.keys()):
1060
+ console.print(
1061
+ f" {name:<20} [red]Should be removed[/red] [dim](no longer in config)[/dim]"
1062
+ )
1063
+ all_correct = False
1064
+ for name in sorted(mcp_status.unmanaged_mcps.keys()):
1065
+ console.print(f" {name:<20} [dim]Unmanaged[/dim]")
1066
+
1067
+ console.print()
1068
+
1069
+ console.print("[bold cyan]Git Hooks Configuration[/bold cyan]\n")
1070
+ try:
1071
+ git_repo_root = get_git_repo_root()
1072
+ hooks_dir = git_repo_root / ".hooks"
1073
+ post_merge_hook = hooks_dir / "post-merge"
1074
+
1075
+ if post_merge_hook.exists() and (git_repo_root / ".git").is_dir():
1076
+ import subprocess
1077
+
1078
+ try:
1079
+ result = subprocess.run(
1080
+ ["git", "config", "--get", "core.hooksPath"],
1081
+ cwd=git_repo_root,
1082
+ capture_output=True,
1083
+ text=True,
1084
+ check=False,
1085
+ )
1086
+ configured_path = result.stdout.strip()
1087
+ if configured_path == ".hooks":
1088
+ console.print(" [green]✓[/green] Post-merge hook configured")
1089
+ else:
1090
+ console.print(
1091
+ " [red]✗[/red] Post-merge hook not configured\n"
1092
+ " [dim]Run 'uv run ai-rules install' to enable automatic reminders[/dim]"
1093
+ )
1094
+ all_correct = False
1095
+ except Exception:
1096
+ console.print(
1097
+ " [red]✗[/red] Post-merge hook not configured\n"
1098
+ " [dim]Run 'uv run ai-rules install' to enable automatic reminders[/dim]"
1099
+ )
1100
+ all_correct = False
1101
+ else:
1102
+ console.print(
1103
+ " [dim]○[/dim] Post-merge hook not available in this repository"
1104
+ )
1105
+ except RuntimeError:
1106
+ console.print(
1107
+ " [dim]○[/dim] Not in a git repository - git hooks not applicable"
1108
+ )
1109
+
1110
+ console.print()
1111
+
1112
+ console.print("[bold cyan]Optional Tools[/bold cyan]\n")
1113
+ from ai_rules.bootstrap import is_command_available
1114
+
1115
+ statusline_missing = False
1116
+ if is_command_available("claude-statusline"):
1117
+ console.print(" [green]✓[/green] claude-statusline installed")
1118
+ else:
1119
+ console.print(" [yellow]○[/yellow] claude-statusline not installed")
1120
+ statusline_missing = True
1121
+
1122
+ console.print()
1123
+
1124
+ console.print("[bold cyan]Shell Completions[/bold cyan]\n")
1125
+ from ai_rules.completions import (
1126
+ detect_shell,
1127
+ find_config_file,
1128
+ get_supported_shells,
1129
+ is_completion_installed,
1130
+ )
1131
+
1132
+ shell = detect_shell()
1133
+ if shell:
1134
+ config_path = find_config_file(shell)
1135
+ if config_path and is_completion_installed(config_path):
1136
+ console.print(
1137
+ f" [green]✓[/green] {shell} completion installed ({config_path})"
1138
+ )
1139
+ else:
1140
+ console.print(
1141
+ f" [yellow]○[/yellow] {shell} completion not installed "
1142
+ "(run: ai-rules completions install)"
1143
+ )
1144
+ else:
1145
+ supported = ", ".join(get_supported_shells())
1146
+ console.print(
1147
+ f" [dim]Shell completion not available for your shell (only {supported} supported)[/dim]"
1148
+ )
1149
+
1150
+ console.print()
1151
+
1152
+ if not all_correct:
1153
+ if cache_stale:
1154
+ console.print(
1155
+ "[yellow]💡 Run 'ai-rules install --rebuild-cache' to fix issues[/yellow]"
1156
+ )
1157
+ else:
1158
+ console.print("[yellow]💡 Run 'ai-rules install' to fix issues[/yellow]")
1159
+ sys.exit(1)
1160
+ elif statusline_missing:
1161
+ console.print("[green]All symlinks are correct![/green]")
1162
+ console.print(
1163
+ "[yellow]💡 Run 'ai-rules install' to install optional tools[/yellow]"
1164
+ )
1165
+ else:
1166
+ console.print("[green]All symlinks are correct![/green]")
1167
+
1168
+
1169
+ @main.command()
1170
+ @click.option("--force", is_flag=True, help="Skip confirmations")
1171
+ @click.option(
1172
+ "--agents",
1173
+ help="Comma-separated list of agents to uninstall (default: all)",
1174
+ shell_complete=complete_agents,
1175
+ )
1176
+ def uninstall(force: bool, agents: str | None) -> None:
1177
+ """Remove AI agent symlinks."""
1178
+ config_dir = get_config_dir()
1179
+ config = Config.load()
1180
+ all_agents = get_agents(config_dir, config)
1181
+ selected_agents = select_agents(all_agents, agents)
1182
+
1183
+ if not force:
1184
+ console.print("[yellow]Warning:[/yellow] This will remove symlinks for:\n")
1185
+ console.print("[bold]Agents:[/bold]")
1186
+ for agent in selected_agents:
1187
+ console.print(f" • {agent.name}")
1188
+ console.print()
1189
+ if not Confirm.ask("Continue?", default=False):
1190
+ console.print("[yellow]Uninstall cancelled[/yellow]")
1191
+ sys.exit(0)
1192
+
1193
+ total_removed = 0
1194
+ total_skipped = 0
1195
+
1196
+ console.print("\n[bold cyan]User-Level Configuration[/bold cyan]")
1197
+ for agent in selected_agents:
1198
+ console.print(f"\n[bold]{agent.name}[/bold]")
1199
+
1200
+ for target, _ in agent.get_filtered_symlinks():
1201
+ success, message = remove_symlink(target, force)
1202
+
1203
+ if success:
1204
+ console.print(f" [green]✓[/green] {target} removed")
1205
+ total_removed += 1
1206
+ elif "Does not exist" in message:
1207
+ console.print(f" [dim]•[/dim] {target} [dim](not installed)[/dim]")
1208
+ else:
1209
+ console.print(f" [yellow]○[/yellow] {target} [dim]({message})[/dim]")
1210
+ total_skipped += 1
1211
+
1212
+ if isinstance(agent, ClaudeAgent):
1213
+ from ai_rules.mcp import OperationResult
1214
+
1215
+ result, message = agent.uninstall_mcps(force=force, dry_run=False)
1216
+ if result == OperationResult.REMOVED:
1217
+ console.print(f" [green]✓[/green] {message}")
1218
+ elif result == OperationResult.NOT_FOUND:
1219
+ console.print(f" [dim]•[/dim] {message}")
1220
+
1221
+ console.print(
1222
+ f"\n[bold]Summary:[/bold] Removed {total_removed}, skipped {total_skipped}"
1223
+ )
1224
+
1225
+
1226
+ @main.command("list-agents")
1227
+ def list_agents_cmd() -> None:
1228
+ """List available AI agents."""
1229
+ config_dir = get_config_dir()
1230
+ config = Config.load()
1231
+ agents = get_agents(config_dir, config)
1232
+
1233
+ table = Table(title="Available AI Agents", show_header=True)
1234
+ table.add_column("ID", style="cyan")
1235
+ table.add_column("Name", style="bold")
1236
+ table.add_column("Symlinks", justify="right")
1237
+ table.add_column("Status")
1238
+
1239
+ for agent in agents:
1240
+ all_symlinks = agent.symlinks
1241
+ filtered_symlinks = agent.get_filtered_symlinks()
1242
+ excluded_count = len(all_symlinks) - len(filtered_symlinks)
1243
+
1244
+ installed = 0
1245
+ for target, source in filtered_symlinks:
1246
+ status_code, _ = check_symlink(target, source)
1247
+ if status_code == "correct":
1248
+ installed += 1
1249
+
1250
+ total = len(filtered_symlinks)
1251
+ status = f"{installed}/{total} installed"
1252
+ if excluded_count > 0:
1253
+ status += f" ({excluded_count} excluded)"
1254
+
1255
+ table.add_row(agent.agent_id, agent.name, str(total), status)
1256
+
1257
+ console.print(table)
1258
+
1259
+
1260
+ @main.command()
1261
+ @click.option("--force", is_flag=True, help="Skip confirmations")
1262
+ def update(force: bool) -> None:
1263
+ """Re-sync symlinks (useful after adding new agents/commands)."""
1264
+ console.print("[bold]Updating AI Rules symlinks...[/bold]\n")
1265
+
1266
+ ctx = click.get_current_context()
1267
+ ctx.invoke(
1268
+ install,
1269
+ force=force,
1270
+ dry_run=False,
1271
+ rebuild_cache=False,
1272
+ agents=None,
1273
+ projects=None,
1274
+ user_only=False,
1275
+ )
1276
+
1277
+
1278
+ @main.command()
1279
+ @click.option("--check", is_flag=True, help="Check for updates without installing")
1280
+ @click.option("--force", is_flag=True, help="Force reinstall even if up to date")
1281
+ @click.option(
1282
+ "--skip-install",
1283
+ is_flag=True,
1284
+ help="Skip running 'install --rebuild-cache' after upgrade",
1285
+ )
1286
+ @click.option(
1287
+ "--only",
1288
+ type=click.Choice(["ai-rules", "statusline"]),
1289
+ help="Only upgrade specific tool",
1290
+ )
1291
+ def upgrade(check: bool, force: bool, skip_install: bool, only: str | None) -> None:
1292
+ """Upgrade ai-rules and related tools to the latest versions from PyPI.
1293
+
1294
+ Examples:
1295
+ ai-rules upgrade # Check and install all updates
1296
+ ai-rules upgrade --check # Only check for updates
1297
+ ai-rules upgrade --only=statusline # Only upgrade statusline tool
1298
+ """
1299
+ from ai_rules.bootstrap import (
1300
+ UPDATABLE_TOOLS,
1301
+ check_tool_updates,
1302
+ perform_pypi_update,
1303
+ )
1304
+
1305
+ tools = [t for t in UPDATABLE_TOOLS if only is None or t.tool_id == only]
1306
+ tools = [t for t in tools if t.is_installed()]
1307
+
1308
+ if not tools:
1309
+ if only:
1310
+ console.print(f"[yellow]⚠[/yellow] Tool '{only}' is not installed")
1311
+ else:
1312
+ console.print("[yellow]⚠[/yellow] No tools are installed")
1313
+ sys.exit(1)
1314
+
1315
+ tool_updates = []
1316
+ for tool in tools:
1317
+ try:
1318
+ current = tool.get_version()
1319
+ if current:
1320
+ console.print(
1321
+ f"[dim]{tool.display_name} current version: {current}[/dim]"
1322
+ )
1323
+ except Exception as e:
1324
+ console.print(
1325
+ f"[red]Error:[/red] Could not get {tool.display_name} version: {e}"
1326
+ )
1327
+ continue
1328
+
1329
+ with console.status(f"Checking {tool.display_name} for updates..."):
1330
+ try:
1331
+ update_info = check_tool_updates(tool)
1332
+ except Exception as e:
1333
+ console.print(
1334
+ f"[red]Error:[/red] Failed to check {tool.display_name} updates: {e}"
1335
+ )
1336
+ continue
1337
+
1338
+ if update_info and (update_info.has_update or force):
1339
+ tool_updates.append((tool, update_info))
1340
+ elif update_info and not update_info.has_update:
1341
+ console.print(
1342
+ f"[green]✓[/green] {tool.display_name} is already up to date!"
1343
+ )
1344
+
1345
+ console.print()
1346
+
1347
+ if not tool_updates and not force:
1348
+ console.print("[green]✓[/green] All tools are up to date!")
1349
+ return
1350
+
1351
+ if not check:
1352
+ for tool, update_info in tool_updates:
1353
+ if update_info.has_update:
1354
+ console.print(
1355
+ f"[cyan]Update available for {tool.display_name}:[/cyan] "
1356
+ f"{update_info.current_version} → {update_info.latest_version}"
1357
+ )
1358
+
1359
+ if check:
1360
+ if tool_updates:
1361
+ console.print("\nRun [bold]ai-rules upgrade[/bold] to install")
1362
+ return
1363
+
1364
+ if not force:
1365
+ if len(tool_updates) == 1:
1366
+ prompt = f"\nInstall {tool_updates[0][0].display_name} update?"
1367
+ else:
1368
+ prompt = f"\nInstall {len(tool_updates)} updates?"
1369
+ if not Confirm.ask(prompt, default=True):
1370
+ console.print("[yellow]Cancelled.[/yellow]")
1371
+ return
1372
+
1373
+ ai_rules_upgraded = False
1374
+ for tool, update_info in tool_updates:
1375
+ with console.status(f"Upgrading {tool.display_name}..."):
1376
+ try:
1377
+ success, msg, was_upgraded = perform_pypi_update(tool.package_name)
1378
+ except Exception as e:
1379
+ console.print(
1380
+ f"\n[red]Error:[/red] {tool.display_name} upgrade failed: {e}"
1381
+ )
1382
+ continue
1383
+
1384
+ if success:
1385
+ new_version = tool.get_version()
1386
+ if new_version == update_info.latest_version:
1387
+ console.print(
1388
+ f"[green]✓[/green] {tool.display_name} upgraded to {new_version}"
1389
+ )
1390
+ if tool.tool_id == "ai-rules":
1391
+ ai_rules_upgraded = True
1392
+ elif new_version == update_info.current_version:
1393
+ console.print(
1394
+ f"[yellow]⚠[/yellow] {tool.display_name} upgrade reported success but version unchanged ({new_version})"
1395
+ )
1396
+ else:
1397
+ console.print(
1398
+ f"[green]✓[/green] {tool.display_name} upgraded to {new_version}"
1399
+ )
1400
+ if tool.tool_id == "ai-rules":
1401
+ ai_rules_upgraded = True
1402
+ else:
1403
+ console.print(
1404
+ f"[red]Error:[/red] {tool.display_name} upgrade failed: {msg}"
1405
+ )
1406
+
1407
+ if ai_rules_upgraded and not skip_install:
1408
+ try:
1409
+ import subprocess
1410
+
1411
+ try:
1412
+ repo_root = get_repo_root()
1413
+ except Exception:
1414
+ console.print(
1415
+ "\n[dim]Not in ai-rules repository - skipping install[/dim]"
1416
+ )
1417
+ console.print(
1418
+ "[dim]Run 'ai-rules install --rebuild-cache' from repo to update[/dim]"
1419
+ )
1420
+ console.print(
1421
+ "[dim]Restart your terminal if the command doesn't work[/dim]"
1422
+ )
1423
+ return
1424
+
1425
+ console.print("\n[dim]Running 'ai-rules install --rebuild-cache'...[/dim]")
1426
+
1427
+ result = subprocess.run(
1428
+ ["ai-rules", "install", "--rebuild-cache"],
1429
+ capture_output=False,
1430
+ text=True,
1431
+ cwd=str(repo_root),
1432
+ timeout=30,
1433
+ )
1434
+
1435
+ if result.returncode == 0:
1436
+ console.print("[dim]✓ Install completed successfully[/dim]")
1437
+ else:
1438
+ console.print(
1439
+ f"[yellow]⚠[/yellow] Install failed with exit code {result.returncode}"
1440
+ )
1441
+ console.print(
1442
+ "[dim]Run 'ai-rules install --rebuild-cache' manually to retry[/dim]"
1443
+ )
1444
+ except subprocess.TimeoutExpired:
1445
+ console.print("[yellow]⚠[/yellow] Install timed out after 30 seconds")
1446
+ console.print(
1447
+ "[dim]Run 'ai-rules install --rebuild-cache' manually to retry[/dim]"
1448
+ )
1449
+ except Exception as e:
1450
+ console.print(f"[yellow]⚠[/yellow] Could not run install: {e}")
1451
+ console.print("[dim]Run 'ai-rules install --rebuild-cache' manually[/dim]")
1452
+
1453
+ console.print("[dim]Restart your terminal if the command doesn't work[/dim]")
1454
+
1455
+
1456
+ @main.command()
1457
+ @click.option(
1458
+ "--agents",
1459
+ help="Comma-separated list of agents to validate (default: all)",
1460
+ shell_complete=complete_agents,
1461
+ )
1462
+ def validate(agents: str | None) -> None:
1463
+ """Validate configuration and source files."""
1464
+ config_dir = get_config_dir()
1465
+ config = Config.load()
1466
+ all_agents = get_agents(config_dir, config)
1467
+ selected_agents = select_agents(all_agents, agents)
1468
+
1469
+ console.print("[bold]Validating AI Rules Configuration[/bold]\n")
1470
+
1471
+ all_valid = True
1472
+ total_checked = 0
1473
+ total_issues = 0
1474
+
1475
+ for agent in selected_agents:
1476
+ console.print(f"[bold]{agent.name}:[/bold]")
1477
+ agent_issues = []
1478
+
1479
+ for _target, source in agent.symlinks:
1480
+ total_checked += 1
1481
+
1482
+ if not source.exists():
1483
+ agent_issues.append((source, "Source file does not exist"))
1484
+ all_valid = False
1485
+ elif not source.is_file():
1486
+ agent_issues.append((source, "Source is not a file"))
1487
+ all_valid = False
1488
+ else:
1489
+ console.print(f" [green]✓[/green] {source.name}")
1490
+
1491
+ excluded_symlinks = [
1492
+ (t, s)
1493
+ for t, s in agent.symlinks
1494
+ if (t, s) not in agent.get_filtered_symlinks()
1495
+ ]
1496
+ if excluded_symlinks:
1497
+ console.print(
1498
+ f" [dim]({len(excluded_symlinks)} symlink(s) excluded by config)[/dim]"
1499
+ )
1500
+
1501
+ for path, issue in agent_issues:
1502
+ console.print(f" [red]✗[/red] {path}")
1503
+ console.print(f" [dim]{issue}[/dim]")
1504
+ total_issues += 1
1505
+
1506
+ console.print()
1507
+
1508
+ console.print(f"[bold]Summary:[/bold] Checked {total_checked} source file(s)")
1509
+
1510
+ if all_valid:
1511
+ console.print("[green]All source files are valid![/green]")
1512
+ else:
1513
+ console.print(f"[red]Found {total_issues} issue(s)[/red]")
1514
+ sys.exit(1)
1515
+
1516
+
1517
+ @main.command()
1518
+ @click.option(
1519
+ "--agents",
1520
+ help="Comma-separated list of agents to check (default: all)",
1521
+ shell_complete=complete_agents,
1522
+ )
1523
+ def diff(agents: str | None) -> None:
1524
+ """Show differences between repo configs and installed symlinks."""
1525
+ config_dir = get_config_dir()
1526
+ config = Config.load()
1527
+ all_agents = get_agents(config_dir, config)
1528
+ selected_agents = select_agents(all_agents, agents)
1529
+
1530
+ console.print("[bold]Configuration Differences[/bold]\n")
1531
+
1532
+ found_differences = False
1533
+
1534
+ for agent in selected_agents:
1535
+ agent_has_diff = False
1536
+ agent_diffs = []
1537
+
1538
+ for target, source in agent.get_filtered_symlinks():
1539
+ target_path = target.expanduser()
1540
+ status_code, message = check_symlink(target_path, source)
1541
+
1542
+ if status_code == "missing":
1543
+ agent_diffs.append((target_path, source, "missing", "Not installed"))
1544
+ agent_has_diff = True
1545
+ elif status_code == "broken":
1546
+ agent_diffs.append((target_path, source, "broken", "Broken symlink"))
1547
+ agent_has_diff = True
1548
+ elif status_code == "wrong_target":
1549
+ try:
1550
+ actual = target_path.resolve()
1551
+ agent_diffs.append(
1552
+ (target_path, source, "wrong", f"Points to {actual}")
1553
+ )
1554
+ agent_has_diff = True
1555
+ except (OSError, RuntimeError):
1556
+ agent_diffs.append(
1557
+ (target_path, source, "broken", "Broken symlink")
1558
+ )
1559
+ agent_has_diff = True
1560
+ elif status_code == "not_symlink":
1561
+ agent_diffs.append(
1562
+ (target_path, source, "file", "Regular file (not symlink)")
1563
+ )
1564
+ agent_has_diff = True
1565
+
1566
+ if agent_has_diff:
1567
+ console.print(f"[bold]{agent.name}:[/bold]")
1568
+ for path, expected_source, diff_type, desc in agent_diffs:
1569
+ if diff_type == "missing":
1570
+ console.print(f" [red]✗[/red] {path}")
1571
+ console.print(f" [dim]{desc}[/dim]")
1572
+ console.print(f" [dim]Expected: → {expected_source}[/dim]")
1573
+ elif diff_type == "broken":
1574
+ console.print(f" [red]✗[/red] {path}")
1575
+ console.print(f" [dim]{desc}[/dim]")
1576
+ elif diff_type == "wrong":
1577
+ console.print(f" [yellow]⚠[/yellow] {path}")
1578
+ console.print(f" [dim]{desc}[/dim]")
1579
+ console.print(f" [dim]Expected: → {expected_source}[/dim]")
1580
+ elif diff_type == "file":
1581
+ console.print(f" [yellow]⚠[/yellow] {path}")
1582
+ console.print(f" [dim]{desc}[/dim]")
1583
+ console.print(f" [dim]Expected: → {expected_source}[/dim]")
1584
+ console.print()
1585
+ found_differences = True
1586
+
1587
+ if not found_differences:
1588
+ console.print("[green]No differences found - all symlinks are correct![/green]")
1589
+ else:
1590
+ console.print(
1591
+ "[yellow]💡 Run 'ai-rules install' to fix these differences[/yellow]"
1592
+ )
1593
+
1594
+
1595
+ @main.group()
1596
+ def exclude() -> None:
1597
+ """Manage exclusion patterns."""
1598
+ pass
1599
+
1600
+
1601
+ @exclude.command("add")
1602
+ @click.argument("pattern")
1603
+ def exclude_add(pattern: str) -> None:
1604
+ """Add an exclusion pattern to user config.
1605
+
1606
+ PATTERN can be an exact path or glob pattern (e.g., ~/.claude/*.json)
1607
+ """
1608
+ data = Config.load_user_config()
1609
+
1610
+ if "exclude_symlinks" not in data:
1611
+ data["exclude_symlinks"] = []
1612
+
1613
+ if pattern in data["exclude_symlinks"]:
1614
+ console.print(f"[yellow]Pattern already excluded:[/yellow] {pattern}")
1615
+ return
1616
+
1617
+ data["exclude_symlinks"].append(pattern)
1618
+ Config.save_user_config(data)
1619
+
1620
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
1621
+ console.print(f"[green]✓[/green] Added exclusion pattern: {pattern}")
1622
+ console.print(f"[dim]Config updated: {user_config_path}[/dim]")
1623
+
1624
+
1625
+ @exclude.command("remove")
1626
+ @click.argument("pattern")
1627
+ def exclude_remove(pattern: str) -> None:
1628
+ """Remove an exclusion pattern from user config."""
1629
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
1630
+
1631
+ if not user_config_path.exists():
1632
+ console.print("[red]No user config found[/red]")
1633
+ sys.exit(1)
1634
+
1635
+ data = Config.load_user_config()
1636
+
1637
+ if "exclude_symlinks" not in data or pattern not in data["exclude_symlinks"]:
1638
+ console.print(f"[yellow]Pattern not found:[/yellow] {pattern}")
1639
+ sys.exit(1)
1640
+
1641
+ data["exclude_symlinks"].remove(pattern)
1642
+ Config.save_user_config(data)
1643
+
1644
+ console.print(f"[green]✓[/green] Removed exclusion pattern: {pattern}")
1645
+ console.print(f"[dim]Config updated: {user_config_path}[/dim]")
1646
+
1647
+
1648
+ @exclude.command("list")
1649
+ def exclude_list() -> None:
1650
+ """List all exclusion patterns."""
1651
+ config = Config.load()
1652
+
1653
+ if not config.exclude_symlinks:
1654
+ console.print("[dim]No exclusion patterns configured[/dim]")
1655
+ return
1656
+
1657
+ console.print("[bold]Exclusion Patterns:[/bold]\n")
1658
+
1659
+ for pattern in sorted(config.exclude_symlinks):
1660
+ console.print(f" • {pattern} [dim](user)[/dim]")
1661
+
1662
+
1663
+ @main.group()
1664
+ def override() -> None:
1665
+ """Manage settings overrides."""
1666
+ pass
1667
+
1668
+
1669
+ @override.command("set")
1670
+ @click.argument("key")
1671
+ @click.argument("value")
1672
+ def override_set(key: str, value: str) -> None:
1673
+ """Set a settings override for an agent.
1674
+
1675
+ KEY should be in format 'agent.setting' (e.g., 'claude.model')
1676
+ Supports array notation: 'claude.hooks.SubagentStop[0].hooks[0].command'
1677
+ VALUE will be parsed as JSON if possible, otherwise treated as string
1678
+
1679
+ Array notation examples:
1680
+ - claude.hooks.SubagentStop[0].command
1681
+ - claude.hooks.SubagentStop[0].hooks[0].command
1682
+ - claude.items[0].nested[1].value
1683
+
1684
+ Path validation:
1685
+ - Validates agent name (must be 'claude', 'goose', etc.)
1686
+ - Validates full path against base settings structure
1687
+ - Provides helpful suggestions when paths are invalid
1688
+ """
1689
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
1690
+ config_dir = get_config_dir()
1691
+
1692
+ parts = key.split(".", 1)
1693
+ if len(parts) != 2:
1694
+ console.print("[red]Error:[/red] Key must be in format 'agent.setting'")
1695
+ console.print(
1696
+ "[dim]Example: claude.model or claude.hooks.SubagentStop[0].command[/dim]"
1697
+ )
1698
+ sys.exit(1)
1699
+
1700
+ agent, setting = parts
1701
+
1702
+ is_valid, error_msg, warning_msg, suggestions = validate_override_path(
1703
+ agent, setting, config_dir
1704
+ )
1705
+
1706
+ if not is_valid:
1707
+ console.print(f"[red]Error:[/red] {error_msg}")
1708
+ if suggestions:
1709
+ console.print(
1710
+ f"[dim]Available options: {', '.join(suggestions[:10])}[/dim]"
1711
+ )
1712
+ sys.exit(1)
1713
+
1714
+ if warning_msg:
1715
+ console.print(f"[yellow]Warning:[/yellow] {warning_msg}")
1716
+
1717
+ import json
1718
+
1719
+ try:
1720
+ parsed_value = json.loads(value)
1721
+ except json.JSONDecodeError:
1722
+ parsed_value = value
1723
+
1724
+ data = Config.load_user_config()
1725
+
1726
+ if "settings_overrides" not in data:
1727
+ data["settings_overrides"] = {}
1728
+
1729
+ if agent not in data["settings_overrides"]:
1730
+ data["settings_overrides"][agent] = {}
1731
+
1732
+ try:
1733
+ path_components = parse_setting_path(setting)
1734
+ except ValueError as e:
1735
+ console.print(f"[red]Error:[/red] {e}")
1736
+ sys.exit(1)
1737
+
1738
+ current = data["settings_overrides"][agent]
1739
+ for i, component in enumerate(path_components[:-1]):
1740
+ if isinstance(component, int):
1741
+ if not isinstance(current, list):
1742
+ console.print(
1743
+ f"[red]Error:[/red] Expected array at path component {i}, "
1744
+ f"but found {type(current).__name__}"
1745
+ )
1746
+ sys.exit(1)
1747
+
1748
+ while len(current) <= component:
1749
+ current.append({})
1750
+
1751
+ current = current[component]
1752
+ else:
1753
+ if component not in current:
1754
+ next_component = (
1755
+ path_components[i + 1] if i + 1 < len(path_components) else None
1756
+ )
1757
+ if isinstance(next_component, int):
1758
+ current[component] = []
1759
+ else:
1760
+ current[component] = {}
1761
+
1762
+ current = current[component]
1763
+
1764
+ final_component = path_components[-1]
1765
+ if isinstance(final_component, int):
1766
+ if not isinstance(current, list):
1767
+ console.print(
1768
+ f"[red]Error:[/red] Expected array for final component, "
1769
+ f"but found {type(current).__name__}"
1770
+ )
1771
+ sys.exit(1)
1772
+
1773
+ while len(current) <= final_component:
1774
+ current.append(None)
1775
+
1776
+ current[final_component] = parsed_value
1777
+ else:
1778
+ current[final_component] = parsed_value
1779
+
1780
+ Config.save_user_config(data)
1781
+
1782
+ console.print(f"[green]✓[/green] Set override: {agent}.{setting} = {parsed_value}")
1783
+ console.print(f"[dim]Config updated: {user_config_path}[/dim]")
1784
+ console.print(
1785
+ "\n[yellow]💡 Run 'ai-rules install --rebuild-cache' to apply changes[/yellow]"
1786
+ )
1787
+
1788
+
1789
+ @override.command("unset")
1790
+ @click.argument("key")
1791
+ def override_unset(key: str) -> None:
1792
+ """Remove a settings override.
1793
+
1794
+ KEY should be in format 'agent.setting' (e.g., 'claude.model')
1795
+ Supports nested keys like 'agent.nested.key'
1796
+ """
1797
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
1798
+
1799
+ if not user_config_path.exists():
1800
+ console.print("[red]No user config found[/red]")
1801
+ sys.exit(1)
1802
+
1803
+ parts = key.split(".", 1)
1804
+ if len(parts) != 2:
1805
+ console.print("[red]Error:[/red] Key must be in format 'agent.setting'")
1806
+ sys.exit(1)
1807
+
1808
+ agent, setting = parts
1809
+
1810
+ data = Config.load_user_config()
1811
+
1812
+ if "settings_overrides" not in data or agent not in data["settings_overrides"]:
1813
+ console.print(f"[yellow]Override not found:[/yellow] {key}")
1814
+ sys.exit(1)
1815
+
1816
+ setting_parts = setting.split(".")
1817
+ current = data["settings_overrides"][agent]
1818
+
1819
+ for part in setting_parts[:-1]:
1820
+ if not isinstance(current, dict) or part not in current:
1821
+ console.print(f"[yellow]Override not found:[/yellow] {key}")
1822
+ sys.exit(1)
1823
+ current = current[part]
1824
+
1825
+ final_key = setting_parts[-1]
1826
+ if not isinstance(current, dict) or final_key not in current:
1827
+ console.print(f"[yellow]Override not found:[/yellow] {key}")
1828
+ sys.exit(1)
1829
+
1830
+ del current[final_key]
1831
+
1832
+ current = data["settings_overrides"][agent]
1833
+ path = []
1834
+
1835
+ for part in setting_parts[:-1]:
1836
+ path.append((current, part))
1837
+ current = current[part]
1838
+
1839
+ for parent, key in reversed(path):
1840
+ if isinstance(parent[key], dict) and not parent[key]:
1841
+ del parent[key]
1842
+ else:
1843
+ break
1844
+
1845
+ if not data["settings_overrides"][agent]:
1846
+ del data["settings_overrides"][agent]
1847
+
1848
+ Config.save_user_config(data)
1849
+
1850
+ console.print(f"[green]✓[/green] Removed override: {key}")
1851
+ console.print(f"[dim]Config updated: {user_config_path}[/dim]")
1852
+ console.print(
1853
+ "\n[yellow]💡 Run 'ai-rules install --rebuild-cache' to apply changes[/yellow]"
1854
+ )
1855
+
1856
+
1857
+ @override.command("list")
1858
+ def override_list() -> None:
1859
+ """List all settings overrides."""
1860
+ config = Config.load()
1861
+
1862
+ if not config.settings_overrides:
1863
+ console.print("[dim]No settings overrides configured[/dim]")
1864
+ return
1865
+
1866
+ console.print("[bold]Settings Overrides:[/bold]\n")
1867
+
1868
+ for agent, overrides in sorted(config.settings_overrides.items()):
1869
+ console.print(f"[bold]{agent}:[/bold]")
1870
+ for key, value in sorted(overrides.items()):
1871
+ console.print(f" • {key}: {value}")
1872
+ console.print()
1873
+
1874
+
1875
+ @main.group()
1876
+ def config() -> None:
1877
+ """Manage ai-rules configuration."""
1878
+ pass
1879
+
1880
+
1881
+ @config.command("show")
1882
+ @click.option(
1883
+ "--merged", is_flag=True, help="Show merged settings with overrides applied"
1884
+ )
1885
+ @click.option("--agent", help="Show config for specific agent only")
1886
+ def config_show(merged: bool, agent: str | None) -> None:
1887
+ """Show current configuration."""
1888
+ config_dir = get_config_dir()
1889
+ cfg = Config.load()
1890
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
1891
+
1892
+ if merged:
1893
+ console.print("[bold]Merged Settings:[/bold]\n")
1894
+
1895
+ agents_to_show = [agent] if agent else ["claude", "goose"]
1896
+
1897
+ for agent_name in agents_to_show:
1898
+ if agent_name not in cfg.settings_overrides:
1899
+ console.print(
1900
+ f"[dim]{agent_name}: No overrides (using base settings)[/dim]\n"
1901
+ )
1902
+ continue
1903
+
1904
+ console.print(f"[bold]{agent_name}:[/bold]")
1905
+
1906
+ from ai_rules.config import AGENT_CONFIG_METADATA
1907
+
1908
+ agent_config = AGENT_CONFIG_METADATA.get(agent_name)
1909
+ if not agent_config:
1910
+ console.print(f" [red]✗[/red] Unknown agent: {agent_name}")
1911
+ console.print()
1912
+ continue
1913
+
1914
+ base_path = config_dir / agent_name / agent_config["config_file"]
1915
+ if base_path.exists():
1916
+ with open(base_path) as f:
1917
+ import json
1918
+
1919
+ import yaml
1920
+
1921
+ if agent_config["format"] == "json":
1922
+ base_settings = json.load(f)
1923
+ else:
1924
+ base_settings = yaml.safe_load(f)
1925
+
1926
+ merged_settings = cfg.merge_settings(agent_name, base_settings)
1927
+
1928
+ overridden_keys = []
1929
+ for key in cfg.settings_overrides[agent_name]:
1930
+ if key in base_settings:
1931
+ old_val = base_settings[key]
1932
+ new_val = merged_settings[key]
1933
+ console.print(
1934
+ f" [yellow]↻[/yellow] {key}: {old_val} → {new_val}"
1935
+ )
1936
+ overridden_keys.append(key)
1937
+ else:
1938
+ console.print(
1939
+ f" [green]+[/green] {key}: {merged_settings[key]}"
1940
+ )
1941
+ overridden_keys.append(key)
1942
+
1943
+ for key, value in merged_settings.items():
1944
+ if key not in overridden_keys:
1945
+ console.print(f" [dim]•[/dim] {key}: {value}")
1946
+ else:
1947
+ console.print(
1948
+ f" [yellow]⚠[/yellow] No base settings found at {base_path}"
1949
+ )
1950
+ console.print(
1951
+ f" [dim]Overrides: {cfg.settings_overrides[agent_name]}[/dim]"
1952
+ )
1953
+
1954
+ console.print()
1955
+ else:
1956
+ console.print("[bold]Configuration:[/bold]\n")
1957
+
1958
+ if user_config_path.exists():
1959
+ with open(user_config_path) as f:
1960
+ content = f.read()
1961
+ console.print(f"[bold]User Config:[/bold] {user_config_path}")
1962
+ console.print(content)
1963
+ else:
1964
+ console.print(f"[dim]No user config at {user_config_path}[/dim]\n")
1965
+
1966
+
1967
+ @config.command("edit")
1968
+ def config_edit() -> None:
1969
+ """Edit user configuration file in $EDITOR."""
1970
+ import os
1971
+ import subprocess
1972
+
1973
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
1974
+ editor = os.environ.get("EDITOR", "vi")
1975
+
1976
+ if not user_config_path.exists():
1977
+ user_config_path.parent.mkdir(parents=True, exist_ok=True)
1978
+ with open(user_config_path, "w") as f:
1979
+ f.write("version: 1\n")
1980
+
1981
+ try:
1982
+ subprocess.run([editor, str(user_config_path)], check=True)
1983
+ console.print(f"[green]✓[/green] Config edited: {user_config_path}")
1984
+ except subprocess.CalledProcessError:
1985
+ console.print("[red]Error opening editor[/red]")
1986
+ sys.exit(1)
1987
+
1988
+
1989
+ def _get_common_exclusions() -> list[tuple[str, str, bool]]:
1990
+ """Get list of common exclusion patterns.
1991
+
1992
+ Returns:
1993
+ List of (pattern, description, default) tuples
1994
+ """
1995
+ return [
1996
+ ("~/.claude/settings.json", "Claude Code settings", False),
1997
+ ("~/.config/goose/config.yaml", "Goose config", False),
1998
+ ("~/.config/goose/.goosehints", "Goose hints", True),
1999
+ ("~/AGENTS.md", "Shared agents file", False),
2000
+ ]
2001
+
2002
+
2003
+ def _collect_exclusion_patterns() -> list[str]:
2004
+ """Collect exclusion patterns from user (Step 1).
2005
+
2006
+ Returns:
2007
+ List of exclusion patterns
2008
+ """
2009
+ console.print("\n[bold]Step 1: Exclusion Patterns[/bold]")
2010
+ console.print("Do you want to exclude any files from being managed?\n")
2011
+
2012
+ console.print("Common files to exclude:")
2013
+ selected_exclusions = []
2014
+
2015
+ for pattern, description, default in _get_common_exclusions():
2016
+ default_str = "Y/n" if default else "y/N"
2017
+ response = console.input(f" Exclude {description}? [{default_str}]: ").lower()
2018
+ should_exclude = (default and response != "n") or (
2019
+ not default and response == "y"
2020
+ )
2021
+ if should_exclude:
2022
+ selected_exclusions.append(pattern)
2023
+ console.print(f" [green]✓[/green] Will exclude: {pattern}")
2024
+
2025
+ console.print(
2026
+ "\n[dim]Enter custom exclusion patterns (glob patterns supported)[/dim]"
2027
+ )
2028
+ console.print("[dim]One per line, empty line to finish:[/dim]")
2029
+ while True:
2030
+ pattern = console.input("> ").strip()
2031
+ if not pattern:
2032
+ break
2033
+ selected_exclusions.append(pattern)
2034
+ console.print(f" [green]✓[/green] Added: {pattern}")
2035
+
2036
+ if selected_exclusions:
2037
+ console.print(
2038
+ f"\n[green]✓[/green] Configured {len(selected_exclusions)} exclusion pattern(s)"
2039
+ )
2040
+
2041
+ return selected_exclusions
2042
+
2043
+
2044
+ def _collect_settings_overrides() -> dict[str, dict[str, Any]]:
2045
+ """Collect settings overrides from user (Step 2).
2046
+
2047
+ Returns:
2048
+ Dictionary of agent settings overrides
2049
+ """
2050
+ import json
2051
+
2052
+ console.print("\n[bold]Step 2: Settings Overrides[/bold]")
2053
+ response = console.input(
2054
+ "Do you want to override any settings for this machine? [y/N]: "
2055
+ )
2056
+
2057
+ if response.lower() != "y":
2058
+ return {}
2059
+
2060
+ settings_overrides = {}
2061
+
2062
+ while True:
2063
+ console.print("\nWhich agent's settings do you want to override?")
2064
+ console.print(" 1) claude")
2065
+ console.print(" 2) goose")
2066
+ console.print(" 3) done")
2067
+ agent_choice = console.input("> ").strip()
2068
+
2069
+ if agent_choice == "3" or not agent_choice:
2070
+ break
2071
+
2072
+ agent_map = {"1": "claude", "2": "goose"}
2073
+ agent = agent_map.get(agent_choice)
2074
+
2075
+ if not agent:
2076
+ console.print("[yellow]Invalid choice[/yellow]")
2077
+ continue
2078
+
2079
+ console.print(f"\n[bold]{agent.title()} settings overrides:[/bold]")
2080
+ console.print("[dim]Enter key=value pairs (empty to finish):[/dim]")
2081
+ console.print("[dim]Example: model=claude-sonnet-4-5-20250929[/dim]\n")
2082
+
2083
+ agent_overrides = {}
2084
+ while True:
2085
+ override = console.input("> ").strip()
2086
+ if not override:
2087
+ break
2088
+
2089
+ if "=" not in override:
2090
+ console.print("[yellow]Invalid format. Use key=value[/yellow]")
2091
+ continue
2092
+
2093
+ key, value = override.split("=", 1)
2094
+ key = key.strip()
2095
+ value = value.strip()
2096
+
2097
+ try:
2098
+ parsed_value = json.loads(value)
2099
+ except json.JSONDecodeError:
2100
+ parsed_value = value
2101
+
2102
+ agent_overrides[key] = parsed_value
2103
+ console.print(f" [green]✓[/green] Added: {key} = {parsed_value}")
2104
+
2105
+ if agent_overrides:
2106
+ settings_overrides[agent] = agent_overrides
2107
+
2108
+ if settings_overrides:
2109
+ total_overrides = sum(len(v) for v in settings_overrides.values())
2110
+ console.print(
2111
+ f"\n[green]✓[/green] Configured {total_overrides} override(s) for {len(settings_overrides)} agent(s)"
2112
+ )
2113
+
2114
+ return settings_overrides
2115
+
2116
+
2117
+ def _display_configuration_summary(config_data: dict[str, Any]) -> None:
2118
+ """Display configuration summary before saving.
2119
+
2120
+ Args:
2121
+ config_data: Configuration dictionary to display
2122
+ """
2123
+ console.print("\n[bold cyan]Configuration Summary:[/bold cyan]")
2124
+ console.print("=" * 50)
2125
+
2126
+ if "exclude_symlinks" in config_data:
2127
+ console.print(
2128
+ f"\n[bold]Global Exclusions ({len(config_data['exclude_symlinks'])}):[/bold]"
2129
+ )
2130
+ for pattern in config_data["exclude_symlinks"]:
2131
+ console.print(f" • {pattern}")
2132
+
2133
+ if "settings_overrides" in config_data:
2134
+ console.print("\n[bold]Settings Overrides:[/bold]")
2135
+ for agent, overrides in config_data["settings_overrides"].items():
2136
+ console.print(f" [bold]{agent}:[/bold]")
2137
+ for key, value in overrides.items():
2138
+ console.print(f" • {key}: {value}")
2139
+
2140
+ console.print("\n" + "=" * 50)
2141
+
2142
+
2143
+ @config.command("init")
2144
+ def config_init() -> None:
2145
+ """Interactive configuration wizard."""
2146
+ user_config_path = Path.home() / ".ai-rules-config.yaml"
2147
+
2148
+ console.print("[bold cyan]Welcome to ai-rules configuration wizard![/bold cyan]\n")
2149
+ console.print("This will help you set up your .ai-rules-config.yaml file.")
2150
+ console.print(f"Config will be created at: [dim]{user_config_path}[/dim]\n")
2151
+
2152
+ if user_config_path.exists():
2153
+ console.print("[yellow]⚠[/yellow] Config file already exists!")
2154
+ if not Confirm.ask("Overwrite existing config?", default=False):
2155
+ console.print("[dim]Cancelled[/dim]")
2156
+ return
2157
+
2158
+ config_data: dict[str, Any] = {"version": 1}
2159
+
2160
+ selected_exclusions = _collect_exclusion_patterns()
2161
+ if selected_exclusions:
2162
+ config_data["exclude_symlinks"] = selected_exclusions
2163
+
2164
+ settings_overrides = _collect_settings_overrides()
2165
+ if settings_overrides:
2166
+ config_data["settings_overrides"] = settings_overrides
2167
+
2168
+ _display_configuration_summary(config_data)
2169
+
2170
+ if Confirm.ask("\nSave configuration?", default=True):
2171
+ Config.save_user_config(config_data)
2172
+
2173
+ console.print(f"\n[green]✓[/green] Configuration saved to {user_config_path}")
2174
+ console.print("\n[bold]Next steps:[/bold]")
2175
+ console.print(" • Run [cyan]ai-rules install[/cyan] to apply these settings")
2176
+ console.print(" • Run [cyan]ai-rules config show[/cyan] to view your config")
2177
+ console.print(
2178
+ " • Run [cyan]ai-rules config show --merged[/cyan] to see merged settings"
2179
+ )
2180
+ else:
2181
+ console.print("[dim]Configuration not saved[/dim]")
2182
+
2183
+
2184
+ @main.group()
2185
+ def completions() -> None:
2186
+ """Manage shell tab completion."""
2187
+ pass
2188
+
2189
+
2190
+ from ai_rules.completions import get_supported_shells
2191
+
2192
+ _SUPPORTED_SHELLS = list(get_supported_shells())
2193
+
2194
+
2195
+ @completions.command(name="bash")
2196
+ def completions_bash() -> None:
2197
+ """Output bash completion script for manual installation."""
2198
+ from ai_rules.completions import generate_completion_script
2199
+
2200
+ try:
2201
+ script = generate_completion_script("bash")
2202
+ console.print(script)
2203
+ console.print(
2204
+ "\n[dim]To install: Add the above to your ~/.bashrc or run:[/dim]"
2205
+ )
2206
+ console.print("[dim] ai-rules completions install[/dim]")
2207
+ except Exception as e:
2208
+ console.print(f"[red]Error generating completion script:[/red] {e}")
2209
+ sys.exit(1)
2210
+
2211
+
2212
+ @completions.command(name="zsh")
2213
+ def completions_zsh() -> None:
2214
+ """Output zsh completion script for manual installation."""
2215
+ from ai_rules.completions import generate_completion_script
2216
+
2217
+ try:
2218
+ script = generate_completion_script("zsh")
2219
+ console.print(script)
2220
+ console.print("\n[dim]To install: Add the above to your ~/.zshrc or run:[/dim]")
2221
+ console.print("[dim] ai-rules completions install[/dim]")
2222
+ except Exception as e:
2223
+ console.print(f"[red]Error generating completion script:[/red] {e}")
2224
+ sys.exit(1)
2225
+
2226
+
2227
+ @completions.command(name="install")
2228
+ @click.option(
2229
+ "--shell",
2230
+ type=click.Choice(_SUPPORTED_SHELLS, case_sensitive=False),
2231
+ help="Shell type (auto-detected if not specified)",
2232
+ )
2233
+ def completions_install(shell: str | None) -> None:
2234
+ """Install shell completion to config file."""
2235
+ from ai_rules.completions import detect_shell, install_completion
2236
+
2237
+ if shell is None:
2238
+ shell = detect_shell()
2239
+ if shell is None:
2240
+ console.print(
2241
+ "[red]Error:[/red] Could not detect shell. Please specify with --shell"
2242
+ )
2243
+ sys.exit(1)
2244
+ console.print(f"[dim]Detected shell:[/dim] {shell}")
2245
+
2246
+ success, message = install_completion(shell, dry_run=False)
2247
+
2248
+ if success:
2249
+ console.print(f"[green]✓[/green] {message}")
2250
+ else:
2251
+ console.print(f"[red]Error:[/red] {message}")
2252
+ sys.exit(1)
2253
+
2254
+
2255
+ @completions.command(name="uninstall")
2256
+ @click.option(
2257
+ "--shell",
2258
+ type=click.Choice(_SUPPORTED_SHELLS, case_sensitive=False),
2259
+ help="Shell type (auto-detected if not specified)",
2260
+ )
2261
+ def completions_uninstall(shell: str | None) -> None:
2262
+ """Remove shell completion from config file."""
2263
+ from ai_rules.completions import (
2264
+ detect_shell,
2265
+ find_config_file,
2266
+ uninstall_completion,
2267
+ )
2268
+
2269
+ if shell is None:
2270
+ shell = detect_shell()
2271
+ if shell is None:
2272
+ console.print(
2273
+ "[red]Error:[/red] Could not detect shell. Please specify with --shell"
2274
+ )
2275
+ sys.exit(1)
2276
+
2277
+ config_path = find_config_file(shell)
2278
+ if config_path is None:
2279
+ console.print(f"[red]Error:[/red] No {shell} config file found")
2280
+ sys.exit(1)
2281
+
2282
+ success, message = uninstall_completion(config_path)
2283
+
2284
+ if success:
2285
+ console.print(f"[green]✓[/green] {message}")
2286
+ else:
2287
+ console.print(f"[red]Error:[/red] {message}")
2288
+ sys.exit(1)
2289
+
2290
+
2291
+ if __name__ == "__main__":
2292
+ main()