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