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.
- aes/__init__.py +5 -0
- aes/__main__.py +37 -0
- aes/analyzer.py +487 -0
- aes/commands/__init__.py +0 -0
- aes/commands/init.py +727 -0
- aes/commands/inspect.py +204 -0
- aes/commands/install.py +379 -0
- aes/commands/publish.py +432 -0
- aes/commands/search.py +65 -0
- aes/commands/status.py +153 -0
- aes/commands/sync.py +413 -0
- aes/commands/validate.py +77 -0
- aes/config.py +43 -0
- aes/domains.py +1382 -0
- aes/frameworks.py +522 -0
- aes/mcp_server.py +213 -0
- aes/registry.py +294 -0
- aes/scaffold/agent.yaml.jinja +135 -0
- aes/scaffold/agentignore.jinja +61 -0
- aes/scaffold/instructions.md.jinja +311 -0
- aes/scaffold/local.example.yaml.jinja +35 -0
- aes/scaffold/local.yaml.jinja +29 -0
- aes/scaffold/operations.md.jinja +33 -0
- aes/scaffold/orchestrator.md.jinja +95 -0
- aes/scaffold/permissions.yaml.jinja +151 -0
- aes/scaffold/setup.md.jinja +244 -0
- aes/scaffold/skill.md.jinja +27 -0
- aes/scaffold/skill.yaml.jinja +175 -0
- aes/scaffold/workflow.yaml.jinja +44 -0
- aes/scaffold/workflow_command.md.jinja +48 -0
- aes/schemas/agent.schema.json +188 -0
- aes/schemas/permissions.schema.json +100 -0
- aes/schemas/registry.schema.json +72 -0
- aes/schemas/skill.schema.json +209 -0
- aes/schemas/workflow.schema.json +92 -0
- aes/targets/__init__.py +29 -0
- aes/targets/_base.py +77 -0
- aes/targets/_composer.py +338 -0
- aes/targets/claude.py +153 -0
- aes/targets/copilot.py +48 -0
- aes/targets/cursor.py +46 -0
- aes/targets/windsurf.py +46 -0
- aes/validator.py +394 -0
- aes_cli-0.2.0.dist-info/METADATA +110 -0
- aes_cli-0.2.0.dist-info/RECORD +48 -0
- aes_cli-0.2.0.dist-info/WHEEL +5 -0
- aes_cli-0.2.0.dist-info/entry_points.txt +3 -0
- aes_cli-0.2.0.dist-info/top_level.txt +1 -0
aes/commands/publish.py
ADDED
|
@@ -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.[/]")
|