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.
- grimoire/__init__.py +7 -0
- grimoire/__version__.py +1 -0
- grimoire/cli/__init__.py +1 -0
- grimoire/cli/app.py +641 -0
- grimoire/cli/cmd_merge.py +37 -0
- grimoire/cli/cmd_upgrade.py +175 -0
- grimoire/core/__init__.py +16 -0
- grimoire/core/config.py +237 -0
- grimoire/core/exceptions.py +87 -0
- grimoire/core/merge.py +230 -0
- grimoire/core/project.py +230 -0
- grimoire/core/resolver.py +68 -0
- grimoire/core/scanner.py +208 -0
- grimoire/core/validator.py +249 -0
- grimoire/mcp/__init__.py +1 -0
- grimoire/mcp/server.py +310 -0
- grimoire/memory/__init__.py +7 -0
- grimoire/memory/backends/__init__.py +0 -0
- grimoire/memory/backends/base.py +84 -0
- grimoire/memory/backends/local.py +148 -0
- grimoire/memory/backends/ollama.py +254 -0
- grimoire/memory/backends/qdrant.py +238 -0
- grimoire/memory/manager.py +162 -0
- grimoire/py.typed +0 -0
- grimoire/registry/__init__.py +1 -0
- grimoire/registry/agents.py +189 -0
- grimoire/registry/local.py +117 -0
- grimoire/tools/__init__.py +19 -0
- grimoire/tools/_common.py +109 -0
- grimoire/tools/agent_forge.py +228 -0
- grimoire/tools/context_guard.py +259 -0
- grimoire/tools/context_router.py +320 -0
- grimoire/tools/harmony_check.py +358 -0
- grimoire/tools/memory_lint.py +468 -0
- grimoire/tools/preflight_check.py +208 -0
- grimoire/tools/stigmergy.py +356 -0
- grimoire_kit-3.0.0.dist-info/METADATA +910 -0
- grimoire_kit-3.0.0.dist-info/RECORD +41 -0
- grimoire_kit-3.0.0.dist-info/WHEEL +4 -0
- grimoire_kit-3.0.0.dist-info/entry_points.txt +3 -0
- grimoire_kit-3.0.0.dist-info/licenses/LICENSE +21 -0
grimoire/__init__.py
ADDED
grimoire/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "3.0.0"
|
grimoire/cli/__init__.py
ADDED
|
@@ -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)
|