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.
@@ -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,3 @@
1
+ """skillsctl — CLI for the Enterprise Skills Catalog."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Allow running as `python -m skillsctl`."""
2
+ from .main import cli
3
+
4
+ cli()
@@ -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)