skillsctl 0.1.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.
- skillsctl-0.1.0/PKG-INFO +90 -0
- skillsctl-0.1.0/README.md +62 -0
- skillsctl-0.1.0/pyproject.toml +45 -0
- skillsctl-0.1.0/src/skillsctl/__init__.py +3 -0
- skillsctl-0.1.0/src/skillsctl/__main__.py +4 -0
- skillsctl-0.1.0/src/skillsctl/client.py +61 -0
- skillsctl-0.1.0/src/skillsctl/commands/__init__.py +0 -0
- skillsctl-0.1.0/src/skillsctl/commands/install.py +97 -0
- skillsctl-0.1.0/src/skillsctl/commands/list_cmd.py +40 -0
- skillsctl-0.1.0/src/skillsctl/commands/remove.py +47 -0
- skillsctl-0.1.0/src/skillsctl/commands/search.py +46 -0
- skillsctl-0.1.0/src/skillsctl/commands/sync.py +63 -0
- skillsctl-0.1.0/src/skillsctl/commands/update.py +54 -0
- skillsctl-0.1.0/src/skillsctl/lockfile.py +62 -0
- skillsctl-0.1.0/src/skillsctl/main.py +49 -0
skillsctl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skillsctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for installing skills, agents, workflows, and rules from an Enterprise Skills Catalog
|
|
5
|
+
Project-URL: Homepage, https://github.com/your-org/enterprise-skills-catalog
|
|
6
|
+
Project-URL: Repository, https://github.com/your-org/enterprise-skills-catalog
|
|
7
|
+
Project-URL: Issues, https://github.com/your-org/enterprise-skills-catalog/issues
|
|
8
|
+
Author: Enterprise Skills Catalog
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,catalog,cli,enterprise,skills,workflows
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: click>=8.1
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: pyyaml>=6.0
|
|
26
|
+
Requires-Dist: rich>=13.0
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# skillsctl
|
|
30
|
+
|
|
31
|
+
CLI tool for installing skills, agents, workflows, and rules from an Enterprise Skills Catalog.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install skillsctl
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Search the catalog
|
|
43
|
+
skillsctl search slack
|
|
44
|
+
|
|
45
|
+
# Install a skill
|
|
46
|
+
skillsctl install send-slack-notification
|
|
47
|
+
|
|
48
|
+
# Install multiple items with dependencies
|
|
49
|
+
skillsctl install send-slack-notification slack-ops-agent --with-deps
|
|
50
|
+
|
|
51
|
+
# List installed items
|
|
52
|
+
skillsctl list
|
|
53
|
+
|
|
54
|
+
# Update an item
|
|
55
|
+
skillsctl update send-slack-notification
|
|
56
|
+
|
|
57
|
+
# Sync all installed items to latest versions
|
|
58
|
+
skillsctl sync
|
|
59
|
+
|
|
60
|
+
# Remove an item
|
|
61
|
+
skillsctl remove send-slack-notification
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
By default, `skillsctl` connects to `http://localhost:8000`. Override with:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# CLI flag
|
|
70
|
+
skillsctl --source https://catalog.company.com install my-skill
|
|
71
|
+
|
|
72
|
+
# Environment variable
|
|
73
|
+
export SKILLSCTL_SOURCE=https://catalog.company.com
|
|
74
|
+
|
|
75
|
+
# Or set in skills.yaml
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Lockfile
|
|
79
|
+
|
|
80
|
+
`skillsctl` maintains a `skills.yaml` file in your project root:
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
source: https://catalog.company.com
|
|
84
|
+
installed:
|
|
85
|
+
send-slack-notification: "2.1.0"
|
|
86
|
+
http-request: "1.3.0"
|
|
87
|
+
slack-ops-agent: "1.0.0"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Installed files are saved to `.skills/{category}/{name}.md`.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# skillsctl
|
|
2
|
+
|
|
3
|
+
CLI tool for installing skills, agents, workflows, and rules from an Enterprise Skills Catalog.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install skillsctl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Search the catalog
|
|
15
|
+
skillsctl search slack
|
|
16
|
+
|
|
17
|
+
# Install a skill
|
|
18
|
+
skillsctl install send-slack-notification
|
|
19
|
+
|
|
20
|
+
# Install multiple items with dependencies
|
|
21
|
+
skillsctl install send-slack-notification slack-ops-agent --with-deps
|
|
22
|
+
|
|
23
|
+
# List installed items
|
|
24
|
+
skillsctl list
|
|
25
|
+
|
|
26
|
+
# Update an item
|
|
27
|
+
skillsctl update send-slack-notification
|
|
28
|
+
|
|
29
|
+
# Sync all installed items to latest versions
|
|
30
|
+
skillsctl sync
|
|
31
|
+
|
|
32
|
+
# Remove an item
|
|
33
|
+
skillsctl remove send-slack-notification
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
By default, `skillsctl` connects to `http://localhost:8000`. Override with:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# CLI flag
|
|
42
|
+
skillsctl --source https://catalog.company.com install my-skill
|
|
43
|
+
|
|
44
|
+
# Environment variable
|
|
45
|
+
export SKILLSCTL_SOURCE=https://catalog.company.com
|
|
46
|
+
|
|
47
|
+
# Or set in skills.yaml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Lockfile
|
|
51
|
+
|
|
52
|
+
`skillsctl` maintains a `skills.yaml` file in your project root:
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
source: https://catalog.company.com
|
|
56
|
+
installed:
|
|
57
|
+
send-slack-notification: "2.1.0"
|
|
58
|
+
http-request: "1.3.0"
|
|
59
|
+
slack-ops-agent: "1.0.0"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Installed files are saved to `.skills/{category}/{name}.md`.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "skillsctl"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI tool for installing skills, agents, workflows, and rules from an Enterprise Skills Catalog"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Enterprise Skills Catalog"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["skills", "catalog", "cli", "enterprise", "agents", "workflows"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Topic :: System :: Systems Administration",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"click>=8.1",
|
|
31
|
+
"httpx>=0.27",
|
|
32
|
+
"pyyaml>=6.0",
|
|
33
|
+
"rich>=13.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
skillsctl = "skillsctl.main:cli"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/your-org/enterprise-skills-catalog"
|
|
41
|
+
Repository = "https://github.com/your-org/enterprise-skills-catalog"
|
|
42
|
+
Issues = "https://github.com/your-org/enterprise-skills-catalog/issues"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/skillsctl"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""HTTP client for the Skills Catalog API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CatalogClient:
|
|
9
|
+
def __init__(self, base_url: str) -> None:
|
|
10
|
+
self.base_url = base_url.rstrip("/")
|
|
11
|
+
self._http = httpx.Client(base_url=self.base_url, timeout=30.0)
|
|
12
|
+
|
|
13
|
+
def _get(self, path: str, **params: str) -> httpx.Response:
|
|
14
|
+
try:
|
|
15
|
+
resp = self._http.get(path, params=params)
|
|
16
|
+
except httpx.ConnectError:
|
|
17
|
+
raise click.ClickException(
|
|
18
|
+
f"Cannot connect to catalog at {self.base_url}. "
|
|
19
|
+
"Is the server running?"
|
|
20
|
+
)
|
|
21
|
+
if resp.status_code == 404:
|
|
22
|
+
return resp # let caller handle
|
|
23
|
+
if resp.status_code >= 400:
|
|
24
|
+
raise click.ClickException(
|
|
25
|
+
f"API error {resp.status_code}: {resp.text[:200]}"
|
|
26
|
+
)
|
|
27
|
+
return resp
|
|
28
|
+
|
|
29
|
+
def get_item(self, name: str) -> dict | None:
|
|
30
|
+
resp = self._get(f"/api/v1/items/{name}")
|
|
31
|
+
if resp.status_code == 404:
|
|
32
|
+
return None
|
|
33
|
+
return resp.json()
|
|
34
|
+
|
|
35
|
+
def get_raw(self, name: str) -> str | None:
|
|
36
|
+
resp = self._get(f"/api/v1/items/{name}/raw")
|
|
37
|
+
if resp.status_code == 404:
|
|
38
|
+
return None
|
|
39
|
+
return resp.text
|
|
40
|
+
|
|
41
|
+
def get_bundle(self, names: list[str]) -> dict:
|
|
42
|
+
resp = self._get("/api/v1/items/bundle", items=",".join(names))
|
|
43
|
+
try:
|
|
44
|
+
data = resp.json()
|
|
45
|
+
except Exception:
|
|
46
|
+
raise click.ClickException(
|
|
47
|
+
f"Unexpected response from {self.base_url}/api/v1/items/bundle — "
|
|
48
|
+
"is the catalog server up-to-date?"
|
|
49
|
+
)
|
|
50
|
+
return data
|
|
51
|
+
|
|
52
|
+
def search(self, query: str, **kwargs: str) -> dict:
|
|
53
|
+
resp = self._get("/api/v1/items/search", q=query, **kwargs)
|
|
54
|
+
return resp.json()
|
|
55
|
+
|
|
56
|
+
def list_items(self, **kwargs: str) -> dict:
|
|
57
|
+
resp = self._get("/api/v1/items", **kwargs)
|
|
58
|
+
return resp.json()
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
self._http.close()
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""skillsctl install — download and save items from the catalog."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from ..client import CatalogClient
|
|
11
|
+
from ..lockfile import Lockfile
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
SKILLS_DIR = ".skills"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_deps(
|
|
18
|
+
client: CatalogClient,
|
|
19
|
+
names: list[str],
|
|
20
|
+
resolved: dict[str, dict],
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Recursively resolve requires dependencies."""
|
|
23
|
+
for name in names:
|
|
24
|
+
if name in resolved:
|
|
25
|
+
continue
|
|
26
|
+
item = client.get_item(name)
|
|
27
|
+
if item is None:
|
|
28
|
+
console.print(f"[yellow]Warning:[/] '{name}' not found in catalog, skipping")
|
|
29
|
+
continue
|
|
30
|
+
resolved[name] = item
|
|
31
|
+
requires = item.get("requires", [])
|
|
32
|
+
if requires:
|
|
33
|
+
_resolve_deps(client, requires, resolved)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@click.command("install")
|
|
37
|
+
@click.argument("names", nargs=-1, required=True)
|
|
38
|
+
@click.option("--with-deps/--no-deps", default=True, help="Install required dependencies")
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def install(ctx: click.Context, names: tuple[str, ...], with_deps: bool) -> None:
|
|
41
|
+
"""Install skills, agents, rules, or workflows from the catalog."""
|
|
42
|
+
client: CatalogClient = ctx.obj["client"]
|
|
43
|
+
lockfile: Lockfile = ctx.obj["lockfile"]
|
|
44
|
+
project_root = lockfile.path.parent
|
|
45
|
+
|
|
46
|
+
# Resolve all items (and deps if requested)
|
|
47
|
+
resolved: dict[str, dict] = {}
|
|
48
|
+
if with_deps:
|
|
49
|
+
_resolve_deps(client, list(names), resolved)
|
|
50
|
+
else:
|
|
51
|
+
for name in names:
|
|
52
|
+
item = client.get_item(name)
|
|
53
|
+
if item is None:
|
|
54
|
+
console.print(f"[yellow]Warning:[/] '{name}' not found in catalog, skipping")
|
|
55
|
+
continue
|
|
56
|
+
resolved[name] = item
|
|
57
|
+
|
|
58
|
+
if not resolved:
|
|
59
|
+
raise click.ClickException("No items to install.")
|
|
60
|
+
|
|
61
|
+
# Download raw files via bundle endpoint for efficiency
|
|
62
|
+
bundle = client.get_bundle(list(resolved.keys()))
|
|
63
|
+
content_map = {bi["name"]: bi for bi in bundle["items"]}
|
|
64
|
+
|
|
65
|
+
if bundle["errors"]:
|
|
66
|
+
for err in bundle["errors"]:
|
|
67
|
+
console.print(f"[yellow]Warning:[/] could not download '{err}'")
|
|
68
|
+
|
|
69
|
+
# Write files and update lockfile
|
|
70
|
+
installed: list[tuple[str, str, str, Path]] = [] # (name, version, category, path)
|
|
71
|
+
|
|
72
|
+
for name, item_meta in resolved.items():
|
|
73
|
+
if name not in content_map:
|
|
74
|
+
continue
|
|
75
|
+
bi = content_map[name]
|
|
76
|
+
category = bi["category"]
|
|
77
|
+
target_dir = project_root / SKILLS_DIR / category
|
|
78
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
target_file = target_dir / f"{name}.md"
|
|
80
|
+
target_file.write_text(bi["content"], encoding="utf-8")
|
|
81
|
+
lockfile.add(name, bi["version"])
|
|
82
|
+
installed.append((name, bi["version"], category, target_file))
|
|
83
|
+
|
|
84
|
+
lockfile.save()
|
|
85
|
+
|
|
86
|
+
# Summary table
|
|
87
|
+
table = Table(title="Installed", show_lines=False)
|
|
88
|
+
table.add_column("Name", style="cyan")
|
|
89
|
+
table.add_column("Version", style="green")
|
|
90
|
+
table.add_column("Category", style="magenta")
|
|
91
|
+
table.add_column("Path", style="dim")
|
|
92
|
+
for name, version, category, path in installed:
|
|
93
|
+
is_dep = name not in names
|
|
94
|
+
label = f"{name} [dim](dep)[/]" if is_dep else name
|
|
95
|
+
table.add_row(label, version, category, str(path.relative_to(project_root)))
|
|
96
|
+
console.print(table)
|
|
97
|
+
console.print(f"[green]{len(installed)} item(s) installed.[/]")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""skillsctl list — show installed items."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from ..lockfile import Lockfile
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
SKILLS_DIR = ".skills"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command("list")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def list_cmd(ctx: click.Context) -> None:
|
|
19
|
+
"""List all installed items from skills.yaml."""
|
|
20
|
+
lockfile: Lockfile = ctx.obj["lockfile"]
|
|
21
|
+
project_root = lockfile.path.parent
|
|
22
|
+
skills_dir = project_root / SKILLS_DIR
|
|
23
|
+
|
|
24
|
+
if not lockfile.installed:
|
|
25
|
+
console.print("[dim]No items installed. Run 'skillsctl install <name>' to get started.[/]")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
table = Table(title="Installed Items", show_lines=False)
|
|
29
|
+
table.add_column("Name", style="cyan")
|
|
30
|
+
table.add_column("Version", style="green")
|
|
31
|
+
table.add_column("Path", style="dim")
|
|
32
|
+
|
|
33
|
+
for name, version in sorted(lockfile.installed.items()):
|
|
34
|
+
# Find the file to show path
|
|
35
|
+
matches = list(skills_dir.rglob(f"{name}.md")) if skills_dir.exists() else []
|
|
36
|
+
path_str = str(matches[0].relative_to(project_root)) if matches else "[red]missing[/]"
|
|
37
|
+
table.add_row(name, version, path_str)
|
|
38
|
+
|
|
39
|
+
console.print(table)
|
|
40
|
+
console.print(f"[dim]Source: {lockfile.source}[/]")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""skillsctl remove — remove installed items."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from ..lockfile import Lockfile
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
SKILLS_DIR = ".skills"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("remove")
|
|
16
|
+
@click.argument("names", nargs=-1, required=True)
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def remove(ctx: click.Context, names: tuple[str, ...]) -> None:
|
|
19
|
+
"""Remove installed skills, agents, rules, or workflows."""
|
|
20
|
+
lockfile: Lockfile = ctx.obj["lockfile"]
|
|
21
|
+
project_root = lockfile.path.parent
|
|
22
|
+
skills_dir = project_root / SKILLS_DIR
|
|
23
|
+
|
|
24
|
+
removed = 0
|
|
25
|
+
for name in names:
|
|
26
|
+
if not lockfile.has(name):
|
|
27
|
+
console.print(f"[yellow]'{name}' is not installed, skipping[/]")
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
# Find and delete the file (scan subdirs since category is in path)
|
|
31
|
+
found = False
|
|
32
|
+
for md in skills_dir.rglob(f"{name}.md"):
|
|
33
|
+
md.unlink()
|
|
34
|
+
found = True
|
|
35
|
+
# Remove empty parent dirs
|
|
36
|
+
parent = md.parent
|
|
37
|
+
if parent != skills_dir and not any(parent.iterdir()):
|
|
38
|
+
parent.rmdir()
|
|
39
|
+
|
|
40
|
+
lockfile.remove(name)
|
|
41
|
+
removed += 1
|
|
42
|
+
status = "removed" if found else "removed from lockfile (file not found)"
|
|
43
|
+
console.print(f" [red]- {name}[/] ({status})")
|
|
44
|
+
|
|
45
|
+
lockfile.save()
|
|
46
|
+
if removed:
|
|
47
|
+
console.print(f"[green]{removed} item(s) removed.[/]")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""skillsctl search — search the remote catalog."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from ..client import CatalogClient
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command("search")
|
|
14
|
+
@click.argument("query")
|
|
15
|
+
@click.option("--category", "-c", default=None, help="Filter by category")
|
|
16
|
+
@click.option("--tag", "-t", default=None, help="Filter by tag")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def search(ctx: click.Context, query: str, category: str | None, tag: str | None) -> None:
|
|
19
|
+
"""Search the catalog for skills, agents, rules, or workflows."""
|
|
20
|
+
client: CatalogClient = ctx.obj["client"]
|
|
21
|
+
|
|
22
|
+
kwargs: dict = {}
|
|
23
|
+
if category:
|
|
24
|
+
kwargs["category"] = category
|
|
25
|
+
if tag:
|
|
26
|
+
kwargs["tags"] = tag
|
|
27
|
+
|
|
28
|
+
result = client.search(query, **kwargs)
|
|
29
|
+
|
|
30
|
+
if not result["results"]:
|
|
31
|
+
console.print(f"[dim]No results for '{query}'[/]")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
table = Table(title=f"Search: '{query}'", show_lines=False)
|
|
35
|
+
table.add_column("Name", style="cyan")
|
|
36
|
+
table.add_column("Version", style="green")
|
|
37
|
+
table.add_column("Category", style="magenta")
|
|
38
|
+
table.add_column("Description", max_width=50)
|
|
39
|
+
|
|
40
|
+
for r in result["results"]:
|
|
41
|
+
item = r["item"]
|
|
42
|
+
desc = item["description"][:80] + ("..." if len(item["description"]) > 80 else "")
|
|
43
|
+
table.add_row(item["name"], item["version"], item["category"], desc)
|
|
44
|
+
|
|
45
|
+
console.print(table)
|
|
46
|
+
console.print(f"[dim]{result['total']} result(s) found[/]")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""skillsctl sync — sync installed items to their catalog versions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from ..client import CatalogClient
|
|
10
|
+
from ..lockfile import Lockfile
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
SKILLS_DIR = ".skills"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command("sync")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def sync(ctx: click.Context) -> None:
|
|
19
|
+
"""Re-download all installed items from the catalog (update to latest)."""
|
|
20
|
+
client: CatalogClient = ctx.obj["client"]
|
|
21
|
+
lockfile: Lockfile = ctx.obj["lockfile"]
|
|
22
|
+
project_root = lockfile.path.parent
|
|
23
|
+
|
|
24
|
+
if not lockfile.installed:
|
|
25
|
+
console.print("[dim]Nothing to sync — no items in skills.yaml[/]")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
updated = 0
|
|
29
|
+
unchanged = 0
|
|
30
|
+
errors = 0
|
|
31
|
+
|
|
32
|
+
for name, local_version in list(lockfile.installed.items()):
|
|
33
|
+
item = client.get_item(name)
|
|
34
|
+
if item is None:
|
|
35
|
+
console.print(f" [yellow]! {name}[/] — not found in catalog")
|
|
36
|
+
errors += 1
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
remote_version = item["version"]
|
|
40
|
+
raw = client.get_raw(name)
|
|
41
|
+
if raw is None:
|
|
42
|
+
console.print(f" [yellow]! {name}[/] — could not download")
|
|
43
|
+
errors += 1
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
category = item["category"]
|
|
47
|
+
target_dir = project_root / SKILLS_DIR / category
|
|
48
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
target_file = target_dir / f"{name}.md"
|
|
50
|
+
target_file.write_text(raw, encoding="utf-8")
|
|
51
|
+
lockfile.add(name, remote_version)
|
|
52
|
+
|
|
53
|
+
if local_version != remote_version:
|
|
54
|
+
console.print(f" [green]+ {name}[/] {local_version} → {remote_version}")
|
|
55
|
+
updated += 1
|
|
56
|
+
else:
|
|
57
|
+
unchanged += 1
|
|
58
|
+
|
|
59
|
+
lockfile.save()
|
|
60
|
+
console.print(
|
|
61
|
+
f"\n[green]{updated} updated[/], {unchanged} unchanged, "
|
|
62
|
+
f"{'[yellow]' + str(errors) + ' errors[/]' if errors else '0 errors'}"
|
|
63
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""skillsctl update — update a specific item to the latest version."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from ..client import CatalogClient
|
|
10
|
+
from ..lockfile import Lockfile
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
SKILLS_DIR = ".skills"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.command("update")
|
|
17
|
+
@click.argument("names", nargs=-1, required=True)
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def update(ctx: click.Context, names: tuple[str, ...]) -> None:
|
|
20
|
+
"""Update specific installed items to their latest catalog version."""
|
|
21
|
+
client: CatalogClient = ctx.obj["client"]
|
|
22
|
+
lockfile: Lockfile = ctx.obj["lockfile"]
|
|
23
|
+
project_root = lockfile.path.parent
|
|
24
|
+
|
|
25
|
+
for name in names:
|
|
26
|
+
if not lockfile.has(name):
|
|
27
|
+
console.print(f"[yellow]'{name}' is not installed — use 'skillsctl install {name}' first[/]")
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
local_version = lockfile.installed[name]
|
|
31
|
+
item = client.get_item(name)
|
|
32
|
+
if item is None:
|
|
33
|
+
console.print(f"[yellow]'{name}' not found in catalog[/]")
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
remote_version = item["version"]
|
|
37
|
+
if local_version == remote_version:
|
|
38
|
+
console.print(f" [dim]{name} already at {local_version} (latest)[/]")
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
raw = client.get_raw(name)
|
|
42
|
+
if raw is None:
|
|
43
|
+
console.print(f"[yellow]'{name}' — could not download[/]")
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
category = item["category"]
|
|
47
|
+
target_dir = project_root / SKILLS_DIR / category
|
|
48
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
target_file = target_dir / f"{name}.md"
|
|
50
|
+
target_file.write_text(raw, encoding="utf-8")
|
|
51
|
+
lockfile.add(name, remote_version)
|
|
52
|
+
console.print(f" [green]+ {name}[/] {local_version} → {remote_version}")
|
|
53
|
+
|
|
54
|
+
lockfile.save()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Manages the skills.yaml lockfile."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_SOURCE = "http://localhost:8000"
|
|
10
|
+
LOCKFILE_NAME = "skills.yaml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Lockfile:
|
|
14
|
+
def __init__(self, path: Path, source: str, installed: dict[str, str]) -> None:
|
|
15
|
+
self.path = path
|
|
16
|
+
self.source = source
|
|
17
|
+
self.installed = installed # name -> version
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def find(cls, start: Path | None = None) -> "Lockfile":
|
|
21
|
+
"""Walk up from start (default: cwd) looking for skills.yaml."""
|
|
22
|
+
cwd = start or Path.cwd()
|
|
23
|
+
current = cwd
|
|
24
|
+
while True:
|
|
25
|
+
candidate = current / LOCKFILE_NAME
|
|
26
|
+
if candidate.exists():
|
|
27
|
+
return cls.load(candidate)
|
|
28
|
+
parent = current.parent
|
|
29
|
+
if parent == current:
|
|
30
|
+
break
|
|
31
|
+
current = parent
|
|
32
|
+
# Not found — return a default at cwd
|
|
33
|
+
return cls(path=cwd / LOCKFILE_NAME, source=DEFAULT_SOURCE, installed={})
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def load(cls, path: Path) -> "Lockfile":
|
|
37
|
+
text = path.read_text(encoding="utf-8")
|
|
38
|
+
data = yaml.safe_load(text) or {}
|
|
39
|
+
return cls(
|
|
40
|
+
path=path,
|
|
41
|
+
source=data.get("source", DEFAULT_SOURCE),
|
|
42
|
+
installed=data.get("installed", {}),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def save(self) -> None:
|
|
46
|
+
data = {
|
|
47
|
+
"source": self.source,
|
|
48
|
+
"installed": dict(sorted(self.installed.items())),
|
|
49
|
+
}
|
|
50
|
+
self.path.write_text(
|
|
51
|
+
yaml.dump(data, default_flow_style=False, sort_keys=False),
|
|
52
|
+
encoding="utf-8",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def add(self, name: str, version: str) -> None:
|
|
56
|
+
self.installed[name] = version
|
|
57
|
+
|
|
58
|
+
def remove(self, name: str) -> bool:
|
|
59
|
+
return self.installed.pop(name, None) is not None
|
|
60
|
+
|
|
61
|
+
def has(self, name: str) -> bool:
|
|
62
|
+
return name in self.installed
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Main CLI group for skillsctl."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .client import CatalogClient
|
|
10
|
+
from .lockfile import Lockfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(version=__version__, prog_name="skillsctl")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--source",
|
|
17
|
+
envvar="SKILLSCTL_SOURCE",
|
|
18
|
+
default=None,
|
|
19
|
+
help="Catalog server URL (default: from skills.yaml or http://localhost:8000)",
|
|
20
|
+
)
|
|
21
|
+
@click.pass_context
|
|
22
|
+
def cli(ctx: click.Context, source: str | None) -> None:
|
|
23
|
+
"""Enterprise Skills Catalog CLI — install, manage, and search skills."""
|
|
24
|
+
ctx.ensure_object(dict)
|
|
25
|
+
|
|
26
|
+
lockfile = Lockfile.find()
|
|
27
|
+
|
|
28
|
+
# Source resolution: --source flag > skills.yaml > env var > default
|
|
29
|
+
resolved_source = source or lockfile.source
|
|
30
|
+
lockfile.source = resolved_source
|
|
31
|
+
|
|
32
|
+
ctx.obj["lockfile"] = lockfile
|
|
33
|
+
ctx.obj["client"] = CatalogClient(resolved_source)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Register commands
|
|
37
|
+
from .commands.install import install
|
|
38
|
+
from .commands.remove import remove
|
|
39
|
+
from .commands.list_cmd import list_cmd
|
|
40
|
+
from .commands.search import search
|
|
41
|
+
from .commands.sync import sync
|
|
42
|
+
from .commands.update import update
|
|
43
|
+
|
|
44
|
+
cli.add_command(install)
|
|
45
|
+
cli.add_command(remove)
|
|
46
|
+
cli.add_command(list_cmd)
|
|
47
|
+
cli.add_command(search)
|
|
48
|
+
cli.add_command(sync)
|
|
49
|
+
cli.add_command(update)
|