grimoire-kit 3.0.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.
Files changed (41) hide show
  1. grimoire/__init__.py +7 -0
  2. grimoire/__version__.py +1 -0
  3. grimoire/cli/__init__.py +1 -0
  4. grimoire/cli/app.py +641 -0
  5. grimoire/cli/cmd_merge.py +37 -0
  6. grimoire/cli/cmd_upgrade.py +175 -0
  7. grimoire/core/__init__.py +16 -0
  8. grimoire/core/config.py +237 -0
  9. grimoire/core/exceptions.py +87 -0
  10. grimoire/core/merge.py +230 -0
  11. grimoire/core/project.py +230 -0
  12. grimoire/core/resolver.py +68 -0
  13. grimoire/core/scanner.py +208 -0
  14. grimoire/core/validator.py +249 -0
  15. grimoire/mcp/__init__.py +1 -0
  16. grimoire/mcp/server.py +310 -0
  17. grimoire/memory/__init__.py +7 -0
  18. grimoire/memory/backends/__init__.py +0 -0
  19. grimoire/memory/backends/base.py +84 -0
  20. grimoire/memory/backends/local.py +148 -0
  21. grimoire/memory/backends/ollama.py +254 -0
  22. grimoire/memory/backends/qdrant.py +238 -0
  23. grimoire/memory/manager.py +162 -0
  24. grimoire/py.typed +0 -0
  25. grimoire/registry/__init__.py +1 -0
  26. grimoire/registry/agents.py +189 -0
  27. grimoire/registry/local.py +117 -0
  28. grimoire/tools/__init__.py +19 -0
  29. grimoire/tools/_common.py +109 -0
  30. grimoire/tools/agent_forge.py +228 -0
  31. grimoire/tools/context_guard.py +259 -0
  32. grimoire/tools/context_router.py +320 -0
  33. grimoire/tools/harmony_check.py +358 -0
  34. grimoire/tools/memory_lint.py +468 -0
  35. grimoire/tools/preflight_check.py +208 -0
  36. grimoire/tools/stigmergy.py +356 -0
  37. grimoire_kit-3.0.0.dist-info/METADATA +910 -0
  38. grimoire_kit-3.0.0.dist-info/RECORD +41 -0
  39. grimoire_kit-3.0.0.dist-info/WHEEL +4 -0
  40. grimoire_kit-3.0.0.dist-info/entry_points.txt +3 -0
  41. grimoire_kit-3.0.0.dist-info/licenses/LICENSE +21 -0
