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/inspect.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""aes inspect — Show project structure and stats."""
|
|
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
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_yaml(path: Path) -> dict:
|
|
18
|
+
"""Load YAML file, return empty dict on error."""
|
|
19
|
+
try:
|
|
20
|
+
with open(path) as f:
|
|
21
|
+
data = yaml.safe_load(f)
|
|
22
|
+
return data if isinstance(data, dict) else {}
|
|
23
|
+
except Exception:
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _render_workflow_diagram(workflow: dict) -> str:
|
|
28
|
+
"""Render a simple ASCII state diagram from a workflow definition."""
|
|
29
|
+
states = workflow.get("states", {})
|
|
30
|
+
transitions = workflow.get("transitions", [])
|
|
31
|
+
|
|
32
|
+
if not states or not transitions:
|
|
33
|
+
return " (no states or transitions defined)"
|
|
34
|
+
|
|
35
|
+
lines = []
|
|
36
|
+
# Find initial and terminal states
|
|
37
|
+
initial = [s for s, v in states.items() if v.get("initial")]
|
|
38
|
+
terminal = [s for s, v in states.items() if v.get("terminal")]
|
|
39
|
+
intermediate = [s for s in states if s not in initial and s not in terminal]
|
|
40
|
+
|
|
41
|
+
# Render flow
|
|
42
|
+
all_ordered = initial + intermediate + terminal
|
|
43
|
+
if all_ordered:
|
|
44
|
+
# Build transition map
|
|
45
|
+
tx_map: dict[str, list[str]] = {}
|
|
46
|
+
for tx in transitions:
|
|
47
|
+
src = tx.get("from", "")
|
|
48
|
+
dst = tx.get("to", "")
|
|
49
|
+
tx_map.setdefault(src, []).append(dst)
|
|
50
|
+
|
|
51
|
+
# Show forward transitions
|
|
52
|
+
forward_chain = initial.copy()
|
|
53
|
+
visited = set(initial)
|
|
54
|
+
current = initial[0] if initial else ""
|
|
55
|
+
while current:
|
|
56
|
+
targets = tx_map.get(current, [])
|
|
57
|
+
next_state = None
|
|
58
|
+
for t in targets:
|
|
59
|
+
if t not in visited and t not in terminal:
|
|
60
|
+
next_state = t
|
|
61
|
+
break
|
|
62
|
+
if next_state:
|
|
63
|
+
forward_chain.append(next_state)
|
|
64
|
+
visited.add(next_state)
|
|
65
|
+
current = next_state
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
lines.append(" " + " --> ".join(forward_chain))
|
|
70
|
+
if terminal:
|
|
71
|
+
lines.append(" Terminal: " + ", ".join(terminal))
|
|
72
|
+
|
|
73
|
+
# Show backward transitions
|
|
74
|
+
backward = [tx for tx in transitions if tx.get("to") in visited and
|
|
75
|
+
all_ordered.index(tx.get("from", "")) > all_ordered.index(tx.get("to", ""))
|
|
76
|
+
if tx.get("from", "") in all_ordered and tx.get("to", "") in all_ordered]
|
|
77
|
+
for tx in backward:
|
|
78
|
+
lines.append(f" (loop) {tx['from']} --> {tx['to']}: {tx.get('description', 'reframe')}")
|
|
79
|
+
|
|
80
|
+
return "\n".join(lines) if lines else " (could not render diagram)"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@click.command("inspect")
|
|
84
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
85
|
+
def inspect_cmd(path: str) -> None:
|
|
86
|
+
"""Show AES project structure and statistics.
|
|
87
|
+
|
|
88
|
+
PATH is the project root directory (default: current directory).
|
|
89
|
+
"""
|
|
90
|
+
project_root = Path(path).resolve()
|
|
91
|
+
agent_dir = project_root / AGENT_DIR
|
|
92
|
+
|
|
93
|
+
if not agent_dir.exists():
|
|
94
|
+
console.print(f"[red]Error:[/] No {AGENT_DIR}/ directory found at {project_root}")
|
|
95
|
+
raise SystemExit(1)
|
|
96
|
+
|
|
97
|
+
manifest_path = agent_dir / "agent.yaml"
|
|
98
|
+
if not manifest_path.exists():
|
|
99
|
+
console.print(f"[red]Error:[/] No agent.yaml found in {agent_dir}")
|
|
100
|
+
raise SystemExit(1)
|
|
101
|
+
|
|
102
|
+
manifest = _load_yaml(manifest_path)
|
|
103
|
+
|
|
104
|
+
# Header
|
|
105
|
+
console.print()
|
|
106
|
+
console.print(f"[bold]{manifest.get('name', 'unknown')}[/] v{manifest.get('version', '?')}")
|
|
107
|
+
console.print(f" {manifest.get('description', '')}")
|
|
108
|
+
console.print(f" Domain: {manifest.get('domain', 'unspecified')} | "
|
|
109
|
+
f"Language: {manifest.get('runtime', {}).get('language', '?')} | "
|
|
110
|
+
f"AES: {manifest.get('aes', '?')}")
|
|
111
|
+
console.print()
|
|
112
|
+
|
|
113
|
+
# Skills table
|
|
114
|
+
skills = manifest.get("skills", [])
|
|
115
|
+
if skills:
|
|
116
|
+
table = Table(title="Skills", show_header=True, header_style="bold")
|
|
117
|
+
table.add_column("ID", style="cyan")
|
|
118
|
+
table.add_column("Manifest")
|
|
119
|
+
table.add_column("Runbook")
|
|
120
|
+
table.add_column("Status")
|
|
121
|
+
|
|
122
|
+
for skill in skills:
|
|
123
|
+
manifest_exists = (agent_dir / skill.get("manifest", "")).exists() if skill.get("manifest") else False
|
|
124
|
+
runbook_exists = (agent_dir / skill.get("runbook", "")).exists() if skill.get("runbook") else False
|
|
125
|
+
status = "[green]OK[/]" if manifest_exists and runbook_exists else "[red]MISSING[/]"
|
|
126
|
+
table.add_row(
|
|
127
|
+
skill.get("id", "?"),
|
|
128
|
+
skill.get("manifest", "-"),
|
|
129
|
+
skill.get("runbook", "-"),
|
|
130
|
+
status,
|
|
131
|
+
)
|
|
132
|
+
console.print(table)
|
|
133
|
+
console.print()
|
|
134
|
+
|
|
135
|
+
# Registries
|
|
136
|
+
registries = manifest.get("registries", [])
|
|
137
|
+
if registries:
|
|
138
|
+
table = Table(title="Registries", show_header=True, header_style="bold")
|
|
139
|
+
table.add_column("ID", style="cyan")
|
|
140
|
+
table.add_column("Path")
|
|
141
|
+
table.add_column("Description")
|
|
142
|
+
table.add_column("Entries")
|
|
143
|
+
|
|
144
|
+
for reg in registries:
|
|
145
|
+
reg_path = agent_dir / reg["path"]
|
|
146
|
+
entry_count = "?"
|
|
147
|
+
if reg_path.exists():
|
|
148
|
+
reg_data = _load_yaml(reg_path)
|
|
149
|
+
categories = reg_data.get("categories", {})
|
|
150
|
+
count = sum(
|
|
151
|
+
len(v) if isinstance(v, dict) else 0
|
|
152
|
+
for v in categories.values()
|
|
153
|
+
)
|
|
154
|
+
entry_count = str(count)
|
|
155
|
+
|
|
156
|
+
table.add_row(
|
|
157
|
+
reg.get("id", "?"),
|
|
158
|
+
reg["path"],
|
|
159
|
+
reg.get("description", "-"),
|
|
160
|
+
entry_count,
|
|
161
|
+
)
|
|
162
|
+
console.print(table)
|
|
163
|
+
console.print()
|
|
164
|
+
|
|
165
|
+
# Workflows
|
|
166
|
+
workflows = manifest.get("workflows", [])
|
|
167
|
+
if workflows:
|
|
168
|
+
for wf_ref in workflows:
|
|
169
|
+
wf_path = agent_dir / wf_ref["path"]
|
|
170
|
+
if wf_path.exists():
|
|
171
|
+
wf_data = _load_yaml(wf_path)
|
|
172
|
+
n_states = len(wf_data.get("states", {}))
|
|
173
|
+
n_transitions = len(wf_data.get("transitions", []))
|
|
174
|
+
console.print(f"[bold]Workflow:[/] {wf_ref['id']} ({n_states} states, {n_transitions} transitions)")
|
|
175
|
+
console.print(_render_workflow_diagram(wf_data))
|
|
176
|
+
console.print()
|
|
177
|
+
|
|
178
|
+
# Commands
|
|
179
|
+
commands = manifest.get("commands", [])
|
|
180
|
+
if commands:
|
|
181
|
+
table = Table(title="Commands", show_header=True, header_style="bold")
|
|
182
|
+
table.add_column("Trigger", style="cyan")
|
|
183
|
+
table.add_column("Description")
|
|
184
|
+
for cmd in commands:
|
|
185
|
+
table.add_row(
|
|
186
|
+
cmd.get("trigger", f"/{cmd.get('id', '?')}"),
|
|
187
|
+
cmd.get("description", "-"),
|
|
188
|
+
)
|
|
189
|
+
console.print(table)
|
|
190
|
+
console.print()
|
|
191
|
+
|
|
192
|
+
# Summary
|
|
193
|
+
console.print("[bold]Summary[/]")
|
|
194
|
+
console.print(f" Skills: {len(skills)}")
|
|
195
|
+
console.print(f" Registries: {len(registries)}")
|
|
196
|
+
console.print(f" Workflows: {len(workflows)}")
|
|
197
|
+
console.print(f" Commands: {len(commands)}")
|
|
198
|
+
|
|
199
|
+
# Resources
|
|
200
|
+
resources = manifest.get("resources", {})
|
|
201
|
+
if resources:
|
|
202
|
+
console.print(f" CPU limit: {resources.get('max_cpu_percent', '-')}%")
|
|
203
|
+
console.print(f" Mem limit: {resources.get('max_memory_percent', '-')}%")
|
|
204
|
+
console.print()
|
aes/commands/install.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""aes install — Install skills from tarballs, local paths, registry, or agent.yaml deps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import yaml
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from aes.config import AGENT_DIR, MANIFEST_FILE, SKILLS_DIR, VENDOR_DIR
|
|
17
|
+
from aes.registry import (
|
|
18
|
+
download_package,
|
|
19
|
+
fetch_index,
|
|
20
|
+
parse_registry_source,
|
|
21
|
+
resolve_version,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Helpers
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _find_skill_files(directory: Path) -> Tuple[str, str, Optional[str]]:
|
|
32
|
+
"""Find skill manifest and runbook in a directory.
|
|
33
|
+
|
|
34
|
+
Returns (skill_id, manifest_filename, runbook_filename_or_None).
|
|
35
|
+
|
|
36
|
+
Handles two naming conventions:
|
|
37
|
+
- Named: ``deploy.skill.yaml`` + ``deploy.md``
|
|
38
|
+
- Generic: ``skill.yaml`` + ``runbook.md``
|
|
39
|
+
"""
|
|
40
|
+
# Look for *.skill.yaml first (named convention), then skill.yaml (generic)
|
|
41
|
+
named = [p for p in directory.iterdir() if p.name.endswith(".skill.yaml") and p.name != "skill.yaml"]
|
|
42
|
+
generic = directory / "skill.yaml"
|
|
43
|
+
|
|
44
|
+
if named:
|
|
45
|
+
manifest_path = named[0]
|
|
46
|
+
elif generic.exists():
|
|
47
|
+
manifest_path = generic
|
|
48
|
+
else:
|
|
49
|
+
raise click.ClickException(
|
|
50
|
+
f"No skill manifest (*.skill.yaml) found in {directory}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
with open(manifest_path) as f:
|
|
54
|
+
manifest_data = yaml.safe_load(f) or {}
|
|
55
|
+
|
|
56
|
+
skill_id = manifest_data.get("id")
|
|
57
|
+
if not skill_id:
|
|
58
|
+
raise click.ClickException(
|
|
59
|
+
f"Skill manifest {manifest_path.name} is missing 'id' field"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Determine runbook filename
|
|
63
|
+
runbook_name: Optional[str] = None
|
|
64
|
+
# Named convention: {id}.md
|
|
65
|
+
named_runbook = directory / f"{skill_id}.md"
|
|
66
|
+
generic_runbook = directory / "runbook.md"
|
|
67
|
+
if named_runbook.exists():
|
|
68
|
+
runbook_name = named_runbook.name
|
|
69
|
+
elif generic_runbook.exists():
|
|
70
|
+
runbook_name = generic_runbook.name
|
|
71
|
+
|
|
72
|
+
return skill_id, manifest_path.name, runbook_name
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _place_in_vendor(
|
|
76
|
+
src_dir: Path,
|
|
77
|
+
skill_id: str,
|
|
78
|
+
project_root: Path,
|
|
79
|
+
force: bool,
|
|
80
|
+
) -> Path:
|
|
81
|
+
"""Copy a skill directory into ``.agent/skills/vendor/{id}/``.
|
|
82
|
+
|
|
83
|
+
Returns the destination path. Raises if it already exists and *force*
|
|
84
|
+
is ``False``.
|
|
85
|
+
"""
|
|
86
|
+
vendor_dir = project_root / AGENT_DIR / SKILLS_DIR / VENDOR_DIR / skill_id
|
|
87
|
+
if vendor_dir.exists():
|
|
88
|
+
if not force:
|
|
89
|
+
raise click.ClickException(
|
|
90
|
+
f"Skill '{skill_id}' already installed at {vendor_dir}. "
|
|
91
|
+
"Use --force to overwrite."
|
|
92
|
+
)
|
|
93
|
+
shutil.rmtree(vendor_dir)
|
|
94
|
+
|
|
95
|
+
shutil.copytree(src_dir, vendor_dir, symlinks=False)
|
|
96
|
+
return vendor_dir
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _register_skill(
|
|
100
|
+
project_root: Path,
|
|
101
|
+
skill_id: str,
|
|
102
|
+
manifest_name: str,
|
|
103
|
+
runbook_name: Optional[str],
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Ensure ``agent.yaml`` has an entry in ``skills:`` for *skill_id*.
|
|
106
|
+
|
|
107
|
+
Paths are relative to ``.agent/``, e.g.
|
|
108
|
+
``skills/vendor/deploy/deploy.skill.yaml``.
|
|
109
|
+
"""
|
|
110
|
+
manifest_path = project_root / AGENT_DIR / MANIFEST_FILE
|
|
111
|
+
with open(manifest_path) as f:
|
|
112
|
+
data = yaml.safe_load(f) or {}
|
|
113
|
+
|
|
114
|
+
skills = data.setdefault("skills", [])
|
|
115
|
+
|
|
116
|
+
# Build relative paths (relative to .agent/)
|
|
117
|
+
rel_manifest = f"{SKILLS_DIR}/{VENDOR_DIR}/{skill_id}/{manifest_name}"
|
|
118
|
+
rel_runbook = (
|
|
119
|
+
f"{SKILLS_DIR}/{VENDOR_DIR}/{skill_id}/{runbook_name}"
|
|
120
|
+
if runbook_name
|
|
121
|
+
else None
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
entry = {"id": skill_id, "manifest": rel_manifest}
|
|
125
|
+
if rel_runbook:
|
|
126
|
+
entry["runbook"] = rel_runbook
|
|
127
|
+
|
|
128
|
+
# Replace existing entry for this id, or append
|
|
129
|
+
replaced = False
|
|
130
|
+
for i, existing in enumerate(skills):
|
|
131
|
+
if existing.get("id") == skill_id:
|
|
132
|
+
skills[i] = entry
|
|
133
|
+
replaced = True
|
|
134
|
+
break
|
|
135
|
+
if not replaced:
|
|
136
|
+
skills.append(entry)
|
|
137
|
+
|
|
138
|
+
with open(manifest_path, "w") as f:
|
|
139
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _safe_extract(tar: tarfile.TarFile, dest: Path) -> None:
|
|
143
|
+
"""Extract tarball members, rejecting path-traversal attacks.
|
|
144
|
+
|
|
145
|
+
Safe for Python 3.9 (no ``data_filter`` yet).
|
|
146
|
+
"""
|
|
147
|
+
for member in tar.getmembers():
|
|
148
|
+
member_path = os.path.normpath(member.name)
|
|
149
|
+
if member_path.startswith("..") or os.path.isabs(member_path):
|
|
150
|
+
raise click.ClickException(
|
|
151
|
+
f"Refusing to extract path-traversal entry: {member.name}"
|
|
152
|
+
)
|
|
153
|
+
# Extra safety: resolve and verify it stays under dest
|
|
154
|
+
target = (dest / member_path).resolve()
|
|
155
|
+
if not str(target).startswith(str(dest.resolve())):
|
|
156
|
+
raise click.ClickException(
|
|
157
|
+
f"Refusing to extract outside target: {member.name}"
|
|
158
|
+
)
|
|
159
|
+
tar.extractall(dest)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Source detection
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _detect_source_type(source: str) -> str:
|
|
167
|
+
"""Return one of: 'tarball', 'local', 'registry', 'git'."""
|
|
168
|
+
if source.startswith("local:"):
|
|
169
|
+
return "local"
|
|
170
|
+
if source.startswith("github:"):
|
|
171
|
+
return "git"
|
|
172
|
+
if source.startswith("aes-hub/"):
|
|
173
|
+
return "registry"
|
|
174
|
+
# Heuristic: file extension
|
|
175
|
+
if source.endswith(".tar.gz") or source.endswith(".tgz"):
|
|
176
|
+
return "tarball"
|
|
177
|
+
if Path(source).is_file() and tarfile.is_tarfile(source):
|
|
178
|
+
return "tarball"
|
|
179
|
+
if Path(source).is_dir():
|
|
180
|
+
return "local"
|
|
181
|
+
# If it looks like a path that doesn't exist, give a helpful error
|
|
182
|
+
return "unknown"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Install modes
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _install_tarball(tarball_path: Path, project_root: Path, force: bool) -> str:
|
|
190
|
+
"""Install a skill from a ``.tar.gz`` tarball. Returns the skill id."""
|
|
191
|
+
if not tarball_path.exists():
|
|
192
|
+
raise click.ClickException(f"File not found: {tarball_path}")
|
|
193
|
+
|
|
194
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
195
|
+
tmp_dir = Path(tmp)
|
|
196
|
+
with tarfile.open(tarball_path, "r:gz") as tar:
|
|
197
|
+
_safe_extract(tar, tmp_dir)
|
|
198
|
+
|
|
199
|
+
# The tarball should contain a single top-level directory
|
|
200
|
+
children = [p for p in tmp_dir.iterdir() if p.is_dir()]
|
|
201
|
+
if len(children) == 1:
|
|
202
|
+
skill_dir = children[0]
|
|
203
|
+
else:
|
|
204
|
+
skill_dir = tmp_dir
|
|
205
|
+
|
|
206
|
+
skill_id, manifest_name, runbook_name = _find_skill_files(skill_dir)
|
|
207
|
+
_place_in_vendor(skill_dir, skill_id, project_root, force)
|
|
208
|
+
_register_skill(project_root, skill_id, manifest_name, runbook_name)
|
|
209
|
+
|
|
210
|
+
return skill_id
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _install_local(source: str, project_root: Path, force: bool) -> str:
|
|
214
|
+
"""Install a skill from a local directory. Returns the skill id."""
|
|
215
|
+
# Strip ``local:`` prefix if present
|
|
216
|
+
dir_path = Path(source.removeprefix("local:")).resolve()
|
|
217
|
+
if not dir_path.is_dir():
|
|
218
|
+
raise click.ClickException(f"Directory not found: {dir_path}")
|
|
219
|
+
|
|
220
|
+
skill_id, manifest_name, runbook_name = _find_skill_files(dir_path)
|
|
221
|
+
_place_in_vendor(dir_path, skill_id, project_root, force)
|
|
222
|
+
_register_skill(project_root, skill_id, manifest_name, runbook_name)
|
|
223
|
+
return skill_id
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _install_registry(source: str, project_root: Path, force: bool) -> str:
|
|
227
|
+
"""Install a skill from the AES registry. Returns the skill id."""
|
|
228
|
+
name, version_spec = parse_registry_source(source)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
index = fetch_index()
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
raise click.ClickException(f"Failed to fetch registry index: {exc}")
|
|
234
|
+
|
|
235
|
+
packages = index.get("packages", {})
|
|
236
|
+
if name not in packages:
|
|
237
|
+
raise click.ClickException(
|
|
238
|
+
f"Package '{name}' not found in registry. "
|
|
239
|
+
"Use 'aes search' to find available packages."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
pkg = packages[name]
|
|
243
|
+
available = list(pkg.get("versions", {}).keys())
|
|
244
|
+
version = resolve_version(version_spec, available)
|
|
245
|
+
if version is None:
|
|
246
|
+
raise click.ClickException(
|
|
247
|
+
f"No version of '{name}' matches '{version_spec}'. "
|
|
248
|
+
f"Available: {', '.join(available)}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
version_info = pkg["versions"][version]
|
|
252
|
+
sha256_expected = version_info["sha256"]
|
|
253
|
+
|
|
254
|
+
console.print(f"[dim]Downloading {name}@{version}...[/]")
|
|
255
|
+
|
|
256
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
257
|
+
tmp_dir = Path(tmp)
|
|
258
|
+
try:
|
|
259
|
+
tarball = download_package(name, version, sha256_expected, tmp_dir)
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
raise click.ClickException(f"Failed to download {name}@{version}: {exc}")
|
|
262
|
+
|
|
263
|
+
return _install_tarball(tarball, project_root, force)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _install_from_deps(project_root: Path, force: bool) -> None:
|
|
267
|
+
"""Install all dependencies declared in ``agent.yaml``."""
|
|
268
|
+
manifest_path = project_root / AGENT_DIR / MANIFEST_FILE
|
|
269
|
+
if not manifest_path.exists():
|
|
270
|
+
raise click.ClickException("No agent.yaml found")
|
|
271
|
+
|
|
272
|
+
with open(manifest_path) as f:
|
|
273
|
+
manifest = yaml.safe_load(f) or {}
|
|
274
|
+
|
|
275
|
+
deps = manifest.get("dependencies", {}).get("skills", {})
|
|
276
|
+
if not deps:
|
|
277
|
+
console.print("[dim]No skill dependencies declared in agent.yaml[/]")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
installed = 0
|
|
281
|
+
skipped = 0
|
|
282
|
+
errored = 0
|
|
283
|
+
|
|
284
|
+
for name, source in deps.items():
|
|
285
|
+
source_type = _detect_source_type(source)
|
|
286
|
+
try:
|
|
287
|
+
if source_type == "local":
|
|
288
|
+
_install_local(source, project_root, force)
|
|
289
|
+
console.print(f" [green]Installed:[/] {name} ← {source}")
|
|
290
|
+
installed += 1
|
|
291
|
+
elif source_type == "tarball":
|
|
292
|
+
_install_tarball(Path(source), project_root, force)
|
|
293
|
+
console.print(f" [green]Installed:[/] {name} ← {source}")
|
|
294
|
+
installed += 1
|
|
295
|
+
elif source_type == "registry":
|
|
296
|
+
_install_registry(source, project_root, force)
|
|
297
|
+
console.print(f" [green]Installed:[/] {name} ← {source}")
|
|
298
|
+
installed += 1
|
|
299
|
+
elif source_type == "git":
|
|
300
|
+
console.print(
|
|
301
|
+
f" [yellow]Skipped:[/] {name} — git sources not yet supported"
|
|
302
|
+
)
|
|
303
|
+
skipped += 1
|
|
304
|
+
else:
|
|
305
|
+
console.print(f" [red]Error:[/] {name} — unknown source: {source}")
|
|
306
|
+
errored += 1
|
|
307
|
+
except click.ClickException as exc:
|
|
308
|
+
console.print(f" [red]Error:[/] {name} — {exc.format_message()}")
|
|
309
|
+
errored += 1
|
|
310
|
+
|
|
311
|
+
console.print()
|
|
312
|
+
console.print(
|
|
313
|
+
f"[bold]Summary:[/] {installed} installed, {skipped} skipped, {errored} errored"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# CLI command
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
@click.command("install")
|
|
322
|
+
@click.argument("source", required=False)
|
|
323
|
+
@click.option(
|
|
324
|
+
"--path",
|
|
325
|
+
default=".",
|
|
326
|
+
type=click.Path(exists=True),
|
|
327
|
+
help="Project root directory",
|
|
328
|
+
)
|
|
329
|
+
@click.option(
|
|
330
|
+
"--force",
|
|
331
|
+
is_flag=True,
|
|
332
|
+
default=False,
|
|
333
|
+
help="Overwrite existing vendor skills",
|
|
334
|
+
)
|
|
335
|
+
def install_cmd(source: Optional[str], path: str, force: bool) -> None:
|
|
336
|
+
"""Install skill dependencies.
|
|
337
|
+
|
|
338
|
+
If SOURCE is provided, install a specific skill from a tarball or local
|
|
339
|
+
directory. Without SOURCE, install all dependencies from agent.yaml.
|
|
340
|
+
|
|
341
|
+
\b
|
|
342
|
+
Examples:
|
|
343
|
+
aes install ./deploy-1.0.0.tar.gz # from tarball
|
|
344
|
+
aes install ../shared-skills/monitoring # from local dir
|
|
345
|
+
aes install local:../shared-skills/deploy # explicit local prefix
|
|
346
|
+
aes install # all deps from agent.yaml
|
|
347
|
+
"""
|
|
348
|
+
project_root = Path(path).resolve()
|
|
349
|
+
agent_dir = project_root / AGENT_DIR
|
|
350
|
+
|
|
351
|
+
if not agent_dir.exists():
|
|
352
|
+
console.print(f"[red]Error:[/] No {AGENT_DIR}/ directory found at {project_root}")
|
|
353
|
+
raise SystemExit(1)
|
|
354
|
+
|
|
355
|
+
if source is None:
|
|
356
|
+
_install_from_deps(project_root, force)
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
source_type = _detect_source_type(source)
|
|
360
|
+
|
|
361
|
+
if source_type == "tarball":
|
|
362
|
+
skill_id = _install_tarball(Path(source).resolve(), project_root, force)
|
|
363
|
+
console.print(f"[green]Installed skill:[/] {skill_id}")
|
|
364
|
+
elif source_type == "local":
|
|
365
|
+
skill_id = _install_local(source, project_root, force)
|
|
366
|
+
console.print(f"[green]Installed skill:[/] {skill_id}")
|
|
367
|
+
elif source_type == "registry":
|
|
368
|
+
skill_id = _install_registry(source, project_root, force)
|
|
369
|
+
console.print(f"[green]Installed skill:[/] {skill_id}")
|
|
370
|
+
elif source_type == "git":
|
|
371
|
+
console.print(
|
|
372
|
+
f"[yellow]Not yet supported:[/] git sources ({source})"
|
|
373
|
+
)
|
|
374
|
+
console.print("[dim]Only tarball, local, and registry install are available.[/]")
|
|
375
|
+
else:
|
|
376
|
+
raise click.ClickException(
|
|
377
|
+
f"Cannot determine source type for '{source}'. "
|
|
378
|
+
"Provide a .tar.gz file, a directory path, or use the local: prefix."
|
|
379
|
+
)
|