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,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()
@@ -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
+ )