aes-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. aes/__init__.py +5 -0
  2. aes/__main__.py +37 -0
  3. aes/analyzer.py +487 -0
  4. aes/commands/__init__.py +0 -0
  5. aes/commands/init.py +727 -0
  6. aes/commands/inspect.py +204 -0
  7. aes/commands/install.py +379 -0
  8. aes/commands/publish.py +432 -0
  9. aes/commands/search.py +65 -0
  10. aes/commands/status.py +153 -0
  11. aes/commands/sync.py +413 -0
  12. aes/commands/validate.py +77 -0
  13. aes/config.py +43 -0
  14. aes/domains.py +1382 -0
  15. aes/frameworks.py +522 -0
  16. aes/mcp_server.py +213 -0
  17. aes/registry.py +294 -0
  18. aes/scaffold/agent.yaml.jinja +135 -0
  19. aes/scaffold/agentignore.jinja +61 -0
  20. aes/scaffold/instructions.md.jinja +311 -0
  21. aes/scaffold/local.example.yaml.jinja +35 -0
  22. aes/scaffold/local.yaml.jinja +29 -0
  23. aes/scaffold/operations.md.jinja +33 -0
  24. aes/scaffold/orchestrator.md.jinja +95 -0
  25. aes/scaffold/permissions.yaml.jinja +151 -0
  26. aes/scaffold/setup.md.jinja +244 -0
  27. aes/scaffold/skill.md.jinja +27 -0
  28. aes/scaffold/skill.yaml.jinja +175 -0
  29. aes/scaffold/workflow.yaml.jinja +44 -0
  30. aes/scaffold/workflow_command.md.jinja +48 -0
  31. aes/schemas/agent.schema.json +188 -0
  32. aes/schemas/permissions.schema.json +100 -0
  33. aes/schemas/registry.schema.json +72 -0
  34. aes/schemas/skill.schema.json +209 -0
  35. aes/schemas/workflow.schema.json +92 -0
  36. aes/targets/__init__.py +29 -0
  37. aes/targets/_base.py +77 -0
  38. aes/targets/_composer.py +338 -0
  39. aes/targets/claude.py +153 -0
  40. aes/targets/copilot.py +48 -0
  41. aes/targets/cursor.py +46 -0
  42. aes/targets/windsurf.py +46 -0
  43. aes/validator.py +394 -0
  44. aes_cli-0.2.0.dist-info/METADATA +110 -0
  45. aes_cli-0.2.0.dist-info/RECORD +48 -0
  46. aes_cli-0.2.0.dist-info/WHEEL +5 -0
  47. aes_cli-0.2.0.dist-info/entry_points.txt +3 -0
  48. aes_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,432 @@
