aes-cli 0.5.0__tar.gz → 0.6.0__tar.gz

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 (86) hide show
  1. {aes_cli-0.5.0 → aes_cli-0.6.0}/PKG-INFO +1 -1
  2. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/__init__.py +1 -1
  3. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/__main__.py +4 -0
  4. aes_cli-0.6.0/aes/commands/bom.py +131 -0
  5. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/init.py +7 -0
  6. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/inspect.py +42 -1
  7. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/publish.py +77 -9
  8. aes_cli-0.6.0/aes/commands/upgrade.py +282 -0
  9. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/config.py +4 -0
  10. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/i18n/_messages.py +48 -0
  11. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/i18n/ja.py +48 -0
  12. aes_cli-0.6.0/aes/migrations.py +72 -0
  13. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/agent.yaml.jinja +12 -2
  14. aes_cli-0.6.0/aes/scaffold/bom.yaml.jinja +27 -0
  15. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/instructions.md.jinja +4 -4
  16. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/permissions.yaml.jinja +30 -2
  17. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/skill.yaml.jinja +2 -2
  18. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/workflow.yaml.jinja +1 -1
  19. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/schemas/agent.schema.json +56 -0
  20. aes_cli-0.6.0/aes/schemas/bom.schema.json +77 -0
  21. aes_cli-0.6.0/aes/schemas/decision-record.schema.json +78 -0
  22. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/schemas/permissions.schema.json +51 -0
  23. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/claude.py +11 -7
  24. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/validator.py +12 -1
  25. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes_cli.egg-info/PKG-INFO +1 -1
  26. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes_cli.egg-info/SOURCES.txt +8 -0
  27. {aes_cli-0.5.0 → aes_cli-0.6.0}/pyproject.toml +1 -1
  28. aes_cli-0.6.0/tests/test_bom.py +132 -0
  29. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_init.py +1 -1
  30. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_sync.py +34 -0
  31. aes_cli-0.6.0/tests/test_upgrade.py +430 -0
  32. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_validate.py +326 -0
  33. {aes_cli-0.5.0 → aes_cli-0.6.0}/README.md +0 -0
  34. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/analyzer.py +0 -0
  35. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/__init__.py +0 -0
  36. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/install.py +0 -0
  37. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/search.py +0 -0
  38. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/status.py +0 -0
  39. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/sync.py +0 -0
  40. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/commands/validate.py +0 -0
  41. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/domains.py +0 -0
  42. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/frameworks.py +0 -0
  43. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/global_config.py +0 -0
  44. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/i18n/__init__.py +0 -0
  45. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/i18n/domains_ja.py +0 -0
  46. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/mcp_server.py +0 -0
  47. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/registry.py +0 -0
  48. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/agentignore.jinja +0 -0
  49. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/instructions.md.jinja +0 -0
  50. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/memory_command.md.jinja +0 -0
  51. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/operations.md.jinja +0 -0
  52. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/orchestrator.md.jinja +0 -0
  53. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/setup.md.jinja +0 -0
  54. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/skill.md.jinja +0 -0
  55. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/ja/workflow_command.md.jinja +0 -0
  56. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/local.example.yaml.jinja +0 -0
  57. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/local.yaml.jinja +0 -0
  58. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/memory_command.md.jinja +0 -0
  59. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/operations.md.jinja +0 -0
  60. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/orchestrator.md.jinja +0 -0
  61. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/setup.md.jinja +0 -0
  62. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/skill.md.jinja +0 -0
  63. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/scaffold/workflow_command.md.jinja +0 -0
  64. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/schemas/registry.schema.json +0 -0
  65. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/schemas/skill.schema.json +0 -0
  66. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/schemas/workflow.schema.json +0 -0
  67. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/__init__.py +0 -0
  68. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/_base.py +0 -0
  69. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/_composer.py +0 -0
  70. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/copilot.py +0 -0
  71. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/cursor.py +0 -0
  72. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes/targets/windsurf.py +0 -0
  73. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes_cli.egg-info/dependency_links.txt +0 -0
  74. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes_cli.egg-info/entry_points.txt +0 -0
  75. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes_cli.egg-info/requires.txt +0 -0
  76. {aes_cli-0.5.0 → aes_cli-0.6.0}/aes_cli.egg-info/top_level.txt +0 -0
  77. {aes_cli-0.5.0 → aes_cli-0.6.0}/setup.cfg +0 -0
  78. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_analyzer.py +0 -0
  79. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_frameworks.py +0 -0
  80. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_inspect.py +0 -0
  81. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_install.py +0 -0
  82. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_mcp_server.py +0 -0
  83. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_publish.py +0 -0
  84. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_registry.py +0 -0
  85. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_search.py +0 -0
  86. {aes_cli-0.5.0 → aes_cli-0.6.0}/tests/test_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aes-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: CLI tool for the Agentic Engineering Standard
