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.
- ai_agent_rules-0.11.0.dist-info/METADATA +390 -0
- ai_agent_rules-0.11.0.dist-info/RECORD +42 -0
- ai_agent_rules-0.11.0.dist-info/WHEEL +5 -0
- ai_agent_rules-0.11.0.dist-info/entry_points.txt +3 -0
- ai_agent_rules-0.11.0.dist-info/licenses/LICENSE +22 -0
- ai_agent_rules-0.11.0.dist-info/top_level.txt +1 -0
- ai_rules/__init__.py +8 -0
- ai_rules/agents/__init__.py +1 -0
- ai_rules/agents/base.py +68 -0
- ai_rules/agents/claude.py +121 -0
- ai_rules/agents/goose.py +44 -0
- ai_rules/agents/shared.py +35 -0
- ai_rules/bootstrap/__init__.py +75 -0
- ai_rules/bootstrap/config.py +261 -0
- ai_rules/bootstrap/installer.py +249 -0
- ai_rules/bootstrap/updater.py +221 -0
- ai_rules/bootstrap/version.py +52 -0
- ai_rules/cli.py +2292 -0
- ai_rules/completions.py +194 -0
- ai_rules/config/AGENTS.md +249 -0
- ai_rules/config/chat_agent_hints.md +1 -0
- ai_rules/config/claude/agents/code-reviewer.md +121 -0
- ai_rules/config/claude/commands/annotate-changelog.md +191 -0
- ai_rules/config/claude/commands/comment-cleanup.md +161 -0
- ai_rules/config/claude/commands/continue-crash.md +38 -0
- ai_rules/config/claude/commands/dev-docs.md +169 -0
- ai_rules/config/claude/commands/pr-creator.md +247 -0
- ai_rules/config/claude/commands/test-cleanup.md +244 -0
- ai_rules/config/claude/commands/update-docs.md +324 -0
- ai_rules/config/claude/hooks/subagentStop.py +92 -0
- ai_rules/config/claude/mcps.json +1 -0
- ai_rules/config/claude/settings.json +116 -0
- ai_rules/config/claude/skills/doc-writer/SKILL.md +293 -0
- ai_rules/config/claude/skills/doc-writer/resources/templates.md +495 -0
- ai_rules/config/claude/skills/prompt-engineer/SKILL.md +272 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/prompt_engineering_guide_2025.md +855 -0
- ai_rules/config/claude/skills/prompt-engineer/resources/templates.md +232 -0
- ai_rules/config/goose/config.yaml +55 -0
- ai_rules/config.py +635 -0
- ai_rules/display.py +40 -0
- ai_rules/mcp.py +370 -0
- 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()
|