1
+ """aes publish — Package skills and templates as tarballs for sharing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import shutil
7
+ import sys
8
+ import tarfile
9
+ import tempfile
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
+ import click
14
+ import yaml
15
+ from rich.console import Console
16
+
17
+ from aes.config import AGENT_DIR, MANIFEST_FILE
18
+
19
+ console = Console()
20
+
21
+ # Files/patterns excluded from template packages by default (privacy-sensitive)
22
+ _TEMPLATE_DEFAULT_EXCLUDES = ["memory/**", "local.yaml", "overrides/**"]
23
+
24
+
25
+ def _publish_skill_dir(skill_dir: Path, output_dir: Path) -> Path:
26
+ """Package a skill directory as ``{id}-{version}.tar.gz``.
27
+
28
+ Returns the tarball path.
29
+ """
30
+ manifests = list(skill_dir.glob("*.skill.yaml")) + list(skill_dir.glob("skill.yaml"))
31
+ if not manifests:
32
+ raise click.ClickException(
33
+ f"No skill manifest (*.skill.yaml) found in {skill_dir}"
34
+ )
35
+
36
+ manifest_path = manifests[0]
37
+ with open(manifest_path) as f:
38
+ manifest = yaml.safe_load(f)
39
+
40
+ skill_id = manifest.get("id", "unknown")
41
+ skill_version = manifest.get("version", "0.0.0")
42
+
43
+ tarball_name = f"{skill_id}-{skill_version}.tar.gz"
44
+ tarball_path = output_dir / tarball_name
45
+
46
+ with tarfile.open(tarball_path, "w:gz") as tar:
47
+ tar.add(skill_dir, arcname=skill_id)
48
+
49
+ return tarball_path
50
+
51
+
52
+ def _publish_from_manifest(
53
+ project_root: Path,
54
+ output_dir: Path,
55
+ skill_filter: Optional[str],
56
+ ) -> int:
57
+ """Publish skills listed in ``agent.yaml``.
58
+
59
+ Returns the number of skills published.
60
+ """
61
+ manifest_path = project_root / AGENT_DIR / MANIFEST_FILE
62
+ if not manifest_path.exists():
63
+ raise click.ClickException(
64
+ f"No {AGENT_DIR}/{MANIFEST_FILE} found at {project_root}"
65
+ )
66
+
67
+ with open(manifest_path) as f:
68
+ data = yaml.safe_load(f) or {}
69
+
70
+ skills = data.get("skills", [])
71
+ if not skills:
72
+ console.print("[dim]No skills declared in agent.yaml[/]")
73
+ return 0
74
+
75
+ agent_dir = project_root / AGENT_DIR
76
+ published = 0
77
+
78
+ for skill_ref in skills:
79
+ skill_id = skill_ref.get("id")
80
+ if not skill_id:
81
+ continue
82
+ if skill_filter and skill_id != skill_filter:
83
+ continue
84
+
85
+ manifest_rel = skill_ref.get("manifest")
86
+ if not manifest_rel:
87
+ console.print(f" [yellow]Skipped:[/] {skill_id} — no manifest path")
88
+ continue
89
+
90
+ manifest_file = agent_dir / manifest_rel
91
+ if not manifest_file.exists():
92
+ console.print(f" [yellow]Skipped:[/] {skill_id} — manifest not found: {manifest_rel}")
93
+ continue
94
+
95
+ # Determine if the skill lives in its own directory or is flat
96
+ skill_parent = manifest_file.parent
97
+ # Check if directory is dedicated to this skill (contains only this skill's files)
98
+ # If the manifest's parent has other skill manifests, it's a flat layout
99
+ other_manifests = [
100
+ p for p in skill_parent.glob("*.skill.yaml")
101
+ if p != manifest_file
102
+ ] + [
103
+ p for p in skill_parent.glob("skill.yaml")
104
+ if p != manifest_file
105
+ ]
106
+
107
+ if not other_manifests:
108
+ # Dedicated directory — publish directly
109
+ tarball = _publish_skill_dir(skill_parent, output_dir)
110
+ else:
111
+ # Flat layout — gather files into a temp dir
112
+ with tempfile.TemporaryDirectory() as tmp:
113
+ staging = Path(tmp) / skill_id
114
+ staging.mkdir()
115
+ # Copy manifest
116
+ shutil.copy2(manifest_file, staging / manifest_file.name)
117
+ # Copy runbook if declared
118
+ runbook_rel = skill_ref.get("runbook")
119
+ if runbook_rel:
120
+ runbook_file = agent_dir / runbook_rel
121
+ if runbook_file.exists():
122
+ shutil.copy2(runbook_file, staging / runbook_file.name)
123
+ tarball = _publish_skill_dir(staging, output_dir)
124
+
125
+ console.print(
126
+ f" [green]Published:[/] {tarball.name} ({tarball.stat().st_size / 1024:.1f} KB)"
127
+ )
128
+ published += 1
129
+
130
+ if skill_filter and published == 0:
131
+ raise click.ClickException(f"Skill '{skill_filter}' not found in agent.yaml")
132
+
133
+ return published
134
+
135
+
136
+ def _is_excluded(rel_path: str, patterns: List[str]) -> bool:
137
+ """Check if *rel_path* matches any exclusion pattern."""
138
+ for pattern in patterns:
139
+ if fnmatch.fnmatch(rel_path, pattern):
140
+ return True
141
+ # Also check just the filename for non-glob patterns
142
+ if "/" not in pattern and fnmatch.fnmatch(Path(rel_path).name, pattern):
143
+ return True
144
+ return False
145
+
146
+
147
+ def _validate_before_publish(project_root: Path) -> bool:
148
+ """Validate the .agent/ directory before publishing.
149
+
150
+ Returns True if validation passes, False otherwise.
151
+ """
152
+ from aes.validator import validate_agent_dir
153
+
154
+ agent_dir = project_root / AGENT_DIR
155
+ if not agent_dir.exists():
156
+ console.print(f"[red]Error:[/] No {AGENT_DIR}/ directory found at {project_root}")
157
+ return False
158
+
159
+ results = validate_agent_dir(agent_dir)
160
+ failures = [r for r in results if not r.valid]
161
+ if failures:
162
+ console.print("[red]Validation failed:[/]")
163
+ for r in failures:
164
+ for err in r.errors:
165
+ console.print(f" {r.file_path.name}: {err}")
166
+ return False
167
+ return True
168
+
169
+
170
+ def _publish_template_dir(
171
+ project_root: Path,
172
+ output_dir: Path,
173
+ exclude_patterns: Optional[List[str]] = None,
174
+ include_memory: bool = False,
175
+ include_all: bool = False,
176
+ ) -> Path:
177
+ """Package a complete .agent/ directory as ``{name}-{version}.tar.gz``.
178
+
179
+ Returns the tarball path.
180
+ """
181
+ agent_dir = project_root / AGENT_DIR
182
+ manifest_path = agent_dir / MANIFEST_FILE
183
+ if not manifest_path.exists():
184
+ raise click.ClickException(
185
+ f"No {AGENT_DIR}/{MANIFEST_FILE} found at {project_root}"
186
+ )
187
+
188
+ with open(manifest_path) as f:
189
+ manifest = yaml.safe_load(f) or {}
190
+
191
+ name = manifest.get("name", "unknown")
192
+ version = manifest.get("version", "0.0.0")
193
+
194
+ # Build exclusion list
195
+ if include_all:
196
+ excludes: List[str] = []
197
+ else:
198
+ excludes = list(_TEMPLATE_DEFAULT_EXCLUDES)
199
+ if include_memory:
200
+ excludes = [p for p in excludes if not p.startswith("memory")]
201
+ if exclude_patterns:
202
+ excludes.extend(exclude_patterns)
203
+
204
+ tarball_name = f"{name}-{version}.tar.gz"
205
+ tarball_path = output_dir / tarball_name
206
+
207
+ with tarfile.open(tarball_path, "w:gz") as tar:
208
+ for file_path in sorted(agent_dir.rglob("*")):
209
+ if not file_path.is_file():
210
+ continue
211
+ rel = file_path.relative_to(agent_dir)
212
+ rel_str = str(rel)
213
+ if _is_excluded(rel_str, excludes):
214
+ continue
215
+ arcname = f"{name}/{AGENT_DIR}/{rel_str}"
216
+ tar.add(file_path, arcname=arcname)
217
+
218
+ return tarball_path
219
+
220
+
221
+ def _prompt_visibility() -> str:
222
+ """Interactively prompt the user for package visibility."""
223
+ console.print("\n[bold]Package visibility:[/]\n")
224
+ choices = [
225
+ ("public", "Anyone can search and download"),
226
+ ("private", "Requires a valid registry token"),
227
+ ]
228
+ for i, (name, desc) in enumerate(choices, 1):
229
+ console.print(f" [bold cyan][{i}][/] {name} — {desc}")
230
+ console.print()
231
+ idx = click.prompt("Choice", type=click.IntRange(1, len(choices)), default=1)
232
+ return choices[idx - 1][0]
233
+
234
+
235
+ def _upload_to_registry(
236
+ tarball: Path,
237
+ skill_id: str,
238
+ version: str,
239
+ description: str,
240
+ tags: Optional[list] = None,
241
+ pkg_type: str = "skill",
242
+ visibility: str = "public",
243
+ ) -> None:
244
+ """Upload a single tarball to the AES registry."""
245
+ from aes.registry import upload_package
246
+
247
+ try:
248
+ upload_package(tarball, skill_id, version, description, tags,
249
+ pkg_type=pkg_type, visibility=visibility)
250
+ console.print(f" [green]Uploaded to registry:[/] {skill_id}@{version}")
251
+ except RuntimeError as exc:
252
+ console.print(f" [red]Registry upload failed:[/] {exc}")
253
+ except Exception as exc:
254
+ console.print(f" [red]Registry upload error:[/] {exc}")
255
+
256
+
257
+ def _upload_tarballs_from_dir(
258
+ output_dir: Path,
259
+ project_root: Path,
260
+ skill_filter: Optional[str],
261
+ visibility: str = "public",
262
+ ) -> None:
263
+ """Upload all tarballs in *output_dir* to the registry."""
264
+ for tarball in sorted(output_dir.glob("*.tar.gz")):
265
+ # Extract id and version from filename: {id}-{version}.tar.gz
266
+ stem = tarball.name.removesuffix(".tar.gz")
267
+ parts = stem.rsplit("-", 1)
268
+ if len(parts) != 2:
269
+ continue
270
+ sid, sver = parts
271
+ if skill_filter and sid != skill_filter:
272
+ continue
273
+
274
+ # Try to read description from the tarball manifest
275
+ description = f"Skill: {sid}"
276
+ tags = None
277
+ try:
278
+ import tarfile as _tf
279
+ with _tf.open(tarball, "r:gz") as tar:
280
+ for member in tar.getmembers():
281
+ if member.name.endswith(".skill.yaml"):
282
+ f = tar.extractfile(member)
283
+ if f:
284
+ mdata = yaml.safe_load(f.read())
285
+ description = mdata.get("description", description)
286
+ tags = mdata.get("tags")
287
+ break
288
+ except Exception:
289
+ pass
290
+
291
+ _upload_to_registry(tarball, sid, sver, description, tags, visibility=visibility)
292
+
293
+
294
+ @click.command("publish")
295
+ @click.argument("skill_path", required=False, type=click.Path(exists=True))
296
+ @click.option("--output", "-o", default=".", type=click.Path(), help="Output directory for tarball(s)")
297
+ @click.option("--path", default=".", type=click.Path(exists=True), help="Project root (used when no SKILL_PATH)")
298
+ @click.option("--skill", default=None, help="Publish a single skill by id (used when no SKILL_PATH)")
299
+ @click.option("--registry", is_flag=True, default=False, help="Also upload to the AES registry")
300
+ @click.option("--template", is_flag=True, default=False, help="Publish entire .agent/ directory as a template")
301
+ @click.option("--include-memory", is_flag=True, default=False, help="Include memory/ in template (excluded by default)")
302
+ @click.option("--exclude", multiple=True, help="Additional glob patterns to exclude from template")
303
+ @click.option("--include-all", is_flag=True, default=False, help="No default exclusions for template")
304
+ @click.option("--visibility", type=click.Choice(["public", "private"]), default=None,
305
+ help="Package visibility (public/private). Prompts if interactive, defaults to public in CI.")
306
+ def publish_cmd(
307
+ skill_path: Optional[str],
308
+ output: str,
309
+ path: str,
310
+ skill: Optional[str],
311
+ registry: bool,
312
+ template: bool,
313
+ include_memory: bool,
314
+ exclude: tuple,
315
+ include_all: bool,
316
+ visibility: Optional[str],
317
+ ) -> None:
318
+ """Package skill(s) or a template as tarball(s) for sharing.
319
+
320
+ With SKILL_PATH, packages that single directory. Without SKILL_PATH,
321
+ reads agent.yaml and packages every listed skill (or one with --skill).
322
+
323
+ Use --template to package the entire .agent/ directory as a template.
324
+ Use --registry to also upload the tarball(s) to the AES registry.
325
+
326
+ \b
327
+ Examples:
328
+ aes publish ./my-skill -o /tmp # explicit directory
329
+ aes publish -o dist/ # all skills from agent.yaml
330
+ aes publish --skill train -o dist/ # single skill by id
331
+ aes publish --skill train --registry # publish to registry
332
+ aes publish --template -o dist/ # publish .agent/ as template
333
+ aes publish --template --include-memory # include memory/ in template
334
+ """
335
+ output_dir = Path(output).resolve()
336
+ output_dir.mkdir(parents=True, exist_ok=True)
337
+
338
+ if registry and visibility is None:
339
+ if sys.stdin.isatty():
340
+ visibility = _prompt_visibility()
341
+ else:
342
+ visibility = "public"
343
+ elif visibility is None:
344
+ visibility = "public"
345
+
346
+ if template:
347
+ # Template mode — package entire .agent/ directory
348
+ project_root = Path(path).resolve()
349
+
350
+ if not _validate_before_publish(project_root):
351
+ raise SystemExit(1)
352
+
353
+ tarball = _publish_template_dir(
354
+ project_root,
355
+ output_dir,
356
+ exclude_patterns=list(exclude) if exclude else None,
357
+ include_memory=include_memory,
358
+ include_all=include_all,
359
+ )
360
+
361
+ # Read name/version for display
362
+ manifest_path = project_root / AGENT_DIR / MANIFEST_FILE
363
+ with open(manifest_path) as f:
364
+ mdata = yaml.safe_load(f) or {}
365
+ tname = mdata.get("name", "unknown")
366
+ tver = mdata.get("version", "0.0.0")
367
+
368
+ console.print(f"[green]Published template:[/] {tarball}")
369
+ console.print(f" Name: {tname} v{tver}")
370
+ console.print(f" Size: {tarball.stat().st_size / 1024:.1f} KB")
371
+
372
+ # List what's excluded
373
+ if not include_all:
374
+ excluded = _TEMPLATE_DEFAULT_EXCLUDES.copy()
375
+ if include_memory:
376
+ excluded = [p for p in excluded if not p.startswith("memory")]
377
+ if excluded:
378
+ console.print(f" Excluded: {', '.join(excluded)}")
379
+
380
+ if registry:
381
+ _upload_to_registry(
382
+ tarball, tname, tver,
383
+ mdata.get("description", ""),
384
+ mdata.get("tags"),
385
+ pkg_type="template",
386
+ visibility=visibility,
387
+ )
388
+ else:
389
+ console.print()
390
+ console.print("[dim]Use --registry to upload to the AES registry.[/]")
391
+ return
392
+
393
+ if skill_path:
394
+ # Explicit directory — original behavior
395
+ tarball = _publish_skill_dir(Path(skill_path).resolve(), output_dir)
396
+
397
+ with open(tarball, "rb") as _f:
398
+ pass # just for size stat
399
+ with tarfile.open(tarball, "r:gz") as tar:
400
+ members = tar.getnames()
401
+
402
+ # Read back id/version for display
403
+ manifests = list(Path(skill_path).resolve().glob("*.skill.yaml")) + \
404
+ list(Path(skill_path).resolve().glob("skill.yaml"))
405
+ if manifests:
406
+ with open(manifests[0]) as f:
407
+ mdata = yaml.safe_load(f)
408
+ sid = mdata.get("id", "unknown")
409
+ sver = mdata.get("version", "0.0.0")
410
+ else:
411
+ sid, sver = "unknown", "0.0.0"
412
+
413
+ console.print(f"[green]Published:[/] {tarball}")
414
+ console.print(f" Skill: {sid} v{sver}")
415
+ console.print(f" Size: {tarball.stat().st_size / 1024:.1f} KB")
416
+
417
+ if registry:
418
+ _upload_to_registry(tarball, sid, sver, mdata.get("description", ""), mdata.get("tags"),
419
+ visibility=visibility)
420
+ else:
421
+ console.print()
422
+ console.print("[dim]Use --registry to upload to the AES registry.[/]")
423
+ else:
424
+ # Publish from agent.yaml
425
+ project_root = Path(path).resolve()
426
+ count = _publish_from_manifest(project_root, output_dir, skill)
427
+ if count:
428
+ console.print()
429
+ console.print(f"[green]Published {count} skill(s)[/] to {output_dir}")
430
+
431
+ if registry:
432
+ _upload_tarballs_from_dir(output_dir, project_root, skill, visibility=visibility)
aes/commands/search.py ADDED
@@ -0,0 +1,65 @@
1
+ """aes search — Search the AES package registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from aes.registry import fetch_index, search_packages
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.command("search")
17
+ @click.argument("query", default="")
18
+ @click.option("--tag", default=None, help="Filter by tag")
19
+ @click.option("--domain", default=None, help="Filter by domain (convention: domain as tag)")
20
+ @click.option("--type", "pkg_type", default=None, type=click.Choice(["skill", "template"]), help="Filter by package type")
21
+ def search_cmd(query: str, tag: Optional[str], domain: Optional[str], pkg_type: Optional[str]) -> None:
22
+ """Search the AES package registry.
23
+
24
+ \b
25
+ Examples:
26
+ aes search "deploy" # keyword search
27
+ aes search --tag ml # filter by tag
28
+ aes search --domain devops # filter by domain
29
+ aes search --type template # filter by type
30
+ aes search # list all packages
31
+ """
32
+ try:
33
+ index = fetch_index()
34
+ except Exception as exc:
35
+ console.print(f"[red]Error:[/] Failed to fetch registry: {exc}")
36
+ console.print("[dim]Check your network or set AES_REGISTRY_URL.[/]")
37
+ raise SystemExit(1)
38
+
39
+ results = search_packages(query=query, tag=tag, domain=domain, index=index, pkg_type=pkg_type)
40
+
41
+ if not results:
42
+ if query:
43
+ console.print(f"[dim]No packages matching '{query}'.[/]")
44
+ else:
45
+ console.print("[dim]No packages found in registry.[/]")
46
+ return
47
+
48
+ table = Table(title="AES Registry")
49
+ table.add_column("Name", style="bold")
50
+ table.add_column("Type", style="cyan")
51
+ table.add_column("Latest")
52
+ table.add_column("Description")
53
+ table.add_column("Tags", style="dim")
54
+
55
+ for pkg in sorted(results, key=lambda p: p["name"]):
56
+ table.add_row(
57
+ str(pkg["name"]),
58
+ str(pkg.get("type", "skill")),
59
+ str(pkg["latest"]),
60
+ str(pkg["description"]),
61
+ ", ".join(str(t) for t in pkg.get("tags", [])),
62
+ )
63
+
64
+ console.print(table)
65
+ console.print(f"\n[dim]{len(results)} package(s) found.[/]")
aes/commands/status.py ADDED
@@ -0,0 +1,153 @@
1
+ """aes status — Show sync status: what changed since last sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ import click
11
+ from rich.console import Console
12
+
13
+ from aes.config import AGENT_DIR, MANIFEST_FILE
14
+ from aes.targets import TARGETS, TARGET_NAMES, AgentContext, SyncPlan
15
+
16
+ console = Console()
17
+
18
+ SYNC_MANIFEST = ".aes-sync.json"
19
+
20
+
21
+ def _sha256(content: str) -> str:
22
+ return hashlib.sha256(content.encode()).hexdigest()[:16]
23
+
24
+
25
+ def _load_sync_manifest(project_root: Path) -> dict:
26
+ path = project_root / SYNC_MANIFEST
27
+ if path.exists():
28
+ with open(path) as f:
29
+ return json.load(f)
30
+ return {"files": {}, "synced_at": None}
31
+
32
+
33
+ @click.command("status")
34
+ @click.argument("path", default=".", type=click.Path(exists=True))
35
+ def status_cmd(path: str) -> None:
36
+ """Show sync status — what changed since last sync.
37
+
38
+ Re-generates tool configs in memory and compares against the stored
39
+ hashes from the last ``aes sync`` run.
40
+
41
+ PATH is the project root directory (default: current directory).
42
+ """
43
+ project_root = Path(path).resolve()
44
+ agent_dir = project_root / AGENT_DIR
45
+
46
+ if not agent_dir.exists():
47
+ console.print(f"[red]Error:[/] No {AGENT_DIR}/ directory found at {project_root}")
48
+ console.print("[dim]Run 'aes init' to create one.[/]")
49
+ raise SystemExit(1)
50
+
51
+ if not (agent_dir / MANIFEST_FILE).exists():
52
+ console.print(f"[red]Error:[/] No {MANIFEST_FILE} found in {agent_dir}")
53
+ raise SystemExit(1)
54
+
55
+ sync_manifest = _load_sync_manifest(project_root)
56
+ synced_at = sync_manifest.get("synced_at")
57
+ tracked_files = sync_manifest.get("files", {})
58
+
59
+ if not synced_at:
60
+ console.print("[yellow]No sync history found.[/]")
61
+ console.print("[dim]Run 'aes sync' to generate tool configs.[/]")
62
+ return
63
+
64
+ # Re-generate all plans in memory
65
+ from aes.commands.sync import _load_agent_context # noqa: avoid circular at top
66
+
67
+ ctx = _load_agent_context(project_root)
68
+ would_generate: dict = {} # rel_path -> content
69
+
70
+ for name in TARGET_NAMES:
71
+ adapter = TARGETS[name]()
72
+ plan = adapter.plan(ctx, force=True)
73
+ for gf in plan.files:
74
+ would_generate[gf.relative_path] = gf.content
75
+
76
+ # Compare
77
+ modified_sources: List[str] = [] # .agent/ changed → sync stale
78
+ output_status: List[tuple] = [] # (rel_path, status_str)
79
+ missing_outputs: List[str] = []
80
+ untracked_would: List[str] = []
81
+
82
+ for rel_path, info in tracked_files.items():
83
+ stored_hash = info.get("sha256", "")
84
+ full_path = project_root / rel_path
85
+
86
+ if not full_path.exists():
87
+ missing_outputs.append(rel_path)
88
+ continue
89
+
90
+ on_disk_hash = _sha256(full_path.read_text())
91
+
92
+ if rel_path in would_generate:
93
+ would_hash = _sha256(would_generate[rel_path])
94
+ if would_hash != stored_hash:
95
+ # Source .agent/ changed → sync would produce different output
96
+ modified_sources.append(rel_path)
97
+ elif on_disk_hash != stored_hash:
98
+ # Output was hand-edited after sync
99
+ output_status.append((rel_path, "manually edited"))
100
+ else:
101
+ output_status.append((rel_path, "up to date"))
102
+ else:
103
+ # Tracked file no longer generated (target removed?)
104
+ if on_disk_hash == stored_hash:
105
+ output_status.append((rel_path, "up to date (target removed)"))
106
+ else:
107
+ output_status.append((rel_path, "manually edited"))
108
+
109
+ # Files that would be generated but aren't tracked yet
110
+ for rel_path in would_generate:
111
+ if rel_path not in tracked_files:
112
+ untracked_would.append(rel_path)
113
+
114
+ # Print report
115
+ console.print(f"[bold].agent/ status[/] (last synced: {synced_at})")
116
+ console.print()
117
+
118
+ needs_sync = False
119
+
120
+ if modified_sources:
121
+ needs_sync = True
122
+ console.print(" [yellow]Source changed (needs sync):[/]")
123
+ for rp in modified_sources:
124
+ console.print(f" [yellow]~[/] {rp}")
125
+ console.print()
126
+
127
+ if missing_outputs:
128
+ needs_sync = True
129
+ console.print(" [red]Missing outputs:[/]")
130
+ for rp in missing_outputs:
131
+ console.print(f" [red]-[/] {rp}")
132
+ console.print()
133
+
134
+ if untracked_would:
135
+ needs_sync = True
136
+ console.print(" [yellow]New outputs (not yet synced):[/]")
137
+ for rp in untracked_would:
138
+ console.print(f" [green]+[/] {rp}")
139
+ console.print()
140
+
141
+ if output_status:
142
+ console.print(" [dim]Synced outputs:[/]")
143
+ for rp, status in output_status:
144
+ if status == "up to date":
145
+ console.print(f" [green]=[/] {rp} [dim]({status})[/]")
146
+ else:
147
+ console.print(f" [yellow]![/] {rp} [dim]({status})[/]")
148
+ console.print()
149
+
150
+ if needs_sync:
151
+ console.print("[yellow]Action:[/] run `aes sync` to update tool configs.")
152
+ else:
153
+ console.print("[green]Everything up to date.[/]")