grimoire/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Grimoire Kit — Composable AI agent platform."""
2
+
3
+ from grimoire.__version__ import __version__
4
+ from grimoire.core.config import GrimoireConfig
5
+ from grimoire.core.exceptions import GrimoireError
6
+
7
+ __all__ = ["GrimoireConfig", "GrimoireError", "__version__"]
@@ -0,0 +1 @@
1
+ __version__ = "3.0.0"
@@ -0,0 +1 @@
1
+ """Grimoire CLI — thin wrapper over core."""
grimoire/cli/app.py ADDED
@@ -0,0 +1,641 @@
1
+ """Grimoire CLI entry point — ``grimoire [command]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from grimoire.__version__ import __version__
13
+ from grimoire.core.config import GrimoireConfig
14
+ from grimoire.core.exceptions import GrimoireConfigError, GrimoireProjectError
15
+
16
+ app = typer.Typer(
17
+ name="grimoire",
18
+ help="Grimoire Kit — Composable AI agent platform.",
19
+ no_args_is_help=True,
20
+ )
21
+
22
+ console = Console(stderr=True)
23
+
24
+
25
+ def _version_callback(value: bool) -> None:
26
+ if value:
27
+ typer.echo(f"grimoire-kit {__version__}")
28
+ raise typer.Exit
29
+
30
+
31
+ @app.callback()
32
+ def main(
33
+ version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True,
34
+ help="Show version and exit."),
35
+ ) -> None:
36
+ """Grimoire Kit — Composable AI agent platform."""
37
+
38
+
39
+ # ── grimoire init ─────────────────────────────────────────────────────────────────
40
+
41
+ _TEMPLATE_YAML = """\
42
+ # Grimoire Kit — Project Context
43
+ # Run: grimoire doctor to validate this file.
44
+
45
+ project:
46
+ name: "{name}"
47
+ description: ""
48
+ type: "webapp"
49
+ stack: []
50
+ repos:
51
+ - name: "{name}"
52
+ path: "."
53
+ default_branch: "main"
54
+
55
+ user:
56
+ name: ""
57
+ language: "Français"
58
+ skill_level: "intermediate"
59
+
60
+ memory:
61
+ backend: "{backend}"
62
+
63
+ agents:
64
+ archetype: "{archetype}"
65
+ custom_agents: []
66
+
67
+ installed_archetypes: []
68
+ """
69
+
70
+ _KNOWN_ARCHETYPES = frozenset({
71
+ "minimal", "web-app", "creative-studio", "fix-loop",
72
+ "infra-ops", "meta", "stack", "features", "platform-engineering",
73
+ })
74
+
75
+ _KNOWN_BACKENDS = frozenset({"auto", "local", "qdrant-local", "qdrant-server", "ollama"})
76
+
77
+ _init_path_arg = typer.Argument(Path("."), help="Project directory to initialise.")
78
+ _init_name_opt = typer.Option("", help="Project name (default: directory name).")
79
+ _init_force_opt = typer.Option(False, "--force", "-f", help="Overwrite existing config.")
80
+ _init_archetype_opt = typer.Option("minimal", "--archetype", "-a", help="Agent archetype to use.")
81
+ _init_backend_opt = typer.Option("auto", "--backend", "-b", help="Memory backend (auto, local, qdrant-local, qdrant-server, ollama).")
82
+
83
+
84
+ @app.command()
85
+ def init(
86
+ path: Path = _init_path_arg,
87
+ name: str = _init_name_opt,
88
+ force: bool = _init_force_opt,
89
+ archetype: str = _init_archetype_opt,
90
+ backend: str = _init_backend_opt,
91
+ ) -> None:
92
+ """Initialise a Grimoire project (creates project-context.yaml)."""
93
+ target = path.resolve()
94
+ target.mkdir(parents=True, exist_ok=True)
95
+ config_file = target / "project-context.yaml"
96
+
97
+ if config_file.exists() and not force:
98
+ console.print(f"[yellow]project-context.yaml already exists at {target}[/yellow]")
99
+ console.print("Use --force to overwrite.")
100
+ raise typer.Exit(1)
101
+
102
+ # Validate archetype
103
+ if archetype not in _KNOWN_ARCHETYPES:
104
+ console.print(f"[red]Unknown archetype:[/red] {archetype}")
105
+ console.print(f"Available: {', '.join(sorted(_KNOWN_ARCHETYPES))}")
106
+ raise typer.Exit(1)
107
+
108
+ # Validate backend
109
+ if backend not in _KNOWN_BACKENDS:
110
+ console.print(f"[red]Unknown backend:[/red] {backend}")
111
+ console.print(f"Available: {', '.join(sorted(_KNOWN_BACKENDS))}")
112
+ raise typer.Exit(1)
113
+
114
+ project_name = name or target.name
115
+ config_file.write_text(_TEMPLATE_YAML.format(name=project_name, archetype=archetype, backend=backend))
116
+
117
+ # Create standard directories
118
+ for d in ("_grimoire/_memory", "_grimoire-output"):
119
+ (target / d).mkdir(parents=True, exist_ok=True)
120
+
121
+ console.print(f"[green]Initialised Grimoire project:[/green] {project_name}")
122
+ console.print(f" Config: {config_file}")
123
+
124
+
125
+ # ── grimoire doctor ───────────────────────────────────────────────────────────────
126
+
127
+ _doctor_path_arg = typer.Argument(Path("."), help="Project root to diagnose.")
128
+
129
+
130
+ @app.command()
131
+ def doctor(
132
+ path: Path = _doctor_path_arg,
133
+ ) -> None:
134
+ """Diagnose a Grimoire project — check config, structure, health."""
135
+ target = path.resolve()
136
+ checks_ok = 0
137
+ checks_fail = 0
138
+
139
+ def ok(msg: str) -> None:
140
+ nonlocal checks_ok
141
+ checks_ok += 1
142
+ console.print(f" [green]OK[/green] {msg}")
143
+
144
+ def fail(msg: str) -> None:
145
+ nonlocal checks_fail
146
+ checks_fail += 1
147
+ console.print(f" [red]FAIL[/red] {msg}")
148
+
149
+ console.print(f"[bold]Grimoire Doctor[/bold] — grimoire-kit {__version__}")
150
+ console.print(f"Project: {target}\n")
151
+
152
+ # 1. Config file
153
+ config_path = target / "project-context.yaml"
154
+ if config_path.is_file():
155
+ ok("project-context.yaml found")
156
+ else:
157
+ fail("project-context.yaml not found — run [bold]grimoire init[/bold]")
158
+ console.print(f"\n[bold]{checks_ok} OK, {checks_fail} FAIL[/bold]")
159
+ raise typer.Exit(1)
160
+
161
+ # 2. Parse config
162
+ try:
163
+ cfg = GrimoireConfig.from_yaml(config_path)
164
+ ok(f"Config valid — project: {cfg.project.name}")
165
+ except GrimoireConfigError as exc:
166
+ fail(f"Config parse error: {exc}")
167
+ console.print(f"\n[bold]{checks_ok} OK, {checks_fail} FAIL[/bold]")
168
+ raise typer.Exit(1) from None
169
+
170
+ # 3. Structure checks
171
+ for d in ("_grimoire", "_grimoire-output"):
172
+ if (target / d).is_dir():
173
+ ok(f"{d}/ directory present")
174
+ else:
175
+ fail(f"{d}/ directory missing")
176
+
177
+ # 4. Memory directory
178
+ mem_dir = target / "_grimoire" / "_memory"
179
+ if mem_dir.is_dir():
180
+ ok("_grimoire/_memory/ exists")
181
+ else:
182
+ fail("_grimoire/_memory/ missing")
183
+
184
+ # 5. Archetype check
185
+ if cfg.agents.archetype:
186
+ ok(f"Archetype configured: {cfg.agents.archetype}")
187
+
188
+ # 6. Summary
189
+ total = checks_ok + checks_fail
190
+ console.print(f"\n[bold]{checks_ok}/{total} checks passed[/bold]")
191
+ if checks_fail > 0:
192
+ raise typer.Exit(1)
193
+
194
+
195
+ # ── grimoire status ───────────────────────────────────────────────────────────────
196
+
197
+ _status_path_arg = typer.Argument(Path("."), help="Project root.")
198
+
199
+
200
+ @app.command()
201
+ def status(
202
+ path: Path = _status_path_arg,
203
+ ) -> None:
204
+ """Show project dashboard — config, agents, memory, health."""
205
+ target = path.resolve()
206
+ config_path = target / "project-context.yaml"
207
+
208
+ if not config_path.is_file():
209
+ console.print("[red]Not a Grimoire project[/red] — run [bold]grimoire init[/bold] first.")
210
+ raise typer.Exit(1)
211
+
212
+ try:
213
+ cfg = GrimoireConfig.from_yaml(config_path)
214
+ except GrimoireConfigError as exc:
215
+ console.print(f"[red]Config error:[/red] {exc}")
216
+ raise typer.Exit(1) from None
217
+
218
+ # Header
219
+ console.print(f"\n[bold]Grimoire Project:[/bold] {cfg.project.name}")
220
+ console.print(f"grimoire-kit {__version__}\n")
221
+
222
+ # Project table
223
+ tbl = Table(title="Project", show_header=False, padding=(0, 2))
224
+ tbl.add_column("Key", style="bold")
225
+ tbl.add_column("Value")
226
+ tbl.add_row("Name", cfg.project.name)
227
+ tbl.add_row("Type", cfg.project.type)
228
+ if cfg.project.stack:
229
+ tbl.add_row("Stack", ", ".join(cfg.project.stack))
230
+ if cfg.project.repos:
231
+ tbl.add_row("Repos", ", ".join(r.name for r in cfg.project.repos))
232
+ tbl.add_row("User", cfg.user.name or "(not set)")
233
+ tbl.add_row("Language", cfg.user.language)
234
+ tbl.add_row("Skill level", cfg.user.skill_level)
235
+ console.print(tbl)
236
+
237
+ # Agents
238
+ console.print("\n[bold]Agents[/bold]")
239
+ console.print(f" Archetype: {cfg.agents.archetype}")
240
+ if cfg.agents.custom_agents:
241
+ console.print(f" Custom: {', '.join(cfg.agents.custom_agents)}")
242
+
243
+ # Memory
244
+ console.print("\n[bold]Memory[/bold]")
245
+ console.print(f" Backend: {cfg.memory.backend}")
246
+
247
+ # Structure health
248
+ console.print("\n[bold]Structure[/bold]")
249
+ dirs = ["_grimoire", "_grimoire-output", "_grimoire/_memory"]
250
+ for d in dirs:
251
+ icon = "[green]✓[/green]" if (target / d).is_dir() else "[red]✗[/red]"
252
+ console.print(f" {icon} {d}/")
253
+
254
+ console.print()
255
+
256
+
257
+ # ── grimoire add / remove ─────────────────────────────────────────────────────────
258
+
259
+ def _load_yaml_rw(config_path: Path) -> tuple[Any, Any]:
260
+ """Load YAML preserving formatting (for round-trip editing)."""
261
+ from ruamel.yaml import YAML
262
+
263
+ yaml = YAML()
264
+ yaml.preserve_quotes = True
265
+ with open(config_path, encoding="utf-8") as fh:
266
+ data = yaml.load(fh)
267
+ return yaml, data
268
+
269
+
270
+ def _save_yaml_rw(yaml: Any, data: Any, config_path: Path) -> None:
271
+ """Write YAML back preserving formatting."""
272
+ with open(config_path, "w", encoding="utf-8") as fh:
273
+ yaml.dump(data, fh)
274
+
275
+
276
+ def _find_config(path: Path) -> Path:
277
+ """Resolve and validate project-context.yaml."""
278
+ target = path.resolve()
279
+ config_path = target / "project-context.yaml"
280
+ if not config_path.is_file():
281
+ console.print("[red]Not a Grimoire project[/red] — run [bold]grimoire init[/bold] first.")
282
+ raise typer.Exit(1)
283
+ return config_path
284
+
285
+
286
+ _add_agent_id = typer.Argument(..., help="Agent identifier to add.")
287
+ _add_path_arg = typer.Argument(Path("."), help="Project root.")
288
+
289
+
290
+ @app.command("add")
291
+ def add_agent(
292
+ agent_id: str = _add_agent_id,
293
+ path: Path = _add_path_arg,
294
+ ) -> None:
295
+ """Add a custom agent to the project configuration."""
296
+ config_path = _find_config(path)
297
+ yaml, data = _load_yaml_rw(config_path)
298
+
299
+ agents = data.get("agents") or {}
300
+ custom: list[str] = agents.get("custom_agents") or []
301
+
302
+ if agent_id in custom:
303
+ console.print(f"[yellow]Agent '{agent_id}' already in project.[/yellow]")
304
+ raise typer.Exit(0)
305
+
306
+ custom.append(agent_id)
307
+ agents["custom_agents"] = custom
308
+ data["agents"] = agents
309
+ _save_yaml_rw(yaml, data, config_path)
310
+
311
+ console.print(f"[green]Added agent:[/green] {agent_id}")
312
+
313
+
314
+ _rm_agent_id = typer.Argument(..., help="Agent identifier to remove.")
315
+ _rm_path_arg = typer.Argument(Path("."), help="Project root.")
316
+
317
+
318
+ @app.command("remove")
319
+ def remove_agent(
320
+ agent_id: str = _rm_agent_id,
321
+ path: Path = _rm_path_arg,
322
+ ) -> None:
323
+ """Remove a custom agent from the project configuration."""
324
+ config_path = _find_config(path)
325
+ yaml, data = _load_yaml_rw(config_path)
326
+
327
+ agents = data.get("agents") or {}
328
+ custom: list[str] = agents.get("custom_agents") or []
329
+
330
+ if agent_id not in custom:
331
+ console.print(f"[yellow]Agent '{agent_id}' not in project.[/yellow]")
332
+ raise typer.Exit(1)
333
+
334
+ custom.remove(agent_id)
335
+ agents["custom_agents"] = custom
336
+ data["agents"] = agents
337
+ _save_yaml_rw(yaml, data, config_path)
338
+
339
+ console.print(f"[green]Removed agent:[/green] {agent_id}")
340
+
341
+
342
+ # ── grimoire validate ─────────────────────────────────────────────────────────────
343
+
344
+ _validate_path_arg = typer.Argument(Path("."), help="Project root to validate.")
345
+
346
+
347
+ @app.command("validate")
348
+ def validate(
349
+ path: Path = _validate_path_arg,
350
+ ) -> None:
351
+ """Validate project-context.yaml against the Grimoire schema."""
352
+ from grimoire.core.validator import validate_config
353
+ from grimoire.tools._common import load_yaml
354
+
355
+ target = path.resolve()
356
+ config_path = target / "project-context.yaml"
357
+
358
+ if not config_path.is_file():
359
+ console.print("[red]No project-context.yaml found.[/red]")
360
+ raise typer.Exit(1)
361
+
362
+ data = load_yaml(config_path)
363
+ errors = validate_config(data, project_root=target)
364
+
365
+ if not errors:
366
+ console.print("[green]project-context.yaml is valid.[/green]")
367
+ raise typer.Exit(0)
368
+
369
+ console.print(f"[red]Found {len(errors)} validation error(s):[/red]\n")
370
+ for err in errors:
371
+ console.print(f" [red]•[/red] {err}")
372
+ raise typer.Exit(1)
373
+
374
+
375
+ # ── grimoire up ───────────────────────────────────────────────────────────────────
376
+
377
+ _up_path_arg = typer.Argument(Path("."), help="Project root.")
378
+ _up_dry_run_opt = typer.Option(False, "--dry-run", help="Show plan without applying.")
379
+
380
+
381
+ @app.command("up")
382
+ def up(
383
+ path: Path = _up_path_arg,
384
+ dry_run: bool = _up_dry_run_opt,
385
+ ) -> None:
386
+ """Reconcile the project state with project-context.yaml."""
387
+ from grimoire.core.project import GrimoireProject
388
+
389
+ target = path.resolve()
390
+ try:
391
+ project = GrimoireProject(target)
392
+ except GrimoireProjectError as exc:
393
+ console.print(f"[red]{exc}[/red]")
394
+ raise typer.Exit(1) from None
395
+
396
+ cfg = project.config
397
+ status = project.status()
398
+
399
+ if dry_run:
400
+ console.print("[bold]grimoire up --dry-run[/bold]\n")
401
+ else:
402
+ console.print("[bold]grimoire up[/bold]\n")
403
+
404
+ actions: list[str] = []
405
+
406
+ # Ensure standard directories exist
407
+ for d in ("_grimoire", "_grimoire/_memory", "_grimoire-output"):
408
+ dp = target / d
409
+ if not dp.is_dir():
410
+ actions.append(f"Create directory: {d}/")
411
+ if not dry_run:
412
+ dp.mkdir(parents=True, exist_ok=True)
413
+
414
+ # Ensure agents dir exists
415
+ agents_dir = target / "_grimoire" / "agents"
416
+ if not agents_dir.is_dir():
417
+ actions.append("Create directory: _grimoire/agents/")
418
+ if not dry_run:
419
+ agents_dir.mkdir(parents=True, exist_ok=True)
420
+
421
+ # Summary
422
+ if actions:
423
+ for a in actions:
424
+ icon = "[cyan]plan[/cyan]" if dry_run else "[green]done[/green]"
425
+ console.print(f" {icon} {a}")
426
+ else:
427
+ console.print(" [green]Everything up to date.[/green]")
428
+
429
+ # Health summary
430
+ console.print(f"\n[bold]Project:[/bold] {cfg.project.name}")
431
+ console.print(f" Archetype: {cfg.agents.archetype}")
432
+ console.print(f" Memory: {cfg.memory.backend}")
433
+ console.print(f" Agents: {status.agents_count}")
434
+
435
+ if status.directories_missing:
436
+ missing = ", ".join(status.directories_missing)
437
+ console.print(f"\n[yellow]Missing dirs (after up):[/yellow] {missing}")
438
+
439
+
440
+ # ── grimoire registry ─────────────────────────────────────────────────────────────
441
+
442
+ registry_app = typer.Typer(help="Browse the agent registry.")
443
+ app.add_typer(registry_app, name="registry")
444
+
445
+ _reg_query_arg = typer.Argument(None, help="Search query.")
446
+
447
+
448
+ @registry_app.command("list")
449
+ def registry_list() -> None:
450
+ """List all available archetypes and agents."""
451
+ from grimoire.registry.local import LocalRegistry
452
+ from grimoire.tools._common import find_project_root
453
+
454
+ try:
455
+ root = find_project_root()
456
+ except FileNotFoundError:
457
+ console.print("[red]Not in a Grimoire project — cannot locate kit root.[/red]")
458
+ raise typer.Exit(1) from None
459
+
460
+ reg = LocalRegistry(root)
461
+ archs = reg.list_archetypes()
462
+ if not archs:
463
+ console.print("[yellow]No archetypes found.[/yellow]")
464
+ return
465
+
466
+ tbl = Table(title="Available Archetypes")
467
+ tbl.add_column("Archetype", style="bold")
468
+ tbl.add_column("Agents", justify="right")
469
+
470
+ for arch_id in archs:
471
+ try:
472
+ dna = reg.inspect_archetype(arch_id)
473
+ tbl.add_row(arch_id, str(len(dna.agents)))
474
+ except Exception:
475
+ tbl.add_row(arch_id, "?")
476
+
477
+ console.print(tbl)
478
+
479
+
480
+ @registry_app.command("search")
481
+ def registry_search(
482
+ query: str = _reg_query_arg,
483
+ ) -> None:
484
+ """Search agents by keyword."""
485
+ from grimoire.registry.local import LocalRegistry
486
+ from grimoire.tools._common import find_project_root
487
+
488
+ if not query:
489
+ console.print("[red]Please provide a search query.[/red]")
490
+ raise typer.Exit(1)
491
+
492
+ try:
493
+ root = find_project_root()
494
+ except FileNotFoundError:
495
+ console.print("[red]Not in a Grimoire project.[/red]")
496
+ raise typer.Exit(1) from None
497
+
498
+ reg = LocalRegistry(root)
499
+ results = reg.search(query)
500
+
501
+ if not results:
502
+ console.print(f"[yellow]No agents matching '{query}'.[/yellow]")
503
+ return
504
+
505
+ tbl = Table(title=f"Search: {query}")
506
+ tbl.add_column("Agent", style="bold")
507
+ tbl.add_column("Archetype")
508
+ tbl.add_column("Description")
509
+
510
+ for item in results:
511
+ tbl.add_row(item.id, item.archetype, item.description or "—")
512
+
513
+ console.print(tbl)
514
+
515
+
516
+ # ── grimoire upgrade ──────────────────────────────────────────────────────────────
517
+
518
+ _upgrade_path_arg = typer.Argument(Path("."), help="Path to the v2 project.")
519
+ _upgrade_dry_run_opt = typer.Option(False, "--dry-run", "-n", help="Show plan without applying.")
520
+
521
+
522
+ @app.command("upgrade")
523
+ def upgrade(
524
+ path: Path = _upgrade_path_arg,
525
+ dry_run: bool = _upgrade_dry_run_opt,
526
+ ) -> None:
527
+ """Migrate a v2 project to v3 structure."""
528
+ from grimoire.cli.cmd_upgrade import (
529
+ detect_version,
530
+ execute_upgrade,
531
+ plan_upgrade,
532
+ )
533
+
534
+ target = path.resolve()
535
+ version = detect_version(target)
536
+
537
+ if version == "v3":
538
+ console.print("[green]Project is already v3 — nothing to do.[/green]")
539
+ return
540
+
541
+ if version == "unknown":
542
+ console.print("[red]No v2 project-context.yaml found at this path.[/red]")
543
+ raise typer.Exit(1)
544
+
545
+ plan = plan_upgrade(target)
546
+
547
+ if dry_run:
548
+ console.print("[bold]grimoire upgrade --dry-run[/bold]\n")
549
+ else:
550
+ console.print("[bold]grimoire upgrade[/bold]\n")
551
+
552
+ if plan.warnings:
553
+ for w in plan.warnings:
554
+ console.print(f" [yellow]⚠ {w}[/yellow]")
555
+
556
+ completed = execute_upgrade(target, plan, dry_run=dry_run)
557
+ for desc in completed:
558
+ icon = "[cyan]plan[/cyan]" if dry_run else "[green]done[/green]"
559
+ console.print(f" {icon} {desc}")
560
+
561
+ if not completed and not plan.warnings:
562
+ console.print(" [green]Nothing to do.[/green]")
563
+
564
+ console.print(f"\n[bold]Migration {'planned' if dry_run else 'complete'}.[/bold]")
565
+
566
+
567
+ # ── grimoire merge ────────────────────────────────────────────────────────────────
568
+
569
+ _merge_from_arg = typer.Argument(..., help="Source directory to merge from.")
570
+ _merge_target_opt = typer.Option(Path("."), "--target", "-t", help="Target project directory.")
571
+ _merge_dry_run_opt = typer.Option(False, "--dry-run", "-n", help="Show plan without merging.")
572
+ _merge_force_opt = typer.Option(False, "--force", "-f", help="Overwrite conflicting files.")
573
+
574
+
575
+ @app.command("merge")
576
+ def merge(
577
+ source: Path = _merge_from_arg,
578
+ target: Path = _merge_target_opt,
579
+ dry_run: bool = _merge_dry_run_opt,
580
+ force: bool = _merge_force_opt,
581
+ undo: bool = typer.Option(False, "--undo", help="Undo the last merge in the target."),
582
+ ) -> None:
583
+ """Merge Grimoire files from a source into a project."""
584
+ from grimoire.cli.cmd_merge import run_merge, run_undo
585
+ from grimoire.core.exceptions import GrimoireMergeError
586
+
587
+ resolved_target = target.resolve()
588
+
589
+ if undo:
590
+ try:
591
+ deleted = run_undo(resolved_target)
592
+ except GrimoireMergeError as exc:
593
+ console.print(f"[red]{exc}[/red]")
594
+ raise typer.Exit(1) from None
595
+
596
+ if deleted:
597
+ for f in deleted:
598
+ console.print(f" [red]deleted[/red] {f}")
599
+ console.print(f"\n[bold]Undo complete — {len(deleted)} file(s) removed.[/bold]")
600
+ else:
601
+ console.print("[yellow]Nothing to undo.[/yellow]")
602
+ return
603
+
604
+ resolved_source = source.resolve()
605
+
606
+ try:
607
+ plan, result = run_merge(
608
+ resolved_source, resolved_target, dry_run=dry_run, force=force,
609
+ )
610
+ except GrimoireMergeError as exc:
611
+ console.print(f"[red]{exc}[/red]")
612
+ raise typer.Exit(1) from None
613
+
614
+ label = "grimoire merge --dry-run" if dry_run else "grimoire merge"
615
+ console.print(f"[bold]{label}[/bold]\n")
616
+
617
+ if plan.warnings:
618
+ for w in plan.warnings:
619
+ console.print(f" [yellow]⚠ {w}[/yellow]")
620
+
621
+ for f in result.files_created:
622
+ icon = "[cyan]plan[/cyan]" if dry_run else "[green]created[/green]"
623
+ console.print(f" {icon} {f}")
624
+
625
+ for f in result.files_skipped:
626
+ console.print(f" [yellow]skipped[/yellow] {f}")
627
+
628
+ for d in result.directories_created:
629
+ icon = "[cyan]plan[/cyan]" if dry_run else "[green]mkdir[/green]"
630
+ console.print(f" {icon} {d}/")
631
+
632
+ total = len(result.files_created) + len(result.files_skipped)
633
+ console.print(f"\n[bold]{total} file(s) processed.[/bold]")
634
+
635
+
636
+ # ── Entry point ───────────────────────────────────────────────────────────────
637
+
638
+ def cli() -> None:
639
+ """Typer entry point for ``grimoire`` console script."""
640
+ app()
641
+
@@ -0,0 +1,37 @@
1
+ """``grimoire merge`` — merge Grimoire files from a source into a project.
2
+
3
+ Wraps :class:`bmad.core.merge.MergeEngine` with CLI output and
4
+ confirmation prompts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from grimoire.core.merge import MergeEngine, MergePlan, MergeResult
12
+
13
+
14
+ def run_merge(
15
+ source: Path,
16
+ target: Path,
17
+ *,
18
+ dry_run: bool = False,
19
+ force: bool = False,
20
+ ) -> tuple[MergePlan, MergeResult]:
21
+ """Analyse and execute a merge.
22
+
23
+ Returns the plan and result for the CLI to display.
24
+ """
25
+ engine = MergeEngine(source, target)
26
+ plan = engine.analyze()
27
+ result = engine.execute(plan, dry_run=dry_run, force=force)
28
+ return plan, result
29
+
30
+
31
+ def run_undo(target: Path) -> list[str]:
32
+ """Undo the last merge in *target* using its log file.
33
+
34
+ Returns the list of deleted file paths.
35
+ """
36
+ log_path = target / ".grimoire-merge-log.json"
37
+ return MergeEngine.undo(log_path)