-
Notifications
You must be signed in to change notification settings - Fork 339
Expand file tree
/
Copy pathcli.py
More file actions
895 lines (796 loc) · 37.5 KB
/
Copy pathcli.py
File metadata and controls
895 lines (796 loc) · 37.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
"""APM compile command CLI."""
from __future__ import annotations
import dataclasses
import sys
from pathlib import Path
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ...core.target_detection import CompileTargetType
from ...compilation import AgentsCompiler, CompilationConfig
from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME
from ...core.command_logger import CommandLogger
from ...core.target_detection import TargetParamType
from ...primitives.discovery import discover_primitives
from ...utils.console import (
_rich_error,
_rich_info,
_rich_panel,
)
from .._helpers import (
_check_orphaned_packages,
_get_console,
_rich_blank_line,
)
from .watcher import _watch_mode
def _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run):
"""Display compilation summary table for single-file mode."""
try:
console = _get_console()
if not console:
_rich_info(f"Processed {stats.get('primitives_found', 0)} primitives:")
_rich_info(f" * {stats.get('instructions', 0)} instructions")
_rich_info(f" * {stats.get('contexts', 0)} contexts")
_rich_info(f"Constitution status: {c_status} hash={c_hash or '-'}")
return
import os
from rich.table import Table
table = Table(
title="Compilation Summary",
show_header=True,
header_style="bold cyan",
)
table.add_column("Component", style="bold white", min_width=15)
table.add_column("Count", style="cyan", min_width=8)
table.add_column("Details", style="white", min_width=20)
constitution_details = f"Hash: {c_hash or '-'}"
table.add_row("Spec-kit Constitution", c_status, constitution_details)
table.add_row(
"Instructions",
str(stats.get("instructions", 0)),
"[+] All validated",
)
table.add_row(
"Contexts",
str(stats.get("contexts", 0)),
"[+] All validated",
)
table.add_row(
"Chatmodes",
str(stats.get("chatmodes", 0)),
"[+] All validated",
)
try:
file_size = os.path.getsize(output_path) if not dry_run else 0
size_str = f"{file_size / 1024:.1f}KB" if file_size > 0 else "Preview"
output_details = f"{output_path.name} ({size_str})"
except Exception:
output_details = f"{output_path.name}"
table.add_row("Output", "* SUCCESS", output_details)
console.print(table)
except Exception:
_rich_info(f"Processed {stats.get('primitives_found', 0)} primitives:")
_rich_info(f" * {stats.get('instructions', 0)} instructions")
_rich_info(f" * {stats.get('contexts', 0)} contexts")
_rich_info(f"Constitution status: {c_status} hash={c_hash or '-'}")
def _display_next_steps(output):
"""Display next steps panel after successful single-file compilation."""
next_steps = [
f"Review the generated {output} file",
"Install MCP dependencies: apm install",
"Execute agentic workflows: apm run <script> --param key=value",
]
try:
console = _get_console()
if console:
from rich.panel import Panel
steps_content = "\n".join(f"* {step}" for step in next_steps)
console.print(Panel(steps_content, title=" Next Steps", border_style="blue"))
else:
_rich_info("Next steps:")
for step in next_steps:
click.echo(f" * {step}")
except (ImportError, NameError):
_rich_info("Next steps:")
for step in next_steps:
click.echo(f" * {step}")
def _display_validation_errors(errors):
"""Display validation errors in a Rich table with actionable feedback."""
try:
console = _get_console()
if console:
from rich.table import Table
error_table = Table(
title="[x] Primitive Validation Errors",
show_header=True,
header_style="bold red",
)
error_table.add_column("File", style="bold red", min_width=20)
error_table.add_column("Error", style="white", min_width=30)
error_table.add_column("Suggestion", style="yellow", min_width=25)
for error in errors:
file_path = str(error) if hasattr(error, "__str__") else "Unknown"
# Extract file path from error string if it contains file info
if ":" in file_path:
parts = file_path.split(":", 1)
file_name = parts[0] if len(parts) > 1 else "Unknown"
error_msg = parts[1].strip() if len(parts) > 1 else file_path
else:
file_name = "Unknown"
error_msg = file_path
# Provide actionable suggestions based on error type
suggestion = _get_validation_suggestion(error_msg)
error_table.add_row(file_name, error_msg, suggestion)
console.print(error_table)
return
except (ImportError, NameError):
pass
# Fallback to simple text output
_rich_error("Validation errors found:")
for error in errors:
click.echo(f" [x] {error}")
def _get_validation_suggestion(error_msg):
"""Get actionable suggestions for validation errors."""
if "Missing 'description'" in error_msg:
return "Add 'description: Your description here' to frontmatter"
elif "applyTo" in error_msg and "globally" in error_msg:
return "Add 'applyTo: \"**/*.py\"' to scope the instruction, or leave as-is for global"
elif "Empty content" in error_msg:
return "Add markdown content below the frontmatter"
else:
return "Check primitive structure and frontmatter"
def _resolve_compile_target(target):
"""Map CLI target input to a compiler-understood target.
The compiler understands single-string targets (``"vscode"``,
``"claude"``, ``"gemini"``, ``"all"``) and ``frozenset`` targets
containing compiler-family names (``"agents"``, ``"claude"``,
``"gemini"``).
Multi-target lists are mapped to the narrowest representation:
a single string when only one compiler family is needed, or a
``frozenset`` of families when multiple are needed. This avoids
collapsing to ``"all"`` (which would incorrectly generate files
for every family).
Family resolution reads ``TargetProfile.compile_family`` from
``KNOWN_TARGETS`` so adding a new compile-eligible target only
requires populating that field. The CLI alias ``"vscode"`` is
treated as ``"copilot"`` for this purpose.
Args:
target: A single target string, a list of target strings, or ``None``.
Returns:
A single string, a ``frozenset`` of compiler families, or ``None``.
"""
from ...integration.targets import KNOWN_TARGETS
if target is None:
return None # will trigger detect_target() auto-detection
if isinstance(target, list):
target_set = set(target)
# Strip targets with no compile output (compile_family is None);
# they would silently fall through the family resolution otherwise.
# ``vscode`` is a CLI alias for ``copilot`` and shares its profile.
skip = {name for name, profile in KNOWN_TARGETS.items() if profile.compile_family is None}
target_set -= skip
if not target_set:
# Solo agent-skills (or another no-compile target) in a list --
# pass through as a string so the compiler's no-op path fires.
for sentinel in target:
if sentinel in skip:
return sentinel
return None
# The "vscode" family handles copilot AND emits AGENTS.md as a
# bonus; the "agents" family emits AGENTS.md only. When both
# appear in a multi-target compile we still need both family
# tokens so the agents compiler routes correctly.
def _family_of(name: str) -> str | None:
if name == "vscode":
return "vscode"
profile = KNOWN_TARGETS.get(name)
return profile.compile_family if profile else None
families: set[str] = set()
for name in target_set:
family = _family_of(name)
if family is None:
continue
families.add(family)
if family == "vscode":
# copilot also emits AGENTS.md; mirror legacy behavior.
families.add("agents")
if len(families) >= 2:
# Single-target copilot collapses {"vscode","agents"} to bare
# "vscode" for routing parity with single-string -t copilot.
if families == {"vscode", "agents"}:
return "vscode"
return frozenset(families)
if "claude" in families:
return "claude"
if "gemini" in families:
return "gemini"
if "vscode" in families:
return "vscode"
# Bare agents-family target: preserve the original target name so
# single-element list routing matches single-string semantics
# (-t cursor and -t [cursor] both end up as "cursor"). Iterate
# KNOWN_TARGETS in insertion order so priority ties (e.g.
# ["opencode","codex"]) resolve deterministically to the
# earliest-registered target. Adding a new agents-family
# target (e.g. zed, cline) costs zero edits here -- it inherits
# whatever priority position it occupies in the registry.
for name, profile in KNOWN_TARGETS.items():
if profile.compile_family == "agents" and name in target_set:
return name
return "vscode" # defensive fallback (unreachable)
return target # single string pass-through
def _resolve_effective_target(
target: str | list[str] | None,
) -> tuple[CompileTargetType, str, str | list[str] | None]:
"""Resolve the CLI --target arg to the compiler-understood effective target.
Mirrors the resolution the one-shot compile path performs (load
apm.yml ``target:`` / ``targets:``, run :func:`_resolve_compile_target`
on both, fall back to :func:`detect_target` for the auto-detect case)
so the watch path can build ``CompilationConfig`` with the same
``target=`` value the one-shot path uses (#1345).
Args:
target: The raw ``--target`` CLI argument (None, str, or list).
Returns:
Tuple ``(effective_target, detection_reason, config_target)`` where
``effective_target`` is what to pass as ``target=`` to
:meth:`CompilationConfig.from_apm_yml`, ``detection_reason`` is the
provenance label, and ``config_target`` is the raw apm.yml value
(str | list | None) for user-facing label rendering.
"""
from ...core.target_detection import detect_target
from ...models.apm_package import APMPackage
config_target = None
apm_yml_path = Path(APM_YML_FILENAME)
if apm_yml_path.exists():
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target
if config_target is None:
try:
from ...core.apm_yml import parse_targets_field
from ...utils.yaml_io import load_yaml
_raw = load_yaml(apm_yml_path)
if isinstance(_raw, dict):
_yaml_targets = parse_targets_field(_raw)
if _yaml_targets:
config_target = (
_yaml_targets[0] if len(_yaml_targets) == 1 else _yaml_targets
)
except Exception:
pass
compile_target = _resolve_compile_target(target)
compile_config_target = _resolve_compile_target(config_target)
if isinstance(compile_target, frozenset):
return compile_target, "explicit --target flag", config_target
if isinstance(compile_config_target, frozenset) and compile_target is None:
return compile_config_target, "apm.yml target", config_target
detected_target, detection_reason = detect_target(
project_root=Path("."),
explicit_target=compile_target,
config_target=compile_config_target if isinstance(compile_config_target, str) else None,
)
return detected_target, detection_reason, config_target
@click.command(help="Compile APM context into distributed AGENTS.md files")
@click.option(
"--output",
"-o",
default=AGENTS_MD_FILENAME,
help="Output file path (for single-file mode)",
)
@click.option(
"--target",
"-t",
type=TargetParamType(),
default=None,
help="Target platform (comma-separated). Values: copilot, claude, cursor, opencode, codex, gemini, windsurf, agent-skills, all. 'agent-skills' deploys to .agents/skills/ (cross-client). 'all' = copilot+claude+cursor+opencode+codex+gemini+windsurf (excludes agent-skills); combine with 'agent-skills' for both.",
)
@click.option(
"--dry-run",
is_flag=True,
help="Preview compilation without writing files (shows placement decisions)",
)
@click.option("--no-links", is_flag=True, help="Skip markdown link resolution")
@click.option("--chatmode", help="Chatmode to prepend to AGENTS.md files")
@click.option("--watch", is_flag=True, help="Auto-regenerate on changes")
@click.option("--validate", is_flag=True, help="Validate primitives without compiling")
@click.option(
"--with-constitution/--no-constitution",
default=True,
show_default=True,
help="Include Spec Kit constitution block at top if memory/constitution.md present",
)
# Distributed compilation options (Task 7)
@click.option(
"--single-agents",
is_flag=True,
help="Force single-file compilation (legacy mode)",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Show detailed source attribution and optimizer analysis",
)
@click.option(
"--local-only",
is_flag=True,
help="Ignore dependencies, compile only local primitives",
)
@click.option(
"--clean",
is_flag=True,
help="Remove orphaned AGENTS.md files that are no longer generated",
)
@click.option(
"--legacy-skill-paths",
"legacy_skill_paths",
is_flag=True,
default=False,
help=(
"Deploy skill files to per-client paths (e.g. .cursor/skills/) instead of "
"the shared .agents/skills/ directory. Compatibility flag for projects that "
"need per-client skill layouts."
),
)
@click.option(
"--all",
"compile_all",
is_flag=True,
default=False,
help="Compile for all canonical targets. Equivalent to --target all.",
)
@click.pass_context
def compile(
ctx,
output,
target,
dry_run,
no_links,
chatmode,
watch,
validate,
with_constitution,
single_agents,
verbose,
local_only,
clean,
legacy_skill_paths,
compile_all,
):
"""Compile APM context into distributed AGENTS.md files.
By default, uses distributed compilation to generate multiple focused AGENTS.md
files across your directory structure following the Minimal Context Principle.
Use --single-agents for traditional single-file compilation when needed.
Target platforms:
* vscode/agents: Generates AGENTS.md + .github/ structure (VSCode/GitHub Copilot)
* claude: Generates CLAUDE.md + .claude/ structure (Claude Code)
* all: Generates both targets (default)
Advanced options:
* --dry-run: Preview compilation without writing files (shows placement decisions)
* --verbose: Show detailed source attribution and optimizer analysis
* --local-only: Ignore dependencies, compile only local .apm/ primitives
* --clean: Remove orphaned AGENTS.md files that are no longer generated
"""
logger = CommandLogger("compile", verbose=verbose, dry_run=dry_run)
# --all flag: equivalent to --target all, with deprecation path
if compile_all:
if target is not None:
logger.error("Cannot use --all together with --target")
sys.exit(2)
target = "all"
elif (isinstance(target, str) and target == "all") or (
isinstance(target, list) and "all" in target
):
# Surface deprecation through the same UX channel as other
# warnings so users actually see it (convergence item 9).
# warnings.warn(DeprecationWarning) is invisible by default in
# CLI output and would only ever fire for downstream library
# consumers running with -W default, which we have none of.
logger.warning("'--target all' is deprecated; use '--all' instead.")
try:
# Check if this is an APM project first
from pathlib import Path
if not Path(APM_YML_FILENAME).exists():
logger.error("Not an APM project - no apm.yml found")
logger.progress(" To initialize an APM project, run:")
logger.progress(" apm init")
sys.exit(1)
# Check if there are any instruction files to compile
from ...compilation.constitution import find_constitution
apm_modules_exists = Path(APM_MODULES_DIR).exists()
constitution_exists = find_constitution(Path(".")).exists()
# Check if .apm directory has actual content
apm_dir = Path(APM_DIR)
local_apm_has_content = apm_dir.exists() and (
any(apm_dir.rglob("*.instructions.md")) or any(apm_dir.rglob("*.chatmode.md"))
)
# If no primitive sources exist, check deeper to provide better feedback
if not apm_modules_exists and not local_apm_has_content and not constitution_exists:
# Check if .apm directories exist but are empty
has_empty_apm = (
apm_dir.exists()
and not any(apm_dir.rglob("*.instructions.md"))
and not any(apm_dir.rglob("*.chatmode.md"))
)
if has_empty_apm:
logger.error("No instruction files found in .apm/ directory")
logger.progress(" To add instructions, create files like:")
logger.progress(" .apm/instructions/coding-standards.instructions.md")
logger.progress(" .apm/chatmodes/backend-engineer.chatmode.md")
else:
logger.error("No APM content found to compile")
logger.progress(" To get started:")
logger.progress(" 1. Install APM dependencies: apm install <owner>/<repo>")
logger.progress(" 2. Or create local instructions: mkdir -p .apm/instructions")
logger.progress(" 3. Then create .instructions.md or .chatmode.md files")
if not dry_run: # Don't exit on dry-run to allow testing
sys.exit(1)
# Validation-only mode
if validate:
logger.start("Validating APM context...", symbol="gear")
compiler = AgentsCompiler(".")
try:
primitives = discover_primitives(".")
except Exception as e:
logger.error(f"Failed to discover primitives: {e}")
logger.progress(f" Error details: {type(e).__name__}")
sys.exit(1)
validation_errors = compiler.validate_primitives(primitives)
if validation_errors:
_display_validation_errors(validation_errors)
logger.error(f"Validation failed with {len(validation_errors)} errors")
sys.exit(1)
logger.success("All primitives validated successfully!")
logger.progress(f"Validated {primitives.count()} primitives:")
logger.progress(f" * {len(primitives.chatmodes)} chatmodes")
logger.progress(f" * {len(primitives.instructions)} instructions")
logger.progress(f" * {len(primitives.contexts)} contexts")
# Show MCP dependency validation count
try:
from ...models.apm_package import APMPackage
apm_pkg = APMPackage.from_apm_yml(Path(APM_YML_FILENAME))
mcp_count = len(apm_pkg.get_mcp_dependencies())
if mcp_count > 0:
logger.progress(f" * {mcp_count} MCP dependencies")
except Exception:
pass
return
# Watch mode
if watch:
# Resolve the same effective target the one-shot path uses so
# `targets: [claude, cursor]` does not silently regress to the
# all-families fanout on every recompile (#1345).
effective_target, _detection_reason, config_target = _resolve_effective_target(target)
_watch_mode(
output,
chatmode,
no_links,
dry_run,
verbose=verbose,
effective_target=effective_target,
target_label_user=target,
target_label_config=config_target,
)
return
logger.start("Starting context compilation...", symbol="cogs")
# Auto-detect target if not explicitly provided
from ...core.target_detection import (
REASON_NO_TARGET_FOLDER,
detect_target,
get_target_description,
)
# Get config target from apm.yml if available. When the file is
# absent we proceed with auto-detection; when it is present but
# malformed we let the parse error surface so users see exactly
# what is wrong (e.g. ``target: opencode,bogus`` -> a ValueError
# naming the bad token), rather than silently falling through to
# auto-detect. See #820.
from ...models.apm_package import APMPackage
config_target = None
apm_yml_path = Path(APM_YML_FILENAME)
if apm_yml_path.exists():
apm_pkg = APMPackage.from_apm_yml(apm_yml_path)
config_target = apm_pkg.target
# Parity with `apm install`: also honor canonical plural
# `targets:` key (#1154). APMPackage only reads singular
# `target:`; parse_targets_field handles both keys, raises
# ConflictingTargetsError when both appear, and validates
# tokens against CANONICAL_TARGETS. When only `targets:` is
# present, apm_pkg.target is None and we promote the plural
# list here so compile sees the same schema install sees.
if config_target is None:
try:
from ...core.apm_yml import parse_targets_field
from ...utils.yaml_io import load_yaml
_raw = load_yaml(apm_yml_path)
if isinstance(_raw, dict):
_yaml_targets = parse_targets_field(_raw)
if _yaml_targets:
config_target = (
_yaml_targets[0] if len(_yaml_targets) == 1 else _yaml_targets
)
except Exception:
pass
# Resolve list targets to compiler-understood value
compile_target = _resolve_compile_target(target)
# Also handle config_target being a list (from apm.yml target: [claude, copilot])
compile_config_target = _resolve_compile_target(config_target)
# A frozenset means multiple compiler families were explicitly
# requested -- bypass detect_target() since it only handles strings.
if isinstance(compile_target, frozenset):
effective_target = compile_target
detection_reason = "explicit --target flag"
elif isinstance(compile_config_target, frozenset) and compile_target is None:
effective_target = compile_config_target
detection_reason = "apm.yml target"
else:
# Pass config_target only when it's a string -- detect_target() is
# typed for Optional[str], and a frozenset config_target is already
# handled by the branch above.
detected_target, detection_reason = detect_target(
project_root=Path("."),
explicit_target=compile_target,
config_target=compile_config_target
if isinstance(compile_config_target, str)
else None,
)
# Keep the detected target intact so the compiler can preserve
# minimal-mode semantics (AGENTS.md only, no .github side outputs).
effective_target = detected_target
# Emit canonical provenance line BEFORE compilation -- mirrors
# `apm install` so users see the same `[i] Targets: ...
# (source: ...)` line on both surfaces. Use the user-facing
# source values (target / config_target) NOT the compiler-family
# expansion in effective_target -- install shows the schema names
# the user wrote (e.g. "copilot"), so compile must too, otherwise
# parity drifts (compile would print "agents, vscode" for the
# same input).
from ...core.target_detection import ResolvedTargets, format_provenance
from ...utils.console import _rich_info
def _coerce_provenance_targets(value):
if value is None:
return []
if isinstance(value, str):
return [t.strip() for t in value.split(",") if t.strip()]
if isinstance(value, list):
return [str(t) for t in value]
if isinstance(value, frozenset):
return sorted(value)
return []
if detection_reason == "explicit --target flag":
_provenance_targets = _coerce_provenance_targets(target)
_provenance_source = "--target flag"
elif detection_reason == "apm.yml target":
_provenance_targets = _coerce_provenance_targets(config_target)
_provenance_source = "apm.yml"
else:
if isinstance(effective_target, frozenset):
_provenance_targets = sorted(effective_target)
elif isinstance(effective_target, str):
_provenance_targets = [effective_target]
else:
_provenance_targets = []
_provenance_source = f"auto-detect ({detection_reason})"
if _provenance_targets:
_rich_info(
format_provenance(
ResolvedTargets(
targets=sorted(set(_provenance_targets)),
source=_provenance_source,
auto_create=True,
)
),
symbol="info",
)
# Build config with distributed compilation flags (Task 7)
config = CompilationConfig.from_apm_yml(
output_path=output if output != AGENTS_MD_FILENAME else None,
chatmode=chatmode,
resolve_links=not no_links if no_links else None,
dry_run=dry_run,
single_agents=single_agents,
trace=verbose,
local_only=local_only,
debug=verbose,
clean_orphaned=clean,
target=effective_target,
)
config.with_constitution = with_constitution
# Handle distributed vs single-file compilation
if config.strategy == "distributed" and not single_agents:
# Show target-aware message with detection reason. Use
# get_target_description() so any future target added to
# target_detection shows up here automatically.
if isinstance(effective_target, frozenset):
# Multi-target compile (from CLI `--target a,b` OR apm.yml
# `target: [a, b]`): show what the compiler will produce.
if isinstance(target, list):
_target_label = f"--target {','.join(target)}"
elif isinstance(config_target, list):
_target_label = f"apm.yml target: [{', '.join(config_target)}]"
else:
_target_label = "multi-target"
from ...core.target_detection import (
should_compile_agents_md,
should_compile_claude_md,
should_compile_gemini_md,
)
_parts = []
if should_compile_agents_md(effective_target):
_parts.append("AGENTS.md")
if should_compile_claude_md(effective_target):
_parts.append("CLAUDE.md")
if should_compile_gemini_md(effective_target):
_parts.append("GEMINI.md")
logger.progress(f"Compiling for {' + '.join(_parts)} ({_target_label})")
elif (
isinstance(effective_target, str)
and effective_target == "vscode"
and detection_reason == REASON_NO_TARGET_FOLDER
):
logger.progress(f"Compiling for AGENTS.md only ({detection_reason})")
logger.progress(
" Create .github/, .claude/, .codex/, .opencode/ or .cursor/ folder for full integration",
symbol="light_bulb",
)
else:
description = get_target_description(effective_target)
logger.progress(f"Compiling for {description} - {detection_reason}")
if dry_run:
logger.dry_run_notice("showing placement without writing files")
if verbose:
logger.verbose_detail(
"Verbose mode: showing source attribution and optimizer analysis"
)
else:
logger.progress("Using single-file compilation (legacy mode)", symbol="page")
# Perform compilation
compiler = AgentsCompiler(".")
result = compiler.compile(config, logger=logger)
compile_has_critical = result.has_critical_security
if result.success:
# Handle different compilation modes
if config.strategy == "distributed" and not single_agents:
# Distributed compilation results - output already shown by professional formatter
# Just show final success message
if dry_run:
# Success message for dry run already included in formatter output
pass
else:
# Defense-in-depth (#820): don't claim "completed
# successfully" when zero files were emitted. With
# parse_target_field as the upstream gatekeeper this is
# unreachable in normal flow, but silent zero-effect
# success is the worst-case package-manager DX.
#
# Pattern-based stat scan (instead of a hardcoded key
# list) so new compile-time targets pick up the guard
# automatically: any stat ending in ``_files_written``
# or ``_files_generated`` contributes to the total.
_files_written = sum(
int(v or 0)
for k, v in result.stats.items()
if k.endswith(("_files_written", "_files_generated"))
)
if _files_written > 0:
logger.success(
"Compilation completed successfully!",
symbol="check",
)
else:
# Zero-output compile is the silent-success failure
# mode #820 guards against. Don't claim success;
# surface what the user can act on. The cause is
# usually one of: target dirs not present (auto-
# detect found nothing), explicit target rejected
# by policy, or no primitives in the project.
logger.warning(
"Compilation completed but produced no output "
"files. Check that target directories exist "
"(e.g. .github/, .claude/) or set 'target:' "
"in apm.yml / pass --target explicitly."
)
else:
# Traditional single-file compilation - keep existing logic
# Perform initial compilation in dry-run to get generated body (without constitution)
intermediate_config = dataclasses.replace(
config,
dry_run=True, # force
strategy="single-file",
)
intermediate_result = compiler.compile(intermediate_config)
if intermediate_result.success:
# Perform constitution injection / preservation
from ...compilation.injector import ConstitutionInjector
injector = ConstitutionInjector(base_dir=".")
output_path = Path(config.output_path)
final_content, c_status, c_hash = injector.inject(
intermediate_result.content,
with_constitution=config.with_constitution,
output_path=output_path,
)
if not dry_run:
# Only rewrite when content materially changes (creation, update, missing constitution case)
if c_status in ("CREATED", "UPDATED", "MISSING"):
# Defense-in-depth: scan compiled output before writing
from ...security.gate import WARN_POLICY, SecurityGate
verdict = SecurityGate.scan_text(
final_content, str(output_path), policy=WARN_POLICY
)
if verdict.has_findings:
actionable = verdict.critical_count + verdict.warning_count
if verdict.has_critical:
compile_has_critical = True
if actionable:
logger.warning(
f"Compiled output contains {actionable} hidden character(s) "
f"-- run 'apm audit --file {output_path}' to inspect"
)
try:
from ...compilation.output_writer import CompiledOutputWriter
CompiledOutputWriter().write(output_path, final_content)
except OSError as e:
logger.error(f"Failed to write final AGENTS.md: {e}")
sys.exit(1)
else:
logger.progress(
"No changes detected; preserving existing AGENTS.md for idempotency"
)
# Report success at the top
if dry_run:
logger.success(
"Context compilation completed successfully (dry run)",
symbol="check",
)
else:
logger.success(
f"Context compiled successfully to {output_path}",
)
stats = (
intermediate_result.stats
) # timestamp removed; stats remain version + counts
# Add spacing before summary table
_rich_blank_line()
_display_single_file_summary(stats, c_status, c_hash, output_path, dry_run)
if dry_run:
preview = final_content[:500] + ("..." if len(final_content) > 500 else "")
_rich_panel(preview, title=" Generated Content Preview", style="cyan")
else:
_display_next_steps(output)
# Display warnings for all compilation modes
if result.warnings:
logger.warning(f"Compilation completed with {len(result.warnings)} warning(s):")
for warning in result.warnings:
logger.warning(f" {warning}")
if result.errors:
logger.error(f"Compilation failed with {len(result.errors)} errors:")
for error in result.errors:
logger.error(f" {error}")
sys.exit(1)
# Check for orphaned packages after successful compilation
try:
orphaned_packages = _check_orphaned_packages()
if orphaned_packages:
_rich_blank_line()
logger.warning(
f"Found {len(orphaned_packages)} orphaned package(s) that were included in compilation:"
)
for pkg in orphaned_packages:
logger.progress(f" * {pkg}")
logger.progress(" Run 'apm prune' to remove orphaned packages")
except Exception:
pass # Continue if orphan check fails
# Hard-fail when critical security findings were detected in compiled
# output. Consistent with apm install and apm unpack behavior.
if compile_has_critical:
logger.error(
"Compiled output contains critical hidden characters"
" -- run 'apm audit' to inspect, 'apm audit --strip' to clean"
)
sys.exit(1)
except ImportError as e:
logger.error(f"Compilation module not available: {e}")
logger.progress("This might be a development environment issue.")
sys.exit(1)
except Exception as e:
logger.error(f"Error during compilation: {e}")
sys.exit(1)