5
5
  Author: Hiro
6
6
  License: Apache-2.0
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.6.0"
@@ -16,6 +16,8 @@ from aes.commands.install import install_cmd
16
16
  from aes.commands.search import search_cmd
17
17
  from aes.commands.status import status_cmd
18
18
  from aes.commands.sync import sync_cmd
19
+ from aes.commands.upgrade import upgrade_cmd
20
+ from aes.commands.bom import bom_cmd
19
21
 
20
22
 
21
23
  def _prompt_language() -> None:
@@ -77,6 +79,8 @@ cli.add_command(install_cmd, "install")
77
79
  cli.add_command(sync_cmd, "sync")
78
80
  cli.add_command(status_cmd, "status")
79
81
  cli.add_command(search_cmd, "search")
82
+ cli.add_command(upgrade_cmd, "upgrade")
83
+ cli.add_command(bom_cmd, "bom")
80
84
 
81
85
 
82
86
  if __name__ == "__main__":
@@ -0,0 +1,131 @@
1
+ """aes bom — Display the Agent Bill of Materials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import yaml
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from aes.config import AGENT_DIR, BOM_FILE
13
+ from aes.i18n import t
14
+
15
+ console = Console()
16
+
17
+
18
+ def _load_bom(agent_dir: Path) -> dict:
19
+ """Load and return bom.yaml, or empty dict."""
20
+ bom_path = agent_dir / BOM_FILE
21
+ if not bom_path.exists():
22
+ return {}
23
+ with open(bom_path) as f:
24
+ data = yaml.safe_load(f)
25
+ return data if isinstance(data, dict) else {}
26
+
27
+
28
+ @click.command("bom")
29
+ @click.argument("path", default=".", type=click.Path(exists=True))
30
+ def bom_cmd(path: str) -> None:
31
+ """Display the Agent Bill of Materials (AI-BOM).
32
+
33
+ Shows all models, frameworks, tools, and data sources
34
+ declared in .agent/bom.yaml.
35
+
36
+ \b
37
+ Examples:
38
+ aes bom # current project
39
+ aes bom ./my-project # specific project
40
+ """
41
+ project_root = Path(path).resolve()
42
+ agent_dir = project_root / AGENT_DIR
43
+
44
+ if not agent_dir.exists():
45
+ console.print(f"[red]{t('common.error')}:[/] {t('common.no_agent_dir', agent_dir=AGENT_DIR, path=project_root)}")
46
+ raise SystemExit(1)
47
+
48
+ bom = _load_bom(agent_dir)
49
+ if not bom:
50
+ console.print(f"\n [dim]{t('bom.not_found')}[/]\n")
51
+ return
52
+
53
+ console.print(f"\n[bold]{t('bom.title')}[/] [dim](AES {bom.get('aes_bom', '?')})[/]\n")
54
+
55
+ # Models
56
+ models = bom.get("models", [])
57
+ if models:
58
+ table = Table(title=t("bom.models_table"), show_header=True, header_style="bold")
59
+ table.add_column(t("bom.col_name"), style="cyan")
60
+ table.add_column(t("bom.col_provider"))
61
+ table.add_column(t("bom.col_purpose"))
62
+ table.add_column(t("bom.col_license"))
63
+ for m in models:
64
+ table.add_row(
65
+ m.get("name", "?"),
66
+ m.get("provider", "?"),
67
+ m.get("purpose", "-"),
68
+ m.get("license", "-"),
69
+ )
70
+ console.print(table)
71
+ console.print()
72
+
73
+ # Frameworks
74
+ frameworks = bom.get("frameworks", [])
75
+ if frameworks:
76
+ table = Table(title=t("bom.frameworks_table"), show_header=True, header_style="bold")
77
+ table.add_column(t("bom.col_name"), style="cyan")
78
+ table.add_column(t("bom.col_version"))
79
+ table.add_column(t("bom.col_license"))
80
+ for fw in frameworks:
81
+ table.add_row(
82
+ fw.get("name", "?"),
83
+ fw.get("version", "-"),
84
+ fw.get("license", "-"),
85
+ )
86
+ console.print(table)
87
+ console.print()
88
+
89
+ # Tools
90
+ tools = bom.get("tools", [])
91
+ if tools:
92
+ table = Table(title=t("bom.tools_table"), show_header=True, header_style="bold")
93
+ table.add_column(t("bom.col_name"), style="cyan")
94
+ table.add_column(t("bom.col_type"))
95
+ table.add_column(t("bom.col_version"))
96
+ table.add_column(t("bom.col_source"))
97
+ for tool in tools:
98
+ table.add_row(
99
+ tool.get("name", "?"),
100
+ tool.get("type", "?"),
101
+ tool.get("version", "-"),
102
+ tool.get("source", "-"),
103
+ )
104
+ console.print(table)
105
+ console.print()
106
+
107
+ # Data Sources
108
+ data_sources = bom.get("data_sources", [])
109
+ if data_sources:
110
+ table = Table(title=t("bom.data_sources_table"), show_header=True, header_style="bold")
111
+ table.add_column(t("bom.col_name"), style="cyan")
112
+ table.add_column(t("bom.col_type"))
113
+ table.add_column(t("bom.col_uri"))
114
+ table.add_column(t("bom.col_license"))
115
+ for ds in data_sources:
116
+ table.add_row(
117
+ ds.get("name", "?"),
118
+ ds.get("type", "?"),
119
+ ds.get("uri", "-"),
120
+ ds.get("license", "-"),
121
+ )
122
+ console.print(table)
123
+ console.print()
124
+
125
+ # Summary
126
+ console.print(f"[bold]{t('bom.summary')}[/]")
127
+ console.print(f" {t('bom.models_count', count=len(models))}")
128
+ console.print(f" {t('bom.frameworks_count', count=len(frameworks))}")
129
+ console.print(f" {t('bom.tools_count', count=len(tools))}")
130
+ console.print(f" {t('bom.data_sources_count', count=len(data_sources))}")
131
+ console.print()
@@ -20,6 +20,8 @@ from rich.tree import Tree
20
20
  from aes.config import (
21
21
  AGENT_DIR,
22
22
  AGENTIGNORE_FILE,
23
+ BOM_FILE,
24
+ DECISIONS_DIR,
23
25
  LOCAL_EXAMPLE_FILE,
24
26
  LOCAL_FILE,
25
27
  SCAFFOLD_DIR,
@@ -620,6 +622,7 @@ def init_cmd(
620
622
  agent_dir.mkdir(exist_ok=True)
621
623
  (agent_dir / MEMORY_DIR).mkdir(exist_ok=True)
622
624
  (agent_dir / MEMORY_DIR / "sessions").mkdir(exist_ok=True)
625
+ (agent_dir / DECISIONS_DIR).mkdir(parents=True, exist_ok=True)
623
626
  (agent_dir / OVERRIDES_DIR).mkdir(exist_ok=True)
624
627
 
625
628
  if skills:
@@ -664,6 +667,10 @@ def init_cmd(
664
667
  content = _render_template(env, "permissions.yaml.jinja", context)
665
668
  (agent_dir / "permissions.yaml").write_text(content)
666
669
 
670
+ # bom.yaml
671
+ content = _render_template(env, "bom.yaml.jinja", context)
672
+ (agent_dir / BOM_FILE).write_text(content)
673
+
667
674
  # .agentignore
668
675
  agentignore_path = project_root / AGENTIGNORE_FILE
669
676
  if not agentignore_path.exists():
@@ -12,7 +12,7 @@ import yaml
12
12
  from rich.console import Console
13
13
  from rich.table import Table
14
14
 
15
- from aes.config import AGENT_DIR
15
+ from aes.config import AGENT_DIR, BOM_FILE, DECISIONS_DIR
16
16
  from aes.i18n import t
17
17
  from aes.registry import (
18
18
  fetch_index,
@@ -205,6 +205,47 @@ def _inspect_local(path: str) -> None:
205
205
  console.print(table)
206
206
  console.print()
207
207
 
208
+ # Models
209
+ models = manifest.get("models", [])
210
+ if models:
211
+ console.print(f"[bold]{t('inspect.models_section')}[/]")
212
+ for m in models:
213
+ purpose = m.get("purpose", "")
214
+ purpose_str = f" [dim]({purpose})[/]" if purpose else ""
215
+ console.print(f" {m.get('name', '?')} — {m.get('provider', '?')}{purpose_str}")
216
+ console.print()
217
+
218
+ # Provenance
219
+ provenance = manifest.get("provenance", {})
220
+ if provenance:
221
+ console.print(f"[bold]{t('inspect.provenance_section')}[/]")
222
+ if provenance.get("created_by"):
223
+ console.print(f" {t('inspect.provenance_created_by', value=provenance['created_by'])}")
224
+ if provenance.get("source"):
225
+ console.print(f" {t('inspect.provenance_source', value=provenance['source'])}")
226
+ console.print()
227
+
228
+ # BOM summary
229
+ bom_path = agent_dir / BOM_FILE
230
+ if bom_path.exists():
231
+ bom = _load_yaml(bom_path)
232
+ n_models = len(bom.get("models", []))
233
+ n_frameworks = len(bom.get("frameworks", []))
234
+ n_tools = len(bom.get("tools", []))
235
+ n_data = len(bom.get("data_sources", []))
236
+ console.print(f"[bold]{t('inspect.bom_section')}[/]")
237
+ console.print(f" {t('inspect.bom_summary', models=n_models, frameworks=n_frameworks, tools=n_tools, data=n_data)}")
238
+ console.print()
239
+
240
+ # Decision records count
241
+ decisions_dir = agent_dir / DECISIONS_DIR
242
+ if decisions_dir.exists() and decisions_dir.is_dir():
243
+ dr_count = len(list(decisions_dir.glob("*.yaml")))
244
+ if dr_count > 0:
245
+ console.print(f"[bold]{t('inspect.decisions_section')}[/]")
246
+ console.print(f" {t('inspect.decisions_count', count=dr_count)}")
247
+ console.print()
248
+
208
249
  # Summary
209
250
  console.print(f"[bold]{t('inspect.summary')}[/]")
210
251
  console.print(f" {t('inspect.skills_count', count=len(skills))}")
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import fnmatch
6
+ import hashlib
7
+ import json
6
8
  import shutil
7
9
  import sys
8
10
  import tarfile
@@ -14,7 +16,7 @@ import click
14
16
  import yaml
15
17
  from rich.console import Console
16
18
 
17
- from aes.config import AGENT_DIR, MANIFEST_FILE
19
+ from aes.config import AGENT_DIR, BOM_FILE, MANIFEST_FILE
18
20
  from aes.i18n import t
19
21
 
20
22
  console = Console()
@@ -171,6 +173,53 @@ def _validate_before_publish(project_root: Path) -> bool:
171
173
  return True
172
174
 
173
175
 
176
+ def _media_type_for_path(path: str) -> str:
177
+ """Return a media type string for a file path."""
178
+ if path.endswith(".yaml") or path.endswith(".yml"):
179
+ return "application/vnd.aes.agent-config.v1+yaml"
180
+ if path.endswith(".md"):
181
+ return "text/markdown"
182
+ if path.endswith(".json"):
183
+ return "application/json"
184
+ return "application/octet-stream"
185
+
186
+
187
+ def _build_package_manifest(
188
+ name: str,
189
+ version: str,
190
+ aes_version: str,
191
+ pkg_type: str,
192
+ files: List[tuple],
193
+ ) -> dict:
194
+ """Build an aes-manifest.json dict.
195
+
196
+ *files* is a list of ``(arcname, file_path)`` tuples.
197
+ """
198
+ layers = []
199
+ for arcname, file_path in files:
200
+ data = file_path.read_bytes()
201
+ digest = hashlib.sha256(data).hexdigest()
202
+ layers.append({
203
+ "mediaType": _media_type_for_path(arcname),
204
+ "digest": f"sha256:{digest}",
205
+ "size": len(data),
206
+ "path": arcname,
207
+ })
208
+
209
+ return {
210
+ "schemaVersion": 1,
211
+ "mediaType": "application/vnd.aes.package.v1+tar+gzip",
212
+ "config": {
213
+ "name": name,
214
+ "version": version,
215
+ "type": pkg_type,
216
+ "aes": aes_version,
217
+ },
218
+ "layers": layers,
219
+ "signature": None,
220
+ }
221
+
222
+
174
223
  def _publish_template_dir(
175
224
  project_root: Path,
176
225
  output_dir: Path,
@@ -194,6 +243,7 @@ def _publish_template_dir(
194
243
 
195
244
  name = manifest.get("name", "unknown")
196
245
  version = manifest.get("version", "0.0.0")
246
+ aes_version = manifest.get("aes", "1.0")
197
247
 
198
248
  # Build exclusion list
199
249
  if include_all:
@@ -208,17 +258,35 @@ def _publish_template_dir(
208
258
  tarball_name = f"{name}-{version}.tar.gz"
209
259
  tarball_path = output_dir / tarball_name
210
260
 
261
+ # Collect files and build package manifest
262
+ included_files: List[tuple] = [] # (rel_path_in_agent, absolute_path)
263
+
264
+ for file_path in sorted(agent_dir.rglob("*")):
265
+ if not file_path.is_file():
266
+ continue
267
+ rel = file_path.relative_to(agent_dir)
268
+ rel_str = str(rel)
269
+ if _is_excluded(rel_str, excludes):
270
+ continue
271
+ included_files.append((f"{AGENT_DIR}/{rel_str}", file_path))
272
+
273
+ # Generate aes-manifest.json
274
+ pkg_manifest = _build_package_manifest(
275
+ name, version, aes_version, "template", included_files,
276
+ )
277
+
211
278
  with tarfile.open(tarball_path, "w:gz") as tar:
212
- for file_path in sorted(agent_dir.rglob("*")):
213
- if not file_path.is_file():
214
- continue
215
- rel = file_path.relative_to(agent_dir)
216
- rel_str = str(rel)
217
- if _is_excluded(rel_str, excludes):
218
- continue
219
- arcname = f"{name}/{AGENT_DIR}/{rel_str}"
279
+ for rel_path, file_path in included_files:
280
+ arcname = f"{name}/{rel_path}"
220
281
  tar.add(file_path, arcname=arcname)
221
282
 
283
+ # Add aes-manifest.json at the package root
284
+ manifest_json = json.dumps(pkg_manifest, indent=2).encode()
285
+ import io
286
+ info = tarfile.TarInfo(name=f"{name}/aes-manifest.json")
287
+ info.size = len(manifest_json)
288
+ tar.addfile(info, io.BytesIO(manifest_json))
289
+
222
290
  return tarball_path
223
291
 
224
292
 
@@ -0,0 +1,282 @@
1
+ """aes upgrade — Upgrade .agent/ to the current AES spec version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import List, Optional
8
+
9
+ import click
10
+ import yaml
11
+ from jinja2 import ChoiceLoader, Environment, FileSystemLoader
12
+ from rich.console import Console
13
+
14
+ from aes.config import AGENT_DIR, COMMANDS_DIR, MANIFEST_FILE, SCAFFOLD_DIR
15
+ from aes.i18n import t
16
+ from aes.migrations import (
17
+ CURRENT_SPEC_VERSION,
18
+ Migration,
19
+ MigrationFile,
20
+ applicable_migrations,
21
+ )
22
+
23
+ console = Console()
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Upgrade plan data structures
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ @dataclass
32
+ class PlannedFile:
33
+ """A file to create during upgrade."""
34
+
35
+ migration_file: MigrationFile
36
+ create_file: bool # whether the file needs creating
37
+ add_manifest_entry: bool # whether the manifest entry needs adding
38
+
39
+
40
+ @dataclass
41
+ class UpgradePlan:
42
+ """The full upgrade plan."""
43
+
44
+ current_version: str
45
+ target_version: str
46
+ migrations: List[Migration] = field(default_factory=list)
47
+ planned_files: List[PlannedFile] = field(default_factory=list)
48
+
49
+ @property
50
+ def has_changes(self) -> bool:
51
+ return bool(self.planned_files) or self.current_version != self.target_version
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Plan computation
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def _compute_plan(agent_dir: Path, manifest: dict) -> UpgradePlan:
60
+ """Compare current .agent/ against migrations to determine needed changes."""
61
+ current_version = manifest.get("aes", "1.0")
62
+ migrations = applicable_migrations(current_version)
63
+
64
+ plan = UpgradePlan(
65
+ current_version=current_version,
66
+ target_version=CURRENT_SPEC_VERSION,
67
+ migrations=migrations,
68
+ )
69
+
70
+ for migration in migrations:
71
+ for mf in migration.files:
72
+ file_path = agent_dir / mf.relative_path
73
+ file_exists = file_path.exists()
74
+
75
+ entry_exists = False
76
+ if mf.manifest_entry and mf.manifest_section:
77
+ entry_id = mf.manifest_entry.get("id", "")
78
+ section = manifest.get(mf.manifest_section, [])
79
+ entry_exists = any(
80
+ item.get("id") == entry_id for item in section
81
+ )
82
+
83
+ needs_file = not file_exists
84
+ needs_entry = bool(mf.manifest_entry) and not entry_exists
85
+
86
+ if needs_file or needs_entry:
87
+ plan.planned_files.append(
88
+ PlannedFile(
89
+ migration_file=mf,
90
+ create_file=needs_file,
91
+ add_manifest_entry=needs_entry,
92
+ )
93
+ )
94
+
95
+ return plan
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Plan display
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ def _display_plan(plan: UpgradePlan) -> None:
104
+ """Display the upgrade plan without applying it."""
105
+ console.print()
106
+ console.print(f"[bold]{t('upgrade.title')}[/]")
107
+ console.print()
108
+ console.print(
109
+ f" {t('upgrade.current_version', version=plan.current_version)}"
110
+ )
111
+ console.print(
112
+ f" {t('upgrade.target_version', version=plan.target_version)}"
113
+ )
114
+ console.print()
115
+
116
+ for migration in plan.migrations:
117
+ console.print(
118
+ f" [bold cyan]{t('upgrade.migration_header', from_v=migration.from_version, to_v=migration.to_version, description=migration.description)}[/]"
119
+ )
120
+
121
+ for pf in plan.planned_files:
122
+ mf = pf.migration_file
123
+ if pf.create_file:
124
+ console.print(
125
+ f" [green]+[/] {mf.relative_path} [dim]({t('upgrade.new_file')})[/]"
126
+ )
127
+ if pf.add_manifest_entry and mf.manifest_entry:
128
+ entry_id = mf.manifest_entry.get("id", "?")
129
+ console.print(
130
+ f" [green]+[/] agent.yaml: {mf.manifest_section}[] [dim]({t('upgrade.add_entry', id=entry_id)})[/]"
131
+ )
132
+
133
+ if plan.current_version != plan.target_version:
134
+ console.print(
135
+ f" [yellow]~[/] agent.yaml: aes [dim]({t('upgrade.bump_version', old=plan.current_version, new=plan.target_version)})[/]"
136
+ )
137
+
138
+ console.print()
139
+ console.print(f" [dim]{t('upgrade.run_apply')}[/]")
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Apply logic
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def _apply_plan(
148
+ agent_dir: Path,
149
+ manifest: dict,
150
+ plan: UpgradePlan,
151
+ env: Environment,
152
+ context: dict,
153
+ ) -> int:
154
+ """Apply the upgrade plan. Returns number of files created."""
155
+ created = 0
156
+
157
+ for pf in plan.planned_files:
158
+ mf = pf.migration_file
159
+
160
+ # Create missing file from template
161
+ if pf.create_file:
162
+ tmpl = env.get_template(mf.template_name)
163
+ content = tmpl.render(**context)
164
+ file_path = agent_dir / mf.relative_path
165
+ file_path.parent.mkdir(parents=True, exist_ok=True)
166
+ file_path.write_text(content)
167
+ console.print(
168
+ f" [green]+[/] {t('upgrade.created_file', path=mf.relative_path)}"
169
+ )
170
+ created += 1
171
+
172
+ # Add manifest entry
173
+ if pf.add_manifest_entry and mf.manifest_entry:
174
+ section = manifest.setdefault(mf.manifest_section, [])
175
+ section.append(dict(mf.manifest_entry))
176
+
177
+ # Bump aes version
178
+ if plan.current_version != plan.target_version:
179
+ manifest["aes"] = plan.target_version
180
+
181
+ # Write updated agent.yaml
182
+ yaml_path = agent_dir / MANIFEST_FILE
183
+ with open(yaml_path, "w") as f:
184
+ yaml.safe_dump(
185
+ manifest,
186
+ f,
187
+ default_flow_style=False,
188
+ sort_keys=False,
189
+ allow_unicode=True,
190
+ )
191
+ console.print(
192
+ f" [yellow]~[/] {t('upgrade.updated_manifest', old=plan.current_version, new=plan.target_version)}"
193
+ )
194
+
195
+ return created
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # CLI command
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ @click.command("upgrade")
204
+ @click.argument("path", default=".", type=click.Path(exists=True))
205
+ @click.option("--apply", "do_apply", is_flag=True, help="Apply the upgrade (default: dry-run).")
206
+ def upgrade_cmd(path: str, do_apply: bool) -> None:
207
+ """Upgrade .agent/ to the current AES spec version.
208
+
209
+ Adds missing files and manifest entries introduced in newer versions
210
+ without touching existing customizations.
211
+
212
+ Without --apply, shows what would change (dry-run).
213
+
214
+ \b
215
+ Examples:
216
+ aes upgrade # show upgrade plan
217
+ aes upgrade --apply # apply and auto-sync
218
+ aes upgrade /path/to/project # target a specific project
219
+ """
220
+ project_root = Path(path).resolve()
221
+ agent_dir = project_root / AGENT_DIR
222
+
223
+ if not agent_dir.exists() or not (agent_dir / MANIFEST_FILE).exists():
224
+ console.print(
225
+ f"[red]{t('common.error')}:[/] {t('upgrade.no_agent_dir')}"
226
+ )
227
+ console.print(f"[dim]{t('common.run_init_hint')}[/]")
228
+ raise SystemExit(1)
229
+
230
+ # Load manifest
231
+ with open(agent_dir / MANIFEST_FILE) as f:
232
+ manifest = yaml.safe_load(f) or {}
233
+
234
+ # Compute plan
235
+ plan = _compute_plan(agent_dir, manifest)
236
+
237
+ if not plan.has_changes:
238
+ console.print(
239
+ f"\n {t('upgrade.up_to_date', version=plan.target_version)}\n"
240
+ )
241
+ return
242
+
243
+ if not do_apply:
244
+ _display_plan(plan)
245
+ return
246
+
247
+ # Build Jinja2 environment (locale-aware, same as init.py)
248
+ from aes.i18n import get_current_locale
249
+
250
+ locale = get_current_locale()
251
+ loaders = []
252
+ if locale != "en":
253
+ locale_dir = SCAFFOLD_DIR / locale
254
+ if locale_dir.exists():
255
+ loaders.append(FileSystemLoader(str(locale_dir)))
256
+ loaders.append(FileSystemLoader(str(SCAFFOLD_DIR)))
257
+
258
+ env = Environment(
259
+ loader=ChoiceLoader(loaders),
260
+ keep_trailing_newline=True,
261
+ )
262
+
263
+ # Template context — minimal, just what the templates need
264
+ context = {
265
+ "name": manifest.get("name", "project"),
266
+ "domain": manifest.get("domain", "other"),
267
+ "language": manifest.get("runtime", {}).get("language", "other"),
268
+ }
269
+
270
+ console.print()
271
+ created = _apply_plan(agent_dir, manifest, plan, env, context)
272
+
273
+ # Auto-sync
274
+ from aes.commands.sync import run_sync
275
+
276
+ synced = run_sync(project_root, force=True, quiet=True)
277
+ if synced > 0:
278
+ console.print(f" [green]{t('upgrade.synced', count=synced)}[/]")
279
+
280
+ console.print(
281
+ f"\n [bold green]{t('upgrade.applied', count=created)}[/]\n"
282
+ )
@@ -29,9 +29,11 @@ REGISTRY_DIR = "registry"
29
29
  WORKFLOWS_DIR = "workflows"
30
30
  COMMANDS_DIR = "commands"
31
31
  MEMORY_DIR = "memory"
32
+ DECISIONS_DIR = "memory/decisions"
32
33
  OVERRIDES_DIR = "overrides"
33
34
  LOCAL_FILE = "local.yaml"
34
35
  LOCAL_EXAMPLE_FILE = "local.example.yaml"
36
+ BOM_FILE = "bom.yaml"
35
37
 
36
38
  # Schema file mapping
37
39
  SCHEMA_MAP = {
@@ -40,4 +42,6 @@ SCHEMA_MAP = {
40
42
  "workflow": "workflow.schema.json",
41
43
  "registry": "registry.schema.json",
42
44
  "permissions": "permissions.schema.json",
45
+ "bom": "bom.schema.json",
46
+ "decision-record": "decision-record.schema.json",
43
47
  }