d365fo-agent-developer 0.6.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.
d365fo_agent/cli.py ADDED
@@ -0,0 +1,651 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+
9
+ from d365fo_agent.build import BuildRunner
10
+ from d365fo_agent.generator import build_generation_bundle, generate_from_spec_file
11
+ from d365fo_agent.graph_query import GraphIndex, discover_graph_path
12
+ from d365fo_agent.graphify_runner import run_graphify_staging
13
+ from d365fo_agent.indexer import (
14
+ build_catalog,
15
+ find_artifacts,
16
+ find_references,
17
+ find_reverse_references,
18
+ get_artifact_details,
19
+ merge_catalogs,
20
+ summarize_classifications,
21
+ )
22
+ from d365fo_agent.index_store import build_index_file
23
+ from d365fo_agent.linter import lint_artifact, load_lint_config
24
+ from d365fo_agent.packageslocal_export import export_packageslocal_to_graphify
25
+ from d365fo_agent.rules import load_rules
26
+ from d365fo_agent.specs import ArtifactSpec, build_artifact_plans, load_spec
27
+ from d365fo_agent.validate import FAMILY_ROOT, validate_file, validate_xml
28
+
29
+
30
+ def _force_utf8(*streams: object) -> None:
31
+ """Force UTF-8 on the given text streams. On Windows stdout/stdin default to cp1252,
32
+ which raises UnicodeEncodeError on any character outside that codepage (✓, CJK, …) and
33
+ would corrupt the JSON-RPC stream. MCP mandates UTF-8, so we enforce it everywhere."""
34
+ for stream in streams:
35
+ try:
36
+ stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
37
+ except (AttributeError, ValueError):
38
+ pass
39
+
40
+
41
+ def main(argv: list[str] | None = None) -> int:
42
+ _force_utf8(sys.stdout, sys.stderr, sys.stdin)
43
+ parser = _build_parser()
44
+ args = parser.parse_args(argv)
45
+
46
+ if args.command == "inventory":
47
+ catalog = _build_catalog_from_args(args)
48
+ payload = {
49
+ "model_count": len(catalog.models),
50
+ "artifact_count": len(catalog.artifacts),
51
+ "classification_summary": summarize_classifications(catalog),
52
+ }
53
+ _dump_json(payload)
54
+ return 0
55
+
56
+ if args.command == "find-element":
57
+ catalog = _build_catalog_from_args(args)
58
+ matches = [artifact.to_dict() for artifact in find_artifacts(catalog, args.name, args.artifact_type)]
59
+ _dump_json({"matches": matches})
60
+ return 0
61
+
62
+ if args.command == "get-element-details":
63
+ catalog = _build_catalog_from_args(args)
64
+ _dump_json(get_artifact_details(catalog, args.name))
65
+ return 0
66
+
67
+ if args.command == "find-references":
68
+ matches = find_references(args.repo_root, args.symbol)
69
+ _dump_json({"matches": matches})
70
+ return 0
71
+
72
+ if args.command == "find-reverse-references":
73
+ catalog = _build_catalog_from_args(args)
74
+ _dump_json({"matches": find_reverse_references(catalog, args.symbol)})
75
+ return 0
76
+
77
+ if args.command == "build-project":
78
+ runner = BuildRunner(msbuild_executable=args.msbuild)
79
+ result = runner.build_project(
80
+ args.project,
81
+ execute=args.execute,
82
+ output_path=args.output_path,
83
+ )
84
+ _dump_json(asdict(result))
85
+ return 0 if result.status in {"planned", "succeeded"} else 1
86
+
87
+ if args.command == "compile-model":
88
+ from d365fo_agent.build import XppCompiler
89
+
90
+ compiler = XppCompiler(args.packages_root, xppc_path=args.xppc)
91
+ out = Path(args.output_dir)
92
+ result = compiler.compile_model(
93
+ args.model,
94
+ output_path=out / "out",
95
+ log_path=out / "compile.log",
96
+ appchecker=args.appchecker,
97
+ xref_file=(out / "xref.txt") if args.xref else None,
98
+ )
99
+ _dump_json(result.to_dict())
100
+ if result.status == "succeeded":
101
+ return 0
102
+ return 2 if result.status == "unavailable" else 1
103
+
104
+ if args.command == "compile-generated":
105
+ from d365fo_agent.build import XppCompiler
106
+
107
+ package = args.package or args.model
108
+ files: list[Path] = []
109
+ if args.generated_dir:
110
+ files += [f for f in Path(args.generated_dir).rglob("*.xml") if not f.name.startswith("generation-")]
111
+ files += [Path(p) for p in (args.file or [])]
112
+ overlays = [
113
+ (f"{package}/{args.model}/{f.parent.name}/{f.name}", f.read_text(encoding="utf-8", errors="ignore"))
114
+ for f in files
115
+ ]
116
+ if not overlays:
117
+ _dump_json({"status": "failed", "message": "No artifact files found (use --generated-dir and/or --file)."})
118
+ return 1
119
+ compiler = XppCompiler(args.packages_root)
120
+ out = Path(args.output_dir)
121
+ result = compiler.compile_overlay(
122
+ args.model, overlays, output_path=out / "out", log_path=out / "compile.log", appchecker=args.appchecker
123
+ )
124
+ _dump_json({**result.to_dict(), "overlaid": [rel for rel, _ in overlays]})
125
+ if result.status == "succeeded":
126
+ return 0
127
+ return 2 if result.status == "unavailable" else 1
128
+
129
+ if args.command == "analyze-spec":
130
+ spec = load_spec(args.spec)
131
+ catalog = _build_catalog_from_args(args)
132
+ plans = build_artifact_plans(spec)
133
+ spec_blocks = spec.artifact_specs if spec.artifact_specs else [ArtifactSpec(spec.title, spec.metadata, spec.sections)]
134
+ graph_index = None
135
+ resolved_graph_path = Path(args.graph) if getattr(args, "graph", None) else discover_graph_path(args.repo_root)
136
+ if resolved_graph_path and resolved_graph_path.exists():
137
+ graph_index = GraphIndex(resolved_graph_path)
138
+ bundles = [
139
+ build_generation_bundle(
140
+ spec_block,
141
+ plan,
142
+ catalog,
143
+ args.repo_root,
144
+ example_limit=args.example_limit,
145
+ graph_index=graph_index,
146
+ graph_query=(plan.artifact_name or plan.target_object or plan.service_class),
147
+ )
148
+ for spec_block, plan in zip(spec_blocks, plans, strict=True)
149
+ ]
150
+ payload: dict[str, object] = {"spec": spec.to_dict(), "artifacts": bundles}
151
+ if len(plans) == 1:
152
+ payload["artifact_plan"] = plans[0].to_dict()
153
+ payload["examples"] = bundles[0]["examples"]
154
+ else:
155
+ payload["artifact_plans"] = [plan.to_dict() for plan in plans]
156
+ _dump_json(payload)
157
+ return 0
158
+
159
+ if args.command == "generate-from-spec":
160
+ result = generate_from_spec_file(
161
+ args.spec,
162
+ args.repo_root,
163
+ args.rules,
164
+ args.output_dir,
165
+ example_limit=args.example_limit,
166
+ graph_path=getattr(args, "graph", None),
167
+ db_path=getattr(args, "db", None),
168
+ )
169
+ _dump_json(result)
170
+ return 0
171
+
172
+ if args.command == "export-packageslocal-graphify":
173
+ result = export_packageslocal_to_graphify(args.packages_root, args.output_dir)
174
+ _dump_json(result)
175
+ return 0
176
+
177
+ if args.command == "run-graphify-staging":
178
+ result = run_graphify_staging(args.staging_dir, args.output_dir, include_html=not args.no_html)
179
+ _dump_json(result)
180
+ return 0
181
+
182
+ if args.command == "build-index":
183
+ if not args.repo_root and not args.packages_root:
184
+ parser.error("build-index needs --repo-root (custom) and/or --packages-root (standard).")
185
+ # repo_root/rules are optional: omit them to build a STANDARD-only index (the shippable
186
+ # knowledge base) from a PackagesLocalDirectory alone. --repo-root is repeatable: all
187
+ # corpora are merged into ONE catalog because rebuilding "custom" replaces every custom row.
188
+ catalog = None
189
+ if args.repo_root:
190
+ rules = load_rules(args.rules)
191
+ catalog = merge_catalogs([build_catalog(Path(root), rules) for root in args.repo_root])
192
+
193
+ def _progress(package: str, count: int) -> None:
194
+ sys.stderr.write(f"[build-index] {package}: +{count}\n")
195
+ sys.stderr.flush()
196
+
197
+ stats = build_index_file(
198
+ args.db,
199
+ catalog,
200
+ packages_root=args.packages_root,
201
+ rebuild=args.rebuild,
202
+ progress=_progress if args.packages_root else None,
203
+ exclude_packages=args.exclude_package,
204
+ )
205
+ _dump_json(stats)
206
+ return 0
207
+
208
+ if args.command == "extract-aot-relations":
209
+ from d365fo_agent.aot_relations import extract_aot_relations
210
+
211
+ def _rel_progress(root: str, count: int) -> None:
212
+ sys.stderr.write(f"[extract-aot-relations] {root}: {count} relations\n")
213
+ sys.stderr.flush()
214
+
215
+ stats = extract_aot_relations(args.root, args.db, progress=_rel_progress)
216
+ _dump_json(stats)
217
+ return 0
218
+
219
+ if args.command == "serve-mcp":
220
+ from d365fo_agent.mcp_server import build_server_from_config, default_knowledge_db
221
+
222
+ db = args.db or (str(default_knowledge_db()) if default_knowledge_db().exists() else None)
223
+ if not db and not args.repo_root:
224
+ parser.error(
225
+ "No knowledge index found. Run 'd365fo-agent fetch-knowledge' to download the standard "
226
+ "D365 knowledge base, or pass --db <index.db> / --repo-root <your D365 repo>."
227
+ )
228
+ server = build_server_from_config(
229
+ args.repo_root,
230
+ args.rules,
231
+ db_path=db,
232
+ packages_root=args.packages_root,
233
+ methodology_path=args.methodology,
234
+ lint_rules_path=args.lint_rules,
235
+ extra_roots=args.extra_root,
236
+ sql_model_path=args.sql_model,
237
+ )
238
+ server.serve_stdio()
239
+ return 0
240
+
241
+ if args.command == "fetch-knowledge":
242
+ from d365fo_agent.knowledge_fetch import fetch_knowledge
243
+
244
+ result = fetch_knowledge(args.url, args.dest, force=args.force)
245
+ _dump_json(result)
246
+ return 0 if result.get("ok") else 1
247
+
248
+ if args.command == "validate-xml":
249
+ profiles = None
250
+ if args.profiles:
251
+ from d365fo_agent.type_profile import load_type_profiles
252
+
253
+ profiles = load_type_profiles(args.profiles)
254
+ if args.file:
255
+ report = validate_file(args.file, args.family, type_profiles=profiles)
256
+ else:
257
+ report = validate_xml(sys.stdin.read(), args.family, type_profiles=profiles)
258
+ _dump_json(report)
259
+ return 0 if report["valid"] else 1
260
+
261
+ if args.command == "build-type-profiles":
262
+ from d365fo_agent.index_store import D365Index
263
+ from d365fo_agent.type_profile import build_type_profiles, default_profiles_path, save_type_profiles
264
+
265
+ roots = [Path(args.repo_root)]
266
+ roots.append(Path(args.packages_root) if args.packages_root else Path(args.repo_root) / "PackagesLocalDirectory")
267
+ index = D365Index(args.db)
268
+ try:
269
+ def _profile_progress(root: str, n: int) -> None:
270
+ sys.stderr.write(f"[type-profiles] {root}: {n}\n")
271
+ sys.stderr.flush()
272
+
273
+ profiles = build_type_profiles(index, roots, sample_per_type=args.sample_per_type, progress=_profile_progress)
274
+ finally:
275
+ index.close()
276
+ out = args.out or str(default_profiles_path(args.db))
277
+ save_type_profiles(profiles, out)
278
+ _dump_json({"types_profiled": len(profiles), "output": str(out).replace("\\", "/"),
279
+ "sample_per_type": args.sample_per_type})
280
+ return 0
281
+
282
+ if args.command == "lint":
283
+ from d365fo_agent.index_store import D365Index
284
+
285
+ cfg_path = args.rules_config or "config/x++-rules.json"
286
+ config = load_lint_config(cfg_path) if Path(cfg_path).exists() else load_lint_config()
287
+ xml_text = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
288
+ index = D365Index(args.db) if args.db else None
289
+ roots = None
290
+ if args.repo_root:
291
+ roots = [Path(args.repo_root)]
292
+ roots.append(Path(args.packages_root) if args.packages_root else Path(args.repo_root) / "PackagesLocalDirectory")
293
+ try:
294
+ report = lint_artifact(xml_text, args.family, index=index, config=config, model=args.model, roots=roots)
295
+ finally:
296
+ if index is not None:
297
+ index.close()
298
+ _dump_json(report)
299
+ return 0 if report["error_count"] == 0 else 1
300
+
301
+ if args.command == "derive-entity":
302
+ from d365fo_agent import entity_derive, knowledge
303
+ from d365fo_agent.index_store import D365Index
304
+
305
+ index = D365Index(args.db)
306
+ try:
307
+ matches = index.lookup_exact(args.source, "AxDataEntityView")
308
+ if not matches:
309
+ index.close()
310
+ _dump_json({"found": False, "source": args.source, "error": "source data entity not found in index"})
311
+ return 1
312
+ roots = [Path(args.repo_root), Path(args.repo_root) / "PackagesLocalDirectory"]
313
+ if args.packages_root:
314
+ roots.append(Path(args.packages_root))
315
+ path = knowledge._resolve_file(matches[0].get("relative_path"), roots)
316
+ if path is None:
317
+ _dump_json({"found": True, "source_available": False, "source": args.source})
318
+ return 1
319
+ source_xml = path.read_text(encoding="utf-8", errors="ignore")
320
+ grants = [g.strip() for g in args.grants.split(",")] if args.grants else None
321
+ entity = entity_derive.derive_public_entity(
322
+ source_xml, args.new_name,
323
+ public_entity_name=args.public_entity_name,
324
+ public_collection_name=args.public_collection_name,
325
+ label=args.label,
326
+ data_management=(True if args.data_management else None),
327
+ staging_table=args.staging_table,
328
+ )
329
+ privilege = entity_derive.build_entity_privilege(
330
+ args.new_name, label=args.privilege_label, grants=grants,
331
+ integration_mode=args.integration_mode or "OData",
332
+ )
333
+ finally:
334
+ index.close()
335
+
336
+ out_dir = Path(args.output_dir)
337
+ (out_dir / "AxDataEntityView").mkdir(parents=True, exist_ok=True)
338
+ (out_dir / "AxSecurityPrivilege").mkdir(parents=True, exist_ok=True)
339
+ entity_file = out_dir / "AxDataEntityView" / f"{entity['name']}.xml"
340
+ priv_file = out_dir / "AxSecurityPrivilege" / f"{privilege['name']}.xml"
341
+ entity_file.write_text(entity["xml"], encoding="utf-8")
342
+ priv_file.write_text(privilege["xml"], encoding="utf-8")
343
+ _dump_json({
344
+ "source": args.source,
345
+ "entity": {k: v for k, v in entity.items() if k != "xml"},
346
+ "entity_file": str(entity_file).replace("\\", "/"),
347
+ "privilege": {k: v for k, v in privilege.items() if k != "xml"},
348
+ "privilege_file": str(priv_file).replace("\\", "/"),
349
+ "review_checklist": entity_derive.REVIEW_CHECKLIST,
350
+ })
351
+ return 0
352
+
353
+ if args.command == "wire-security":
354
+ from d365fo_agent import security_wiring
355
+
356
+ extend_duty = not args.new_duty
357
+ extend_role = not args.new_role
358
+ result = security_wiring.wire_security(
359
+ args.privilege, duty=args.duty, role=args.role,
360
+ extend_duty=extend_duty, extend_role=extend_role, suffix=args.suffix,
361
+ duty_label=args.duty_label, role_label=args.role_label, role_description=args.role_description,
362
+ )
363
+
364
+ # Verify extension targets against the index (warn, don't block — referenced by name only).
365
+ target_checks: list[dict[str, object]] = []
366
+ warnings: list[str] = []
367
+ if args.db:
368
+ from d365fo_agent.index_store import D365Index
369
+
370
+ index = D365Index(args.db)
371
+ try:
372
+ for kind, name, do_extend, atype in (
373
+ ("duty", args.duty, extend_duty, "AxSecurityDuty"),
374
+ ("role", args.role, extend_role, "AxSecurityRole"),
375
+ ):
376
+ if name and do_extend:
377
+ in_index = index.exists(name, atype)
378
+ target_checks.append({"kind": kind, "name": name, "in_index": in_index})
379
+ if not in_index:
380
+ warnings.append(
381
+ f"Extension target {kind} '{name}' is not indexed as {atype}. Confirm the exact "
382
+ f"standard {kind} name, or use --new-{kind} to create a custom object instead."
383
+ )
384
+ finally:
385
+ index.close()
386
+
387
+ out_dir = Path(args.output_dir)
388
+ written = []
389
+ for art in result["artifacts"]:
390
+ folder = FAMILY_ROOT.get(str(art["family"]), "AxUnknown")
391
+ (out_dir / folder).mkdir(parents=True, exist_ok=True)
392
+ fpath = out_dir / folder / f"{art['name']}.xml"
393
+ fpath.write_text(str(art["xml"]), encoding="utf-8")
394
+ written.append({"family": art["family"], "name": art["name"],
395
+ "file": str(fpath).replace("\\", "/"),
396
+ "validate": validate_xml(str(art["xml"]), str(art["family"]))})
397
+ _dump_json({
398
+ "wired": True, "privilege": result["privilege"], "chain": result["chain"],
399
+ "artifacts": written, "target_checks": target_checks, "warnings": warnings,
400
+ "review_checklist": result["review_checklist"],
401
+ })
402
+ return 0
403
+
404
+ if args.command == "scaffold":
405
+ from d365fo_agent import knowledge
406
+ from d365fo_agent.index_store import D365Index
407
+
408
+ properties = {}
409
+ for item in args.set or []:
410
+ if "=" in item:
411
+ key, value = item.split("=", 1)
412
+ properties[key.strip()] = value.strip()
413
+ index = D365Index(args.db)
414
+ try:
415
+ roots = [Path(args.repo_root)]
416
+ roots.append(Path(args.packages_root) if args.packages_root else Path(args.repo_root) / "PackagesLocalDirectory")
417
+ result = knowledge.scaffold_object(
418
+ index, args.artifact_type, roots, new_name=args.new_name, query=args.query,
419
+ properties=properties or None,
420
+ )
421
+ finally:
422
+ index.close()
423
+ if args.output and result.get("xml"):
424
+ out = Path(args.output)
425
+ out.parent.mkdir(parents=True, exist_ok=True)
426
+ out.write_text(str(result["xml"]), encoding="utf-8")
427
+ result["written_to"] = str(out).replace("\\", "/")
428
+ _dump_json(result)
429
+ return 0 if result.get("found") else 1
430
+
431
+ parser.error(f"Unsupported command: {args.command}")
432
+ return 2
433
+
434
+
435
+ def _build_parser() -> argparse.ArgumentParser:
436
+ parser = argparse.ArgumentParser(prog="d365fo-agent")
437
+ subparsers = parser.add_subparsers(dest="command", required=True)
438
+
439
+ inventory = subparsers.add_parser("inventory")
440
+ _add_catalog_args(inventory)
441
+
442
+ find_element = subparsers.add_parser("find-element")
443
+ _add_catalog_args(find_element)
444
+ find_element.add_argument("--name", required=True)
445
+ find_element.add_argument("--artifact-type")
446
+
447
+ element_details = subparsers.add_parser("get-element-details")
448
+ _add_catalog_args(element_details)
449
+ element_details.add_argument("--name", required=True)
450
+
451
+ references = subparsers.add_parser("find-references")
452
+ references.add_argument("--repo-root", required=True)
453
+ references.add_argument("--symbol", required=True)
454
+
455
+ reverse_references = subparsers.add_parser("find-reverse-references")
456
+ _add_catalog_args(reverse_references)
457
+ reverse_references.add_argument("--symbol", required=True)
458
+
459
+ build_project = subparsers.add_parser("build-project")
460
+ build_project.add_argument("--project", required=True)
461
+ build_project.add_argument("--output-path")
462
+ build_project.add_argument("--msbuild", default="msbuild.exe")
463
+ build_project.add_argument("--execute", action="store_true")
464
+
465
+ compile_model_cmd = subparsers.add_parser(
466
+ "compile-model", help="Compile a model with the real X++ compiler (xppc.exe); structured diagnostics."
467
+ )
468
+ compile_model_cmd.add_argument("--packages-root", required=True, help="PackagesLocalDirectory (metadata + bin/xppc.exe).")
469
+ compile_model_cmd.add_argument("--model", required=True, help="Model/module name to compile (e.g. BABAccountsPayable).")
470
+ compile_model_cmd.add_argument("--output-dir", required=True, help="Where the assembly + compile.log are written.")
471
+ compile_model_cmd.add_argument("--appchecker", action="store_true", help="Also run the Best-Practice (Appchecker) rules.")
472
+ compile_model_cmd.add_argument("--xref", action="store_true", help="Also update the cross-reference data.")
473
+ compile_model_cmd.add_argument("--xppc", help="Override path to xppc.exe (default: <packages-root>/bin/xppc.exe).")
474
+
475
+ compile_generated_cmd = subparsers.add_parser(
476
+ "compile-generated",
477
+ help="Overlay generated artifact(s) into their model in the PLD, compile, then restore — proves generated X++ compiles.",
478
+ )
479
+ compile_generated_cmd.add_argument("--packages-root", required=True, help="PackagesLocalDirectory (metadata + bin/xppc.exe).")
480
+ compile_generated_cmd.add_argument("--model", required=True, help="Target model/module the artifacts belong to.")
481
+ compile_generated_cmd.add_argument("--package", help="Package folder (defaults to the model name).")
482
+ compile_generated_cmd.add_argument("--generated-dir", help="A generate-from-spec output dir; all its *.xml are overlaid.")
483
+ compile_generated_cmd.add_argument("--file", action="append", help="An explicit artifact XML file (repeatable).")
484
+ compile_generated_cmd.add_argument("--output-dir", required=True, help="Where the compile log/assembly are written.")
485
+ compile_generated_cmd.add_argument("--appchecker", action="store_true", help="Also run the Best-Practice (Appchecker) rules.")
486
+
487
+ analyze_spec = subparsers.add_parser("analyze-spec")
488
+ _add_catalog_args(analyze_spec)
489
+ analyze_spec.add_argument("--spec", required=True)
490
+ analyze_spec.add_argument("--example-limit", type=int, default=3)
491
+ analyze_spec.add_argument("--graph")
492
+
493
+ generate_from_spec = subparsers.add_parser("generate-from-spec")
494
+ _add_catalog_args(generate_from_spec)
495
+ generate_from_spec.add_argument("--spec", required=True)
496
+ generate_from_spec.add_argument("--output-dir", required=True)
497
+ generate_from_spec.add_argument("--example-limit", type=int, default=3)
498
+ generate_from_spec.add_argument("--graph")
499
+ generate_from_spec.add_argument("--db", help="SQLite index — resolves table-extension field types from the real EDT base type.")
500
+
501
+ export_packageslocal = subparsers.add_parser("export-packageslocal-graphify")
502
+ export_packageslocal.add_argument("--packages-root", required=True)
503
+ export_packageslocal.add_argument("--output-dir", required=True)
504
+
505
+ run_graphify = subparsers.add_parser("run-graphify-staging")
506
+ run_graphify.add_argument("--staging-dir", required=True)
507
+ run_graphify.add_argument("--output-dir", required=True)
508
+ run_graphify.add_argument("--no-html", action="store_true")
509
+
510
+ build_index = subparsers.add_parser("build-index", help="Build the SQLite FTS5 index (standard and/or custom).")
511
+ build_index.add_argument(
512
+ "--repo-root", action="append", default=None,
513
+ help="Custom D365 source repo (repeatable — all corpora are merged; omit to build a "
514
+ "STANDARD-only knowledge index).",
515
+ )
516
+ build_index.add_argument("--rules", help="Classification rules JSON (required with --repo-root).")
517
+ build_index.add_argument("--db", required=True, help="Output SQLite database path, e.g. .omx/index/d365fo.db")
518
+ build_index.add_argument("--packages-root", help="PackagesLocalDirectory to index the standard D365 corpus.")
519
+ build_index.add_argument("--rebuild", action="store_true", help="Delete and rebuild the DB from scratch.")
520
+ build_index.add_argument(
521
+ "--exclude-package",
522
+ action="append",
523
+ default=None,
524
+ metavar="PATTERN",
525
+ help="fnmatch pattern of PLD packages to skip (repeatable), e.g. --exclude-package 'BAB*' "
526
+ "to keep custom/ISV code out of a publishable standard index.",
527
+ )
528
+
529
+ extract_aot = subparsers.add_parser(
530
+ "extract-aot-relations",
531
+ help="Parse <Relations> from every AxTable/AxTableExtension (the AOT foreign keys) "
532
+ "into the SQL data model database.",
533
+ )
534
+ extract_aot.add_argument("--db", required=True, help="SQL model SQLite path, e.g. .omx/index/sqlmodel-raw.db")
535
+ extract_aot.add_argument(
536
+ "--root", action="append", required=True,
537
+ help="Corpus root to walk (repeatable): a PackagesLocalDirectory and/or a source tree.",
538
+ )
539
+
540
+ serve_mcp = subparsers.add_parser("serve-mcp", help="Run the stdio MCP server exposing D365 knowledge tools.")
541
+ serve_mcp.add_argument("--repo-root", help="Optional custom D365 repo (omit to serve from the knowledge index alone).")
542
+ serve_mcp.add_argument("--rules", help="Classification rules JSON (only with --repo-root).")
543
+ serve_mcp.add_argument("--db", help="SQLite index path (defaults to the fetched knowledge cache ~/.d365fo-agent/d365fo.db).")
544
+ serve_mcp.add_argument("--packages-root", help="PackagesLocalDirectory — enables source-reading tools (signatures, examples).")
545
+ serve_mcp.add_argument(
546
+ "--extra-root", action="append", default=None,
547
+ help="Additional source corpus root indexed into the same DB (repeatable).",
548
+ )
549
+ serve_mcp.add_argument("--lint-rules", help="X++ lint rules JSON (defaults to the bundled rules).")
550
+ serve_mcp.add_argument(
551
+ "--sql-model",
552
+ help="SQLite SQL data model extracted from a deployed D365 database (enables get_sql_model; "
553
+ "defaults to a sqlmodel-raw.db next to the knowledge index).",
554
+ )
555
+ serve_mcp.add_argument("--methodology")
556
+
557
+ fetch_knowledge_cmd = subparsers.add_parser(
558
+ "fetch-knowledge", help="Download the prebuilt standard-D365 knowledge index to the local cache."
559
+ )
560
+ fetch_knowledge_cmd.add_argument("--url", help="Release-asset URL (.db or .db.gz). Defaults to the built-in published URL.")
561
+ fetch_knowledge_cmd.add_argument("--dest", help="Destination path (defaults to ~/.d365fo-agent/d365fo.db).")
562
+ fetch_knowledge_cmd.add_argument("--force", action="store_true", help="Re-download even if already present.")
563
+
564
+ validate_xml_cmd = subparsers.add_parser("validate-xml", help="Validate AOT XML (file or stdin) offline.")
565
+ validate_xml_cmd.add_argument("--file", help="Path to the XML file. Omit to read XML from stdin.")
566
+ validate_xml_cmd.add_argument("--family", help="Expected artifact family (e.g. service, data-entity).")
567
+ validate_xml_cmd.add_argument("--profiles", help="Path to aot-type-profiles.json for universal (learned) structural rules.")
568
+
569
+ type_profiles_cmd = subparsers.add_parser(
570
+ "build-type-profiles", help="Learn per-type structural rules from the corpus (for universal validate_xml)."
571
+ )
572
+ type_profiles_cmd.add_argument("--repo-root", required=True)
573
+ type_profiles_cmd.add_argument("--db", required=True, help="SQLite index (source of type names + example paths).")
574
+ type_profiles_cmd.add_argument("--packages-root")
575
+ type_profiles_cmd.add_argument("--sample-per-type", type=int, default=200)
576
+ type_profiles_cmd.add_argument("--out", help="Output JSON (default: <db dir>/aot-type-profiles.json).")
577
+
578
+ lint_cmd = subparsers.add_parser("lint", help="Lint AOT XML against the X++ coding rules.")
579
+ lint_cmd.add_argument("--file", help="Path to the XML file. Omit to read XML from stdin.")
580
+ lint_cmd.add_argument("--family", help="Artifact family hint (optional).")
581
+ lint_cmd.add_argument("--model", help="Owning model name (optional, refines naming/extension rules).")
582
+ lint_cmd.add_argument("--db", help="SQLite index for index-backed rules (target existence, EDT types).")
583
+ lint_cmd.add_argument("--rules-config", help="Path to x++-rules.json (defaults to config/x++-rules.json).")
584
+ lint_cmd.add_argument("--repo-root", help="Repo root — enables reading EDT i:type to type STANDARD EDT fields.")
585
+ lint_cmd.add_argument("--packages-root", help="PackagesLocalDirectory (defaults to <repo-root>/PackagesLocalDirectory).")
586
+
587
+ derive_entity_cmd = subparsers.add_parser(
588
+ "derive-entity", help="Duplicate a standard data entity into a new public OData entity + privilege."
589
+ )
590
+ derive_entity_cmd.add_argument("--repo-root", required=True)
591
+ derive_entity_cmd.add_argument("--db", required=True, help="SQLite index used to locate the source entity.")
592
+ derive_entity_cmd.add_argument("--source", required=True, help="Source data entity name (e.g. CustCustomerV3Entity).")
593
+ derive_entity_cmd.add_argument("--new-name", required=True, help="New custom entity name (prefixed).")
594
+ derive_entity_cmd.add_argument("--output-dir", required=True)
595
+ derive_entity_cmd.add_argument("--packages-root")
596
+ derive_entity_cmd.add_argument("--public-entity-name")
597
+ derive_entity_cmd.add_argument("--public-collection-name")
598
+ derive_entity_cmd.add_argument("--label")
599
+ derive_entity_cmd.add_argument("--data-management", action="store_true")
600
+ derive_entity_cmd.add_argument("--staging-table")
601
+ derive_entity_cmd.add_argument("--integration-mode", default="OData")
602
+ derive_entity_cmd.add_argument("--grants", help="Comma-separated grants, e.g. Read,Create,Update,Delete.")
603
+ derive_entity_cmd.add_argument("--privilege-label")
604
+
605
+ wire_security_cmd = subparsers.add_parser(
606
+ "wire-security", help="Wire a privilege into a duty/role (extension-first) so it actually grants access."
607
+ )
608
+ wire_security_cmd.add_argument("--privilege", required=True, help="Privilege name to grant (e.g. from derive-entity).")
609
+ wire_security_cmd.add_argument("--duty", help="Duty to place the privilege in (standard duty to extend, or new custom duty name).")
610
+ wire_security_cmd.add_argument("--role", help="Role to attach the duty/privilege to (standard role to extend, or new custom role name).")
611
+ wire_security_cmd.add_argument("--new-duty", action="store_true", help="Create a new custom AxSecurityDuty instead of extending a standard one.")
612
+ wire_security_cmd.add_argument("--new-role", action="store_true", help="Create a new custom AxSecurityRole instead of extending a standard one.")
613
+ wire_security_cmd.add_argument("--suffix", help="Extension-name suffix (your model name); defaults to the privilege name.")
614
+ wire_security_cmd.add_argument("--duty-label")
615
+ wire_security_cmd.add_argument("--role-label")
616
+ wire_security_cmd.add_argument("--role-description")
617
+ wire_security_cmd.add_argument("--output-dir", required=True)
618
+ wire_security_cmd.add_argument("--db", help="SQLite index to verify standard duty/role targets exist (recommended when extending).")
619
+
620
+ scaffold_cmd = subparsers.add_parser(
621
+ "scaffold", help="Clone a real corpus example of ANY AOT type as a starting skeleton for a new object."
622
+ )
623
+ scaffold_cmd.add_argument("--repo-root", required=True)
624
+ scaffold_cmd.add_argument("--db", required=True, help="SQLite index used to find an example.")
625
+ scaffold_cmd.add_argument("--artifact-type", required=True, help="AOT type, e.g. AxView, AxWorkflowApproval, AxEnumExtension.")
626
+ scaffold_cmd.add_argument("--new-name", help="Rename the scaffold's root <Name> to this.")
627
+ scaffold_cmd.add_argument("--query", help="Bias example selection toward a relevant one.")
628
+ scaffold_cmd.add_argument("--set", action="append", metavar="Element=Value",
629
+ help="Set a top-level element on the skeleton (repeatable), e.g. --set Label=@My:Foo.")
630
+ scaffold_cmd.add_argument("--packages-root")
631
+ scaffold_cmd.add_argument("--output", help="Write the scaffold XML to this path.")
632
+
633
+ return parser
634
+
635
+
636
+ def _add_catalog_args(parser: argparse.ArgumentParser) -> None:
637
+ parser.add_argument("--repo-root", required=True)
638
+ parser.add_argument("--rules", required=True)
639
+
640
+
641
+ def _build_catalog_from_args(args: argparse.Namespace):
642
+ return build_catalog(Path(args.repo_root), load_rules(args.rules))
643
+
644
+
645
+ def _dump_json(payload: dict[str, object]) -> None:
646
+ json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
647
+ sys.stdout.write("\n")
648
+
649
+
650
+ if __name__ == "__main__":
651
+ raise SystemExit(main())