ai-config-cli 0.1.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.
- ai_config/__init__.py +3 -0
- ai_config/__main__.py +6 -0
- ai_config/adapters/__init__.py +1 -0
- ai_config/adapters/claude.py +353 -0
- ai_config/cli.py +729 -0
- ai_config/cli_render.py +525 -0
- ai_config/cli_theme.py +44 -0
- ai_config/config.py +260 -0
- ai_config/init.py +763 -0
- ai_config/operations.py +357 -0
- ai_config/scaffold.py +87 -0
- ai_config/settings.py +63 -0
- ai_config/types.py +143 -0
- ai_config/validators/__init__.py +149 -0
- ai_config/validators/base.py +48 -0
- ai_config/validators/component/__init__.py +1 -0
- ai_config/validators/component/hook.py +366 -0
- ai_config/validators/component/mcp.py +230 -0
- ai_config/validators/component/skill.py +411 -0
- ai_config/validators/context.py +69 -0
- ai_config/validators/marketplace/__init__.py +1 -0
- ai_config/validators/marketplace/validators.py +433 -0
- ai_config/validators/plugin/__init__.py +1 -0
- ai_config/validators/plugin/validators.py +336 -0
- ai_config/validators/target/__init__.py +1 -0
- ai_config/validators/target/claude.py +154 -0
- ai_config/watch.py +279 -0
- ai_config_cli-0.1.0.dist-info/METADATA +235 -0
- ai_config_cli-0.1.0.dist-info/RECORD +32 -0
- ai_config_cli-0.1.0.dist-info/WHEEL +4 -0
- ai_config_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ai_config_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ai_config/cli.py
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"""CLI for ai-config."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import Event
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from ai_config.cli_theme import SYMBOLS, create_console
|
|
15
|
+
from ai_config.config import (
|
|
16
|
+
ConfigError,
|
|
17
|
+
find_config_file,
|
|
18
|
+
load_config,
|
|
19
|
+
validate_marketplace_references,
|
|
20
|
+
)
|
|
21
|
+
from ai_config.operations import (
|
|
22
|
+
get_status,
|
|
23
|
+
sync_config,
|
|
24
|
+
update_plugins,
|
|
25
|
+
verify_sync,
|
|
26
|
+
)
|
|
27
|
+
from ai_config.scaffold import create_plugin
|
|
28
|
+
from ai_config.validators import VALIDATORS, run_validators_sync
|
|
29
|
+
|
|
30
|
+
console = create_console()
|
|
31
|
+
error_console = create_console(stderr=True)
|
|
32
|
+
|
|
33
|
+
# Command order for --help display (logical workflow order)
|
|
34
|
+
COMMAND_ORDER = ["init", "sync", "status", "watch", "update", "doctor", "plugin", "cache"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OrderedGroup(click.Group):
|
|
38
|
+
"""Click group that displays commands in a defined order."""
|
|
39
|
+
|
|
40
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
41
|
+
"""Return commands in logical workflow order."""
|
|
42
|
+
commands = super().list_commands(ctx)
|
|
43
|
+
# Sort by COMMAND_ORDER index, unknown commands go to end
|
|
44
|
+
return sorted(commands, key=lambda x: COMMAND_ORDER.index(x) if x in COMMAND_ORDER else 999)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@click.group(cls=OrderedGroup)
|
|
48
|
+
@click.version_option(package_name="ai-config-cli")
|
|
49
|
+
def main() -> None:
|
|
50
|
+
"""ai-config: Declarative plugin manager for Claude Code."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@main.command()
|
|
55
|
+
@click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path))
|
|
56
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be done without making changes")
|
|
57
|
+
@click.option("--fresh", is_flag=True, help="Clear cache before syncing")
|
|
58
|
+
@click.option("--verify", is_flag=True, help="Verify sync after completion")
|
|
59
|
+
def sync(
|
|
60
|
+
config_path: Path | None,
|
|
61
|
+
dry_run: bool,
|
|
62
|
+
fresh: bool,
|
|
63
|
+
verify: bool,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Sync plugins and marketplaces to match config.
|
|
66
|
+
|
|
67
|
+
\b
|
|
68
|
+
When to use:
|
|
69
|
+
- After editing .ai-config/config.yaml to add/remove plugins
|
|
70
|
+
- After cloning a repo with an existing ai-config setup
|
|
71
|
+
- To fix drift between config and installed state
|
|
72
|
+
|
|
73
|
+
\b
|
|
74
|
+
What you'll see:
|
|
75
|
+
- Table of actions taken (install/enable/disable)
|
|
76
|
+
- "No changes needed" means config already matches reality
|
|
77
|
+
- Errors show which plugins/marketplaces failed
|
|
78
|
+
|
|
79
|
+
\b
|
|
80
|
+
Typical workflow:
|
|
81
|
+
1. Edit config.yaml
|
|
82
|
+
2. Run: ai-config sync --dry-run (preview changes)
|
|
83
|
+
3. Run: ai-config sync (apply changes)
|
|
84
|
+
4. Verify: ai-config doctor (check health)
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
config = load_config(config_path)
|
|
88
|
+
except ConfigError as e:
|
|
89
|
+
error_console.print(f"[error]Error loading config:[/error] {e}")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
# Validate marketplace references
|
|
93
|
+
ref_errors = validate_marketplace_references(config)
|
|
94
|
+
if ref_errors:
|
|
95
|
+
error_console.print("[error]Config validation errors:[/error]")
|
|
96
|
+
for error in ref_errors:
|
|
97
|
+
error_console.print(f" {SYMBOLS['bullet']} {error}")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
if dry_run:
|
|
101
|
+
console.print("[warning]Dry run mode - no changes will be made[/warning]")
|
|
102
|
+
results = sync_config(config, dry_run=dry_run, fresh=fresh)
|
|
103
|
+
else:
|
|
104
|
+
# Use spinner for actual sync operations
|
|
105
|
+
with Progress(
|
|
106
|
+
SpinnerColumn(),
|
|
107
|
+
TextColumn("[progress.description]{task.description}"),
|
|
108
|
+
console=console,
|
|
109
|
+
transient=True,
|
|
110
|
+
) as progress:
|
|
111
|
+
progress.add_task("Syncing plugins...", total=None)
|
|
112
|
+
results = sync_config(config, dry_run=dry_run, fresh=fresh)
|
|
113
|
+
|
|
114
|
+
for target_type, result in results.items():
|
|
115
|
+
console.print(f"\n[subheader]Target: {target_type}[/subheader]")
|
|
116
|
+
|
|
117
|
+
if result.actions_taken:
|
|
118
|
+
# Use a table for actions
|
|
119
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
120
|
+
table.add_column("Action", style="key")
|
|
121
|
+
table.add_column("Target")
|
|
122
|
+
table.add_column("Scope", style="info")
|
|
123
|
+
|
|
124
|
+
for action in result.actions_taken:
|
|
125
|
+
table.add_row(
|
|
126
|
+
action.action,
|
|
127
|
+
action.target,
|
|
128
|
+
action.scope or "-",
|
|
129
|
+
)
|
|
130
|
+
console.print(table)
|
|
131
|
+
else:
|
|
132
|
+
console.print(f" [success]{SYMBOLS['pass']}[/success] No changes needed")
|
|
133
|
+
|
|
134
|
+
if result.errors:
|
|
135
|
+
console.print("[error]Errors:[/error]")
|
|
136
|
+
for error in result.errors:
|
|
137
|
+
console.print(f" {SYMBOLS['fail']} {error}")
|
|
138
|
+
|
|
139
|
+
# Verify if requested
|
|
140
|
+
if verify and not dry_run:
|
|
141
|
+
console.print("\n[subheader]Verification:[/subheader]")
|
|
142
|
+
discrepancies = verify_sync(config)
|
|
143
|
+
if discrepancies:
|
|
144
|
+
console.print("[error]Out of sync:[/error]")
|
|
145
|
+
for d in discrepancies:
|
|
146
|
+
console.print(f" {SYMBOLS['fail']} {d}")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
else:
|
|
149
|
+
console.print(f"[success]{SYMBOLS['pass']} All in sync![/success]")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@main.command()
|
|
153
|
+
@click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path))
|
|
154
|
+
@click.option("--verify", is_flag=True, help="Verify current state matches config")
|
|
155
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
156
|
+
def status(
|
|
157
|
+
config_path: Path | None,
|
|
158
|
+
verify: bool,
|
|
159
|
+
as_json: bool,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Show current plugin and marketplace status.
|
|
162
|
+
|
|
163
|
+
\b
|
|
164
|
+
When to use:
|
|
165
|
+
- See what plugins are currently installed in Claude Code
|
|
166
|
+
- Check if plugins are enabled or disabled
|
|
167
|
+
- Compare actual state with config using --verify
|
|
168
|
+
|
|
169
|
+
\b
|
|
170
|
+
What you'll see:
|
|
171
|
+
- Table of installed plugins with ID, version, scope, and enabled status
|
|
172
|
+
- List of registered marketplaces
|
|
173
|
+
- Use --json for machine-readable output
|
|
174
|
+
|
|
175
|
+
\b
|
|
176
|
+
Typical workflow:
|
|
177
|
+
1. Run: ai-config status (see current state)
|
|
178
|
+
2. Run: ai-config status --verify (compare with config)
|
|
179
|
+
3. Run: ai-config sync (if out of sync)
|
|
180
|
+
"""
|
|
181
|
+
result = get_status()
|
|
182
|
+
|
|
183
|
+
if as_json:
|
|
184
|
+
output = {
|
|
185
|
+
"target": result.target_type,
|
|
186
|
+
"plugins": [
|
|
187
|
+
{
|
|
188
|
+
"id": p.id,
|
|
189
|
+
"installed": p.installed,
|
|
190
|
+
"enabled": p.enabled,
|
|
191
|
+
"scope": p.scope,
|
|
192
|
+
"version": p.version,
|
|
193
|
+
}
|
|
194
|
+
for p in result.plugins
|
|
195
|
+
],
|
|
196
|
+
"marketplaces": result.marketplaces,
|
|
197
|
+
"errors": result.errors,
|
|
198
|
+
}
|
|
199
|
+
console.print_json(json.dumps(output))
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# Display plugins table
|
|
203
|
+
console.print("\n[subheader]Installed Plugins:[/subheader]")
|
|
204
|
+
if result.plugins:
|
|
205
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
206
|
+
table.add_column("ID", style="key")
|
|
207
|
+
table.add_column("Version")
|
|
208
|
+
table.add_column("Scope")
|
|
209
|
+
table.add_column("Enabled")
|
|
210
|
+
|
|
211
|
+
for plugin in result.plugins:
|
|
212
|
+
if plugin.enabled:
|
|
213
|
+
enabled_str = f"[success]{SYMBOLS['pass']}[/success]"
|
|
214
|
+
else:
|
|
215
|
+
enabled_str = f"[error]{SYMBOLS['fail']}[/error]"
|
|
216
|
+
table.add_row(
|
|
217
|
+
plugin.id,
|
|
218
|
+
plugin.version or "-",
|
|
219
|
+
plugin.scope or "-",
|
|
220
|
+
enabled_str,
|
|
221
|
+
)
|
|
222
|
+
console.print(table)
|
|
223
|
+
else:
|
|
224
|
+
console.print(" No plugins installed")
|
|
225
|
+
|
|
226
|
+
# Display marketplaces
|
|
227
|
+
console.print("\n[subheader]Registered Marketplaces:[/subheader]")
|
|
228
|
+
if result.marketplaces:
|
|
229
|
+
for mp in result.marketplaces:
|
|
230
|
+
console.print(f" {SYMBOLS['bullet']} {mp}")
|
|
231
|
+
else:
|
|
232
|
+
console.print(" No marketplaces registered")
|
|
233
|
+
|
|
234
|
+
# Show errors if any
|
|
235
|
+
if result.errors:
|
|
236
|
+
console.print("\n[error]Errors:[/error]")
|
|
237
|
+
for error in result.errors:
|
|
238
|
+
console.print(f" {SYMBOLS['fail']} {error}")
|
|
239
|
+
|
|
240
|
+
# Verify against config if requested
|
|
241
|
+
if verify:
|
|
242
|
+
console.print("\n[subheader]Verification:[/subheader]")
|
|
243
|
+
try:
|
|
244
|
+
config = load_config(config_path)
|
|
245
|
+
discrepancies = verify_sync(config)
|
|
246
|
+
if discrepancies:
|
|
247
|
+
console.print("[error]Out of sync:[/error]")
|
|
248
|
+
for d in discrepancies:
|
|
249
|
+
console.print(f" {SYMBOLS['fail']} {d}")
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
else:
|
|
252
|
+
console.print(f"[success]{SYMBOLS['pass']} All in sync![/success]")
|
|
253
|
+
except ConfigError as e:
|
|
254
|
+
error_console.print(f"[error]Cannot verify - config error:[/error] {e}")
|
|
255
|
+
sys.exit(1)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@main.command()
|
|
259
|
+
@click.option("--all", "update_all", is_flag=True, help="Update all plugins")
|
|
260
|
+
@click.option("--fresh", is_flag=True, help="Clear cache before updating")
|
|
261
|
+
@click.argument("plugins", nargs=-1)
|
|
262
|
+
def update(
|
|
263
|
+
update_all: bool,
|
|
264
|
+
fresh: bool,
|
|
265
|
+
plugins: tuple[str, ...],
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Update plugins to latest versions.
|
|
268
|
+
|
|
269
|
+
\b
|
|
270
|
+
When to use:
|
|
271
|
+
- Get latest plugin versions from marketplaces
|
|
272
|
+
- Update a specific plugin after upstream changes
|
|
273
|
+
- Refresh all plugins with --all
|
|
274
|
+
|
|
275
|
+
\b
|
|
276
|
+
What you'll see:
|
|
277
|
+
- Lists plugins that were updated
|
|
278
|
+
- Shows errors for failed updates
|
|
279
|
+
|
|
280
|
+
\b
|
|
281
|
+
Typical workflow:
|
|
282
|
+
1. Run: ai-config update --all (update everything)
|
|
283
|
+
2. Run: ai-config update plugin1 (update specific plugin)
|
|
284
|
+
3. Run: ai-config doctor (verify health after update)
|
|
285
|
+
"""
|
|
286
|
+
if not update_all and not plugins:
|
|
287
|
+
error_console.print("[error]Specify plugins to update or use --all[/error]")
|
|
288
|
+
sys.exit(1)
|
|
289
|
+
|
|
290
|
+
plugin_ids = None if update_all else list(plugins)
|
|
291
|
+
|
|
292
|
+
# Use spinner for update operation
|
|
293
|
+
with Progress(
|
|
294
|
+
SpinnerColumn(),
|
|
295
|
+
TextColumn("[progress.description]{task.description}"),
|
|
296
|
+
console=console,
|
|
297
|
+
transient=True,
|
|
298
|
+
) as progress:
|
|
299
|
+
progress.add_task("Updating plugins...", total=None)
|
|
300
|
+
result = update_plugins(plugin_ids=plugin_ids, fresh=fresh)
|
|
301
|
+
|
|
302
|
+
if result.actions_taken:
|
|
303
|
+
console.print(f"[success]{SYMBOLS['pass']} Updated plugins:[/success]")
|
|
304
|
+
for action in result.actions_taken:
|
|
305
|
+
console.print(f" {SYMBOLS['arrow']} {action.target}")
|
|
306
|
+
|
|
307
|
+
if result.errors:
|
|
308
|
+
console.print("[error]Errors:[/error]")
|
|
309
|
+
for error in result.errors:
|
|
310
|
+
console.print(f" {SYMBOLS['fail']} {error}")
|
|
311
|
+
|
|
312
|
+
if not result.success:
|
|
313
|
+
sys.exit(1)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@main.group()
|
|
317
|
+
def cache() -> None:
|
|
318
|
+
"""Manage plugin cache.
|
|
319
|
+
|
|
320
|
+
USE CASES:
|
|
321
|
+
- Clear stale plugin data with 'cache clear'
|
|
322
|
+
- Force re-download of plugins on next sync
|
|
323
|
+
"""
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@cache.command(name="clear")
|
|
328
|
+
def cache_clear() -> None:
|
|
329
|
+
"""Clear the plugin cache.
|
|
330
|
+
|
|
331
|
+
\b
|
|
332
|
+
When to use:
|
|
333
|
+
- When plugins seem stale or out of date
|
|
334
|
+
- After changing marketplace URLs
|
|
335
|
+
- When sync doesn't pick up expected changes
|
|
336
|
+
|
|
337
|
+
\b
|
|
338
|
+
What you'll see:
|
|
339
|
+
- Success message when cache is cleared
|
|
340
|
+
- Error message if clearing fails
|
|
341
|
+
|
|
342
|
+
\b
|
|
343
|
+
Typical workflow:
|
|
344
|
+
1. Run: ai-config cache clear
|
|
345
|
+
2. Run: ai-config sync --fresh (re-fetch everything)
|
|
346
|
+
"""
|
|
347
|
+
from ai_config.adapters import claude
|
|
348
|
+
|
|
349
|
+
with Progress(
|
|
350
|
+
SpinnerColumn(),
|
|
351
|
+
TextColumn("[progress.description]{task.description}"),
|
|
352
|
+
console=console,
|
|
353
|
+
transient=True,
|
|
354
|
+
) as progress:
|
|
355
|
+
progress.add_task("Clearing cache...", total=None)
|
|
356
|
+
result = claude.clear_cache()
|
|
357
|
+
|
|
358
|
+
if result.success:
|
|
359
|
+
console.print(f"[success]{SYMBOLS['pass']} Cache cleared successfully[/success]")
|
|
360
|
+
else:
|
|
361
|
+
error_console.print(f"[error]Failed to clear cache:[/error] {result.stderr}")
|
|
362
|
+
sys.exit(1)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@main.group()
|
|
366
|
+
def plugin() -> None:
|
|
367
|
+
"""Plugin management commands.
|
|
368
|
+
|
|
369
|
+
Subcommands:
|
|
370
|
+
create - Scaffold a new plugin
|
|
371
|
+
"""
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@plugin.command(name="create")
|
|
376
|
+
@click.argument("name")
|
|
377
|
+
@click.option(
|
|
378
|
+
"--path",
|
|
379
|
+
type=click.Path(path_type=Path),
|
|
380
|
+
help="Base path for plugin directory",
|
|
381
|
+
)
|
|
382
|
+
def plugin_create(name: str, path: Path | None) -> None:
|
|
383
|
+
"""Create a new plugin scaffold.
|
|
384
|
+
|
|
385
|
+
\b
|
|
386
|
+
When to use:
|
|
387
|
+
- Start a new plugin project from scratch
|
|
388
|
+
- Create a local plugin for testing skills/hooks
|
|
389
|
+
|
|
390
|
+
\b
|
|
391
|
+
What you'll see:
|
|
392
|
+
- Creates directory with manifest.yaml, skills/, and hooks/
|
|
393
|
+
- Shows next steps for plugin development
|
|
394
|
+
|
|
395
|
+
\b
|
|
396
|
+
Typical workflow:
|
|
397
|
+
1. Run: ai-config plugin create my-plugin
|
|
398
|
+
2. Edit my-plugin/manifest.yaml
|
|
399
|
+
3. Add skills to my-plugin/skills/
|
|
400
|
+
4. Add to config.yaml as local marketplace
|
|
401
|
+
5. Run: ai-config sync
|
|
402
|
+
"""
|
|
403
|
+
plugin_dir = create_plugin(name, path)
|
|
404
|
+
console.print(f"[success]{SYMBOLS['pass']} Created plugin scaffold at:[/success] {plugin_dir}")
|
|
405
|
+
console.print("\n[subheader]Next steps:[/subheader]")
|
|
406
|
+
console.print(f" 1. Edit {plugin_dir}/manifest.yaml")
|
|
407
|
+
console.print(f" 2. Add skills to {plugin_dir}/skills/")
|
|
408
|
+
console.print(f" 3. Add hooks to {plugin_dir}/hooks/")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@main.command()
|
|
412
|
+
@click.option("--output", "-o", type=click.Path(path_type=Path), help="Output path for config file")
|
|
413
|
+
@click.option("--non-interactive", is_flag=True, help="Create minimal config without prompts")
|
|
414
|
+
def init(output: Path | None, non_interactive: bool) -> None:
|
|
415
|
+
"""Create a new ai-config configuration file interactively.
|
|
416
|
+
|
|
417
|
+
\b
|
|
418
|
+
When to use:
|
|
419
|
+
- First-time setup of ai-config in a new project
|
|
420
|
+
- Starting fresh with a new plugin configuration
|
|
421
|
+
- Creating config without writing YAML manually
|
|
422
|
+
|
|
423
|
+
\b
|
|
424
|
+
What you'll see:
|
|
425
|
+
- Interactive wizard walks through marketplace/plugin selection
|
|
426
|
+
- Creates .ai-config/config.yaml (or custom path with -o)
|
|
427
|
+
- Use --non-interactive for minimal empty config
|
|
428
|
+
|
|
429
|
+
\b
|
|
430
|
+
Typical workflow:
|
|
431
|
+
1. Run: ai-config init (interactive wizard)
|
|
432
|
+
2. Follow prompts to add marketplaces and plugins
|
|
433
|
+
3. Run: ai-config sync (install plugins)
|
|
434
|
+
4. Run: ai-config doctor (verify setup)
|
|
435
|
+
"""
|
|
436
|
+
from ai_config.init import create_minimal_config, run_init_wizard, write_config
|
|
437
|
+
|
|
438
|
+
if non_interactive:
|
|
439
|
+
# Generate minimal config without prompts
|
|
440
|
+
init_config = create_minimal_config(output)
|
|
441
|
+
path = write_config(init_config)
|
|
442
|
+
console.print(f"[success]{SYMBOLS['pass']} Created minimal config at {path}[/success]")
|
|
443
|
+
console.print("\n[subheader]Next steps:[/subheader]")
|
|
444
|
+
console.print(" 1. Edit the config file to add marketplaces and plugins")
|
|
445
|
+
console.print(" 2. Run: ai-config sync")
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
result = run_init_wizard(console, output)
|
|
449
|
+
if result is None:
|
|
450
|
+
console.print("[warning]Cancelled[/warning]")
|
|
451
|
+
sys.exit(1)
|
|
452
|
+
|
|
453
|
+
assert result is not None # Type narrowing for type checker
|
|
454
|
+
path = write_config(result)
|
|
455
|
+
console.print()
|
|
456
|
+
console.print(f"[success]{SYMBOLS['pass']} Config created at {path}[/success]")
|
|
457
|
+
console.print("\n[subheader]Next steps:[/subheader]")
|
|
458
|
+
console.print(" ai-config sync # Install plugins")
|
|
459
|
+
console.print(" ai-config doctor # Verify setup")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@main.command()
|
|
463
|
+
@click.option("--config", "-c", "config_path", type=click.Path(exists=True, path_type=Path))
|
|
464
|
+
@click.option(
|
|
465
|
+
"--category",
|
|
466
|
+
type=click.Choice(list(VALIDATORS.keys())),
|
|
467
|
+
multiple=True,
|
|
468
|
+
help="Run only specific validation categories",
|
|
469
|
+
)
|
|
470
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
471
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show all checks including passed")
|
|
472
|
+
def doctor(
|
|
473
|
+
config_path: Path | None,
|
|
474
|
+
category: tuple[str, ...],
|
|
475
|
+
as_json: bool,
|
|
476
|
+
verbose: bool,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Diagnose plugin, marketplace, and component issues.
|
|
479
|
+
|
|
480
|
+
\b
|
|
481
|
+
When to use:
|
|
482
|
+
- Verify setup after sync or update
|
|
483
|
+
- Debug why a plugin or skill isn't working
|
|
484
|
+
- Check for configuration drift or missing dependencies
|
|
485
|
+
|
|
486
|
+
\b
|
|
487
|
+
What you'll see:
|
|
488
|
+
- Shows pass/fail/warn status for each check
|
|
489
|
+
- Failed checks include fix_hint with remediation steps
|
|
490
|
+
- Use --verbose to see all checks (including passed)
|
|
491
|
+
- Use --json for machine-readable output
|
|
492
|
+
|
|
493
|
+
\b
|
|
494
|
+
Checks performed:
|
|
495
|
+
- Marketplace registration and accessibility
|
|
496
|
+
- Plugin installation and enabled state
|
|
497
|
+
- Skill file validity and frontmatter
|
|
498
|
+
- Hook configuration and script existence
|
|
499
|
+
- MCP server configuration
|
|
500
|
+
|
|
501
|
+
\b
|
|
502
|
+
Typical workflow:
|
|
503
|
+
1. Run: ai-config doctor (check health)
|
|
504
|
+
2. Read fix_hint for any failures
|
|
505
|
+
3. Run suggested commands to fix issues
|
|
506
|
+
4. Re-run: ai-config doctor (verify fixes)
|
|
507
|
+
"""
|
|
508
|
+
from ai_config.cli_render import render_doctor_output
|
|
509
|
+
|
|
510
|
+
try:
|
|
511
|
+
actual_config_path = find_config_file(config_path)
|
|
512
|
+
config = load_config(config_path)
|
|
513
|
+
except ConfigError as e:
|
|
514
|
+
error_console.print(f"[error]Error loading config:[/error] {e}")
|
|
515
|
+
sys.exit(1)
|
|
516
|
+
|
|
517
|
+
# Run validators
|
|
518
|
+
categories_to_run = list(category) if category else None
|
|
519
|
+
reports = run_validators_sync(config, actual_config_path, categories_to_run)
|
|
520
|
+
|
|
521
|
+
if as_json:
|
|
522
|
+
output = {
|
|
523
|
+
"reports": {
|
|
524
|
+
cat: {
|
|
525
|
+
"target": report.target,
|
|
526
|
+
"passed": report.passed,
|
|
527
|
+
"has_warnings": report.has_warnings,
|
|
528
|
+
"results": [
|
|
529
|
+
{
|
|
530
|
+
"check_name": r.check_name,
|
|
531
|
+
"status": r.status,
|
|
532
|
+
"message": r.message,
|
|
533
|
+
"details": r.details,
|
|
534
|
+
"fix_hint": r.fix_hint,
|
|
535
|
+
}
|
|
536
|
+
for r in report.results
|
|
537
|
+
],
|
|
538
|
+
}
|
|
539
|
+
for cat, report in reports.items()
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
console.print_json(json.dumps(output))
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
console.print()
|
|
546
|
+
console.print(Panel.fit("[header]ai-config doctor[/header]", border_style="cyan"))
|
|
547
|
+
console.print()
|
|
548
|
+
|
|
549
|
+
_total_pass, _total_warn, total_fail = render_doctor_output(reports, config, console, verbose)
|
|
550
|
+
|
|
551
|
+
if total_fail > 0:
|
|
552
|
+
sys.exit(1)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@main.command()
|
|
556
|
+
@click.option(
|
|
557
|
+
"--config",
|
|
558
|
+
"-c",
|
|
559
|
+
"config_path",
|
|
560
|
+
type=click.Path(exists=True, path_type=Path),
|
|
561
|
+
help="Path to config file",
|
|
562
|
+
)
|
|
563
|
+
@click.option(
|
|
564
|
+
"--debounce",
|
|
565
|
+
type=float,
|
|
566
|
+
default=1.5,
|
|
567
|
+
help="Seconds to wait after changes before syncing",
|
|
568
|
+
)
|
|
569
|
+
@click.option(
|
|
570
|
+
"--dry-run",
|
|
571
|
+
is_flag=True,
|
|
572
|
+
help="Show changes without syncing",
|
|
573
|
+
)
|
|
574
|
+
@click.option(
|
|
575
|
+
"--verbose",
|
|
576
|
+
"-v",
|
|
577
|
+
is_flag=True,
|
|
578
|
+
help="Show all file events",
|
|
579
|
+
)
|
|
580
|
+
def watch(
|
|
581
|
+
config_path: Path | None,
|
|
582
|
+
debounce: float,
|
|
583
|
+
dry_run: bool,
|
|
584
|
+
verbose: bool,
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Watch config and plugin directories, auto-sync on changes.
|
|
587
|
+
|
|
588
|
+
\b
|
|
589
|
+
When to use:
|
|
590
|
+
- During plugin development to auto-sync on skill/hook edits
|
|
591
|
+
- When iterating on config to see changes applied immediately
|
|
592
|
+
- Keeping plugins in sync while editing across multiple files
|
|
593
|
+
|
|
594
|
+
\b
|
|
595
|
+
What you'll see:
|
|
596
|
+
- Which paths are being watched (config + plugin directories)
|
|
597
|
+
- Detected changes grouped by type (config vs plugin)
|
|
598
|
+
- Sync results after each batch of changes
|
|
599
|
+
|
|
600
|
+
\b
|
|
601
|
+
How it works:
|
|
602
|
+
1. Start: ai-config watch
|
|
603
|
+
2. Edit your plugin files or config
|
|
604
|
+
3. Changes are batched (1.5s debounce)
|
|
605
|
+
4. Sync runs automatically
|
|
606
|
+
5. Press Ctrl+C to stop
|
|
607
|
+
|
|
608
|
+
\b
|
|
609
|
+
Important limitation:
|
|
610
|
+
Claude Code only loads plugins at session start. After syncing,
|
|
611
|
+
you must restart Claude Code for changes to take effect.
|
|
612
|
+
Use: claude --resume to continue your previous session.
|
|
613
|
+
"""
|
|
614
|
+
from ai_config.watch import FileChange, collect_watch_paths, run_watch_loop
|
|
615
|
+
|
|
616
|
+
try:
|
|
617
|
+
actual_config_path = find_config_file(config_path)
|
|
618
|
+
config = load_config(config_path)
|
|
619
|
+
except ConfigError as e:
|
|
620
|
+
error_console.print(f"[error]Error loading config:[/error] {e}")
|
|
621
|
+
sys.exit(1)
|
|
622
|
+
|
|
623
|
+
watch_config = collect_watch_paths(config, actual_config_path)
|
|
624
|
+
|
|
625
|
+
# Display watch info
|
|
626
|
+
console.print()
|
|
627
|
+
console.print(Panel.fit("[header]ai-config watch[/header]", border_style="cyan"))
|
|
628
|
+
console.print()
|
|
629
|
+
|
|
630
|
+
console.print("[subheader]Watching:[/subheader]")
|
|
631
|
+
console.print(f" {SYMBOLS['bullet']} Config: {watch_config.config_path}")
|
|
632
|
+
for plugin_dir in watch_config.plugin_directories:
|
|
633
|
+
console.print(f" {SYMBOLS['bullet']} Plugin: {plugin_dir}")
|
|
634
|
+
|
|
635
|
+
if not watch_config.plugin_directories:
|
|
636
|
+
console.print(" [info](no local plugin directories)[/info]")
|
|
637
|
+
|
|
638
|
+
console.print()
|
|
639
|
+
if dry_run:
|
|
640
|
+
console.print("[warning]Dry run: true[/warning]")
|
|
641
|
+
console.print("[info]Press Ctrl+C to stop[/info]")
|
|
642
|
+
console.print()
|
|
643
|
+
console.print(
|
|
644
|
+
"[dim]Note: Claude Code loads plugins at session start. "
|
|
645
|
+
"After changes sync, restart Claude Code to apply them.[/dim]"
|
|
646
|
+
)
|
|
647
|
+
console.print("[dim]Tip: Use 'claude --resume' to continue your previous session.[/dim]")
|
|
648
|
+
console.print()
|
|
649
|
+
|
|
650
|
+
# Track sync count
|
|
651
|
+
sync_count = 0
|
|
652
|
+
|
|
653
|
+
def on_changes(changes: list[FileChange]) -> None:
|
|
654
|
+
"""Handle detected changes."""
|
|
655
|
+
nonlocal sync_count
|
|
656
|
+
sync_count += 1
|
|
657
|
+
|
|
658
|
+
config_changes = [c for c in changes if c.change_type == "config"]
|
|
659
|
+
plugin_changes = [c for c in changes if c.change_type == "plugin_directory"]
|
|
660
|
+
|
|
661
|
+
console.print(f"[subheader]Changes detected (batch #{sync_count}):[/subheader]")
|
|
662
|
+
if config_changes:
|
|
663
|
+
console.print(f" Config: {len(config_changes)} change(s)")
|
|
664
|
+
if verbose:
|
|
665
|
+
for c in config_changes:
|
|
666
|
+
console.print(f" {SYMBOLS['arrow']} {c.event_type}: {c.path}")
|
|
667
|
+
if plugin_changes:
|
|
668
|
+
console.print(f" Plugins: {len(plugin_changes)} change(s)")
|
|
669
|
+
if verbose:
|
|
670
|
+
for c in plugin_changes:
|
|
671
|
+
console.print(f" {SYMBOLS['arrow']} {c.event_type}: {c.path}")
|
|
672
|
+
|
|
673
|
+
# Reload config if it changed
|
|
674
|
+
try:
|
|
675
|
+
current_config = load_config(config_path)
|
|
676
|
+
except ConfigError as e:
|
|
677
|
+
error_console.print(f"[error]Config error:[/error] {e}")
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
if dry_run:
|
|
681
|
+
console.print("[warning]Dry run - no sync performed[/warning]")
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
# Sync
|
|
685
|
+
with Progress(
|
|
686
|
+
SpinnerColumn(),
|
|
687
|
+
TextColumn("[progress.description]{task.description}"),
|
|
688
|
+
console=console,
|
|
689
|
+
transient=True,
|
|
690
|
+
) as progress:
|
|
691
|
+
progress.add_task("Syncing...", total=None)
|
|
692
|
+
results = sync_config(current_config, dry_run=False, fresh=False)
|
|
693
|
+
|
|
694
|
+
total_actions = sum(len(r.actions_taken) for r in results.values())
|
|
695
|
+
if total_actions > 0:
|
|
696
|
+
console.print(f"[success]{SYMBOLS['pass']} Synced {total_actions} action(s)[/success]")
|
|
697
|
+
else:
|
|
698
|
+
console.print(f"[info]{SYMBOLS['pass']} No sync needed[/info]")
|
|
699
|
+
|
|
700
|
+
for result in results.values():
|
|
701
|
+
if result.errors:
|
|
702
|
+
for error in result.errors:
|
|
703
|
+
error_console.print(f" [error]{SYMBOLS['fail']}[/error] {error}")
|
|
704
|
+
|
|
705
|
+
console.print()
|
|
706
|
+
|
|
707
|
+
# Setup signal handler for graceful shutdown
|
|
708
|
+
stop_event = Event()
|
|
709
|
+
|
|
710
|
+
def signal_handler(signum: int, frame: object) -> None:
|
|
711
|
+
console.print("\n[info]Stopping...[/info]")
|
|
712
|
+
stop_event.set()
|
|
713
|
+
|
|
714
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
715
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
716
|
+
|
|
717
|
+
# Run the watch loop
|
|
718
|
+
run_watch_loop(
|
|
719
|
+
watch_config=watch_config,
|
|
720
|
+
on_changes=on_changes,
|
|
721
|
+
stop_event=stop_event,
|
|
722
|
+
debounce_seconds=debounce,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
console.print(f"[success]{SYMBOLS['pass']} Watch stopped[/success]")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
if __name__ == "__main__":
|
|
729
|
+
main()
|