From 75c8b309851ce7bcc23724b213be1b9a28d563ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sat, 25 Apr 2026 20:05:00 +0200 Subject: [PATCH 01/13] feat: Add SPLENT API client --- .env.example | 3 ++- src/splent_cli/services/api_client.py | 38 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/splent_cli/services/api_client.py diff --git a/.env.example b/.env.example index fa9af61..a06e984 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ WORKING_DIR=/workspace GITHUB_USER= GITHUB_TOKEN= TWINE_USERNAME=__token__ -TWINE_PASSWORD= \ No newline at end of file +TWINE_PASSWORD= +SPLENT_API_URL=http://127.0.0.1:5000 diff --git a/src/splent_cli/services/api_client.py b/src/splent_cli/services/api_client.py new file mode 100644 index 0000000..030c384 --- /dev/null +++ b/src/splent_cli/services/api_client.py @@ -0,0 +1,38 @@ +import os +from urllib.parse import quote + +import requests + + +class SplentAPIError(RuntimeError): + pass + + +def _base_url() -> str: + return os.getenv("SPLENT_API_URL", "http://127.0.0.1:5000").rstrip("/") + + +def get(path: str): + if not path.startswith("/"): + raise ValueError("API path must start with '/'") + + try: + response = requests.get(f"{_base_url()}{path}", timeout=10) + response.raise_for_status() + return response.json() + + except requests.exceptions.JSONDecodeError as exc: + raise SplentAPIError("The SPLENT API returned an invalid JSON response.") from exc + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else "unknown" + raise SplentAPIError(f"SPLENT API returned HTTP {status}.") from exc + except requests.exceptions.RequestException as exc: + raise SplentAPIError(f"Could not connect to the SPLENT API: {exc}") from exc + + +def get_packages(): + return get("/api/packages") + + +def get_package_by_name(name: str): + return get(f"/api/packages/{quote(name, safe='')}") From 71c08e1ad46b34715d4a8613e8abb7704e183310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sat, 25 Apr 2026 21:14:28 +0200 Subject: [PATCH 02/13] feat: Use SPLENT API for feature search --- .../commands/feature/feature_search.py | 190 ++++++++++-------- 1 file changed, 106 insertions(+), 84 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 2a35182..104eee0 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,126 +1,148 @@ -import urllib.request -import urllib.error -import json -import os import click +from splent_cli.services.api_client import SplentAPIError, get_packages -def _github_request(url: str, token: str | None) -> dict | list | None: - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "splent-cli", - "X-GitHub-Api-Version": "2022-11-28", - } - if token: - headers["Authorization"] = f"token {token}" - req = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(req, timeout=10) as resp: - return json.loads(resp.read().decode()) - except urllib.error.HTTPError as e: - if e.code == 404: - return None - raise - except urllib.error.URLError as e: - click.secho(f"❌ Network error: {e.reason}", fg="red") - raise SystemExit(1) + +def _contract_description(package: dict) -> str: + contract = package.get("contract") or {} + return contract.get("description") or "" + + +def _contract_items(package: dict, key: str) -> list[str]: + contract = package.get("contract") or {} + values = contract.get(key) or {} + + if isinstance(values, dict): + items = [] + for name, value in sorted(values.items()): + if isinstance(value, list): + if value: + items.append(f"{name}: {', '.join(str(item) for item in value)}") + elif value: + items.append(f"{name}: {value}") + return items + if isinstance(values, list): + return sorted(str(name) for name in values) + if isinstance(values, str): + return [values] + return [] + + +def _package_matches(package: dict, query: str) -> bool: + haystack = [ + package.get("name") or "", + package.get("full_name") or "", + _contract_description(package), + " ".join(_contract_items(package, "provides")), + " ".join(_contract_items(package, "requires")), + ] + return query.lower() in " ".join(haystack).lower() -def _latest_tag(org: str, repo: str, token: str | None) -> str | None: - data = _github_request( - f"https://api.github.com/repos/{org}/{repo}/releases/latest", token - ) - if data and data.get("tag_name"): - return data["tag_name"] - # fall back to tags - tags = _github_request(f"https://api.github.com/repos/{org}/{repo}/tags", token) - if tags and isinstance(tags, list) and tags: - return tags[0].get("name") - return None +def _format_items(items: list[str]) -> str: + if not items: + return "-" + return ", ".join(items) -@click.command("feature:search", short_help="Search for available features on GitHub.") +def _repo_url(package: dict) -> str | None: + return package.get("html_url") or None + + +def _updated_at(package: dict) -> str: + value = package.get("updated_at") or "" + if "T" in value: + return value.split("T", 1)[0] + return value or "-" + + +def _load_packages() -> list[dict]: + data = get_packages() + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + raise SplentAPIError("The SPLENT API returned an unexpected packages payload.") + + +@click.command("feature:search", short_help="Search for available SPLENT packages.") @click.argument("query", required=False) @click.option( "--org", default="splent-io", show_default=True, - help="GitHub organisation to search in.", + help="Deprecated. The API decides which GitHub organisation to read.", ) @click.option( "--all", "show_all", is_flag=True, - help="Show all repos, not just splent_feature_* ones.", + help="Show all API packages, not just splent_feature_* packages.", ) def feature_search(query, org, show_all): """ - List available features from a GitHub organisation. + List available packages from the SPLENT API. \b - By default searches the splent-io org and filters by repos that match - the splent_feature_* naming convention. + By default reads SPLENT_API_URL/api/packages and filters by packages that + match the splent_feature_* naming convention. Optionally filter by QUERY (partial name match). Examples: splent feature:search splent feature:search auth - splent feature:search --org my-org + SPLENT_API_URL=http://127.0.0.1:5000 splent feature:search """ - token = os.getenv("GITHUB_TOKEN") - - click.echo(click.style(f"\nπŸ” Searching features in {org}...\n", fg="cyan")) - - # Paginate through all repos - repos = [] - page = 1 - while True: - url = f"https://api.github.com/orgs/{org}/repos?per_page=100&page={page}&type=public" - batch = _github_request(url, token) - if not batch: - break - repos.extend(batch) - if len(batch) < 100: - break - page += 1 - - if repos is None: - click.secho(f"❌ Organisation '{org}' not found or not accessible.", fg="red") + if org != "splent-io": + click.secho( + "⚠️ --org is ignored when searching via the SPLENT API.", + fg="yellow", + ) + + click.echo(click.style("\nπŸ” Searching packages in SPLENT API...\n", fg="cyan")) + + try: + packages = _load_packages() + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Start splent-api or set SPLENT_API_URL to the API base URL.") raise SystemExit(1) - # Filter if not show_all: - repos = [r for r in repos if "feature" in r.get("name", "").lower()] + packages = [ + p for p in packages if (p.get("name") or "").startswith("splent_feature_") + ] + if query: - repos = [r for r in repos if query.lower() in r.get("name", "").lower()] + packages = [p for p in packages if _package_matches(p, query)] - if not repos: - msg = f"No features found in {org}" + if not packages: + msg = "No packages found" if query: msg += f" matching '{query}'" click.secho(f"ℹ️ {msg}.", fg="yellow") return - click.secho(f"Found {len(repos)} feature(s) in {org}:\n", fg="cyan") - - col = max(len(r["name"]) for r in repos) + 2 - for repo in sorted(repos, key=lambda r: r["name"]): - name = repo["name"] - desc = repo.get("description") or "" - latest = _latest_tag(org, name, token) - version_label = ( - click.style(latest, fg="green") - if latest - else click.style("no releases", fg="yellow") - ) - click.echo(f" {name:<{col}} {version_label:<20} {desc}") + click.secho(f"Found {len(packages)} package(s):\n", fg="cyan") - click.echo() - if not token: - click.secho( - "πŸ’‘ Set GITHUB_TOKEN to avoid rate limits and access private repos.", - fg="yellow", - ) + col = max(len(p.get("name") or "") for p in packages) + 2 + + for package in sorted(packages, key=lambda p: p.get("name") or ""): + name = package.get("name") or "-" + desc = _contract_description(package) + updated = _updated_at(package) + provides = _format_items(_contract_items(package, "provides")) + requires = _format_items(_contract_items(package, "requires")) + + click.echo(f" {name:<{col}} updated {updated} {desc}") + click.echo(f" {'':<{col}} provides: {provides}") + click.echo(f" {'':<{col}} requires: {requires}") + + url = _repo_url(package) + if url: + click.echo(f" {'':<{col}} {url}") + + click.echo() + + click.echo("Use SPLENT_API_URL to point this command at another splent-api server.") cli_command = feature_search From a8be877fac42eb1aaf6ee0f6e43825e7d8f96cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 03:24:35 +0200 Subject: [PATCH 03/13] feat: Improve feature search with package API --- .../commands/feature/feature_search.py | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 104eee0..1df1c44 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,3 +1,6 @@ +import shutil +import textwrap + import click from splent_cli.services.api_client import SplentAPIError, get_packages @@ -39,10 +42,41 @@ def _package_matches(package: dict, query: str) -> bool: return query.lower() in " ".join(haystack).lower() -def _format_items(items: list[str]) -> str: +def _terminal_width() -> int: + return min(shutil.get_terminal_size((100, 20)).columns, 120) + + +def _echo_wrapped(label: str, value: str, width: int) -> None: + prefix = f" {label:<12}" + wrapped = textwrap.wrap( + value or "-", + width=max(width - len(prefix), 30), + subsequent_indent=" " * len(prefix), + ) + if not wrapped: + click.echo(prefix + "-") + return + + click.echo(prefix + wrapped[0]) + for line in wrapped[1:]: + click.echo(line) + + +def _echo_contract_section(label: str, items: list[str], width: int) -> None: + click.echo(f" {label}") if not items: - return "-" - return ", ".join(items) + click.echo(" -") + return + + for item in items: + wrapped = textwrap.wrap( + item, + width=max(width - 6, 30), + initial_indent=" - ", + subsequent_indent=" ", + ) + for line in wrapped: + click.echo(line) def _repo_url(package: dict) -> str | None: @@ -60,50 +94,50 @@ def _load_packages() -> list[dict]: data = get_packages() if isinstance(data, list): return [item for item in data if isinstance(item, dict)] - raise SplentAPIError("The SPLENT API returned an unexpected packages payload.") + raise SplentAPIError("Unexpected package response.") -@click.command("feature:search", short_help="Search for available SPLENT packages.") +@click.command("feature:search", short_help="Search for available features.") @click.argument("query", required=False) @click.option( "--org", default="splent-io", show_default=True, - help="Deprecated. The API decides which GitHub organisation to read.", + help="GitHub organisation to search in.", ) @click.option( "--all", "show_all", is_flag=True, - help="Show all API packages, not just splent_feature_* packages.", + help="Show all packages, not just splent_feature_* ones.", ) def feature_search(query, org, show_all): """ - List available packages from the SPLENT API. + List available features. \b - By default reads SPLENT_API_URL/api/packages and filters by packages that - match the splent_feature_* naming convention. + By default filters packages that match the splent_feature_* naming + convention. Optionally filter by QUERY (partial name match). Examples: splent feature:search splent feature:search auth - SPLENT_API_URL=http://127.0.0.1:5000 splent feature:search + splent feature:search --all """ if org != "splent-io": click.secho( - "⚠️ --org is ignored when searching via the SPLENT API.", + " --org is ignored by the configured package index.", fg="yellow", ) - click.echo(click.style("\nπŸ” Searching packages in SPLENT API...\n", fg="cyan")) + click.echo(click.style("\n Searching features...\n", fg="cyan")) try: packages = _load_packages() except SplentAPIError as exc: click.secho(f"❌ {exc}", fg="red") - click.echo(" Start splent-api or set SPLENT_API_URL to the API base URL.") + click.echo(" Check SPLENT_API_URL or start the package index.") raise SystemExit(1) if not show_all: @@ -121,28 +155,28 @@ def feature_search(query, org, show_all): click.secho(f"ℹ️ {msg}.", fg="yellow") return - click.secho(f"Found {len(packages)} package(s):\n", fg="cyan") + click.secho(f" Found {len(packages)} package(s):\n", fg="cyan") - col = max(len(p.get("name") or "") for p in packages) + 2 + width = _terminal_width() for package in sorted(packages, key=lambda p: p.get("name") or ""): name = package.get("name") or "-" desc = _contract_description(package) updated = _updated_at(package) - provides = _format_items(_contract_items(package, "provides")) - requires = _format_items(_contract_items(package, "requires")) + provides = _contract_items(package, "provides") + requires = _contract_items(package, "requires") - click.echo(f" {name:<{col}} updated {updated} {desc}") - click.echo(f" {'':<{col}} provides: {provides}") - click.echo(f" {'':<{col}} requires: {requires}") + click.secho(f" {name}", bold=True) + _echo_wrapped("updated", updated, width) + _echo_wrapped("summary", desc, width) + _echo_contract_section("provides", provides, width) + _echo_contract_section("requires", requires, width) url = _repo_url(package) if url: - click.echo(f" {'':<{col}} {url}") + _echo_wrapped("repo", url, width) click.echo() - click.echo("Use SPLENT_API_URL to point this command at another splent-api server.") - cli_command = feature_search From aed524c535303575b9db88e016eed2149abde4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 04:04:20 +0200 Subject: [PATCH 04/13] refactor: refactoring feature search --- .../commands/feature/feature_search.py | 29 +++---------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 1df1c44..c362d3f 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -31,17 +31,6 @@ def _contract_items(package: dict, key: str) -> list[str]: return [] -def _package_matches(package: dict, query: str) -> bool: - haystack = [ - package.get("name") or "", - package.get("full_name") or "", - _contract_description(package), - " ".join(_contract_items(package, "provides")), - " ".join(_contract_items(package, "requires")), - ] - return query.lower() in " ".join(haystack).lower() - - def _terminal_width() -> int: return min(shutil.get_terminal_size((100, 20)).columns, 120) @@ -99,19 +88,13 @@ def _load_packages() -> list[dict]: @click.command("feature:search", short_help="Search for available features.") @click.argument("query", required=False) -@click.option( - "--org", - default="splent-io", - show_default=True, - help="GitHub organisation to search in.", -) @click.option( "--all", "show_all", is_flag=True, help="Show all packages, not just splent_feature_* ones.", ) -def feature_search(query, org, show_all): +def feature_search(query, show_all): """ List available features. @@ -125,12 +108,6 @@ def feature_search(query, org, show_all): splent feature:search auth splent feature:search --all """ - if org != "splent-io": - click.secho( - " --org is ignored by the configured package index.", - fg="yellow", - ) - click.echo(click.style("\n Searching features...\n", fg="cyan")) try: @@ -146,7 +123,9 @@ def feature_search(query, org, show_all): ] if query: - packages = [p for p in packages if _package_matches(p, query)] + packages = [ + p for p in packages if query.lower() in (p.get("name") or "").lower() + ] if not packages: msg = "No packages found" From 6e140e1960e84a6ff7679ec6b31e5e3ebf88c440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 05:03:12 +0200 Subject: [PATCH 05/13] feat: Use splent API in feature install --- .env.example | 2 +- .../commands/feature/feature_install.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index a06e984..915f6fe 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,4 @@ GITHUB_USER= GITHUB_TOKEN= TWINE_USERNAME=__token__ TWINE_PASSWORD= -SPLENT_API_URL=http://127.0.0.1:5000 +SPLENT_API_URL=http://host.docker.internal:5000 diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index c7e1343..f57589f 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -5,6 +5,7 @@ import click import requests from splent_cli.services import context, compose +from splent_cli.services.api_client import SplentAPIError, get_package_by_name from splent_cli.utils.feature_utils import read_features_from_data @@ -169,6 +170,32 @@ def feature_install(feature_identifier, env_scope, mode, version): namespace, namespace_github, namespace_fs, feature_name = ( compose.parse_feature_identifier(feature_identifier) ) + + api_feature_name = ( + feature_name + if feature_name.startswith("splent_feature_") + else f"splent_feature_{feature_name}" + ) + try: + package = get_package_by_name(api_feature_name) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + package_name = package.get("name") or api_feature_name + full_name = package.get("full_name") + if isinstance(full_name, str) and "/" in full_name: + feature_identifier = full_name + else: + feature_identifier = f"{namespace_github}/{package_name}" + namespace, namespace_github, namespace_fs, feature_name = ( + compose.parse_feature_identifier(feature_identifier) + ) short = feature_name.replace("splent_feature_", "") # ── Ask mode if not specified ───────────────────────────────────── From 4a7a456cc54a399ba23c1b96f0404b161554e53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 18:32:56 +0200 Subject: [PATCH 06/13] feat: Resolve feature versions through package API --- .../commands/feature/feature_versions.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/splent_cli/commands/feature/feature_versions.py b/src/splent_cli/commands/feature/feature_versions.py index 3d936b5..abd62af 100644 --- a/src/splent_cli/commands/feature/feature_versions.py +++ b/src/splent_cli/commands/feature/feature_versions.py @@ -6,6 +6,7 @@ import click from splent_cli.services import compose +from splent_cli.services.api_client import SplentAPIError, get_package_by_name from splent_cli.utils.feature_utils import load_product_features @@ -81,6 +82,36 @@ def _latest_upload(v): return [] +def _feature_api_name(feature_name: str) -> str: + if feature_name.startswith("splent_feature_"): + return feature_name + return f"splent_feature_{feature_name}" + + +def _resolve_feature_from_api(namespace_github: str, feature_name: str) -> tuple[str, str]: + api_name = _feature_api_name(feature_name) + + try: + package = get_package_by_name(api_name) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) from exc + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + full_name = package.get("full_name") + if isinstance(full_name, str) and "/" in full_name: + _, namespace_github, _, feature_name = compose.parse_feature_identifier( + full_name + ) + return namespace_github, feature_name + + return namespace_github, package.get("name") or api_name + + # ── Version helpers ─────────────────────────────────────────────────────────── @@ -330,6 +361,9 @@ def feature_versions( feature_identifier ) feature_name = feature_name.split("@")[0] + namespace_github, feature_name = _resolve_feature_from_api( + namespace_github, feature_name + ) show_github = not only_pypi show_pypi = not only_github From 817635f79939beabb9fe5c514ad42d705b6cca77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 23:02:31 +0200 Subject: [PATCH 07/13] feat: Use marketplace API for feature clone --- .../commands/feature/feature_clone.py | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_clone.py b/src/splent_cli/commands/feature/feature_clone.py index 7e0ee60..b08fa5a 100644 --- a/src/splent_cli/commands/feature/feature_clone.py +++ b/src/splent_cli/commands/feature/feature_clone.py @@ -4,6 +4,7 @@ import requests import click from splent_cli.services import context +from splent_cli.services.api_client import SplentAPIError, get_package_by_name from splent_cli.utils.cache_utils import make_feature_readonly from splent_cli.utils.feature_utils import normalize_namespace @@ -69,6 +70,41 @@ def _parse_full_name(full_name: str): return namespace, repo, version +def _feature_api_name(repo: str) -> str: + if repo.startswith("splent_feature_"): + return repo + return f"splent_feature_{repo}" + + +def _resolve_full_name_from_api(full_name: str) -> str: + raw = full_name + version = None + if "@" in raw: + raw, version = raw.split("@", 1) + + repo = raw.split("/")[-1] + api_name = _feature_api_name(repo) + + try: + package = get_package_by_name(api_name) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) from exc + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + resolved = package.get("full_name") + if not isinstance(resolved, str) or "/" not in resolved: + resolved = f"splent-io/{package.get('name') or api_name}" + + if version: + return f"{resolved}@{version}" + return resolved + + def _validate_identifier_part(value: str, label: str): if not re.fullmatch(r"[a-zA-Z0-9_\-\.]+", value): raise SystemExit( @@ -84,17 +120,18 @@ def _validate_identifier_part(value: str, label: str): @click.command( "feature:clone", short_help="Clone a feature into the local cache.", - help="Clone a feature into the local cache namespace.", + help="Clone a marketplace feature into the local cache.", ) @click.argument("full_name", required=True) def feature_clone(full_name): """ - Clone / into: + Clone , or / into: .splent_cache/features//@ - If version is omitted, fetches the latest tag or 'main'. """ + full_name = _resolve_full_name_from_api(full_name) namespace, repo, version = _parse_full_name(full_name) _validate_identifier_part(namespace, "namespace") From b6b08f5f639662d27f8f4221214b124b135d7c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 23:38:50 +0200 Subject: [PATCH 08/13] feat: Add feature:info --- .../commands/feature/feature_clone.py | 1 + .../commands/feature/feature_info.py | 134 ++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/splent_cli/commands/feature/feature_info.py diff --git a/src/splent_cli/commands/feature/feature_clone.py b/src/splent_cli/commands/feature/feature_clone.py index b08fa5a..2c84261 100644 --- a/src/splent_cli/commands/feature/feature_clone.py +++ b/src/splent_cli/commands/feature/feature_clone.py @@ -81,6 +81,7 @@ def _resolve_full_name_from_api(full_name: str) -> str: version = None if "@" in raw: raw, version = raw.split("@", 1) + repo = raw.split("/")[-1] api_name = _feature_api_name(repo) diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py new file mode 100644 index 0000000..7a2bdfd --- /dev/null +++ b/src/splent_cli/commands/feature/feature_info.py @@ -0,0 +1,134 @@ +import shutil +import textwrap + +import click + +from splent_cli.services.api_client import SplentAPIError, get_package_by_name + + +def _contract_description(package: dict) -> str: + contract = package.get("contract") or {} + return contract.get("description") or "" + + +def _contract_items(package: dict, key: str) -> list[str]: + contract = package.get("contract") or {} + values = contract.get(key) or {} + + if isinstance(values, dict): + items = [] + for name, value in sorted(values.items()): + if isinstance(value, list): + if value: + items.append(f"{name}: {', '.join(str(item) for item in value)}") + elif value: + items.append(f"{name}: {value}") + return items + if isinstance(values, list): + return sorted(str(name) for name in values) + if isinstance(values, str): + return [values] + return [] + + +def _terminal_width() -> int: + return min(shutil.get_terminal_size((100, 20)).columns, 120) + + +def _echo_wrapped(label: str, value: str, width: int) -> None: + prefix = f" {label:<12}" + wrapped = textwrap.wrap( + value or "-", + width=max(width - len(prefix), 30), + subsequent_indent=" " * len(prefix), + ) + if not wrapped: + click.echo(prefix + "-") + return + + click.echo(prefix + wrapped[0]) + for line in wrapped[1:]: + click.echo(line) + + +def _echo_contract_section(label: str, items: list[str], width: int) -> None: + click.echo(f" {label}") + if not items: + click.echo(" -") + return + + for item in items: + wrapped = textwrap.wrap( + item, + width=max(width - 6, 30), + initial_indent=" - ", + subsequent_indent=" ", + ) + for line in wrapped: + click.echo(line) + + +def _repo_url(package: dict) -> str | None: + return package.get("html_url") or None + + +def _updated_at(package: dict) -> str: + value = package.get("updated_at") or "" + if "T" in value: + return value.split("T", 1)[0] + return value or "-" + + +def _feature_api_name(feature_name: str) -> str: + if feature_name.startswith("splent_feature_"): + return feature_name + return f"splent_feature_{feature_name}" + + +@click.command("feature:info", short_help="Show marketplace feature details.") +@click.argument("feature_name", required=True) +def feature_info(feature_name): + """ + Show marketplace information for one feature. + + \b + Examples: + splent feature:info auth + splent feature:info splent_feature_auth + """ + api_name = _feature_api_name(feature_name) + click.echo(click.style(f"\n Loading feature {api_name}...\n", fg="cyan")) + + try: + package = get_package_by_name(api_name) + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + click.echo(" Check SPLENT_API_URL or start the package index.") + raise SystemExit(1) + + if not isinstance(package, dict): + click.secho("❌ Invalid package response from API.", fg="red") + raise SystemExit(1) + + width = _terminal_width() + name = package.get("name") or api_name + desc = _contract_description(package) + updated = _updated_at(package) + provides = _contract_items(package, "provides") + requires = _contract_items(package, "requires") + + click.secho(f" {name}", bold=True) + _echo_wrapped("full name", package.get("full_name") or "-", width) + _echo_wrapped("updated", updated, width) + _echo_wrapped("summary", desc, width) + _echo_contract_section("provides", provides, width) + _echo_contract_section("requires", requires, width) + + url = _repo_url(package) + if url: + _echo_wrapped("repo", url, width) + + click.echo() + + +cli_command = feature_info From d2bfb1d1bcf12dd9fea3098b9c2e719afd2bed7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Sun, 26 Apr 2026 23:55:09 +0200 Subject: [PATCH 09/13] refactor: refactor feature search --- .../commands/feature/feature_search.py | 82 +------------------ 1 file changed, 4 insertions(+), 78 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index c362d3f..9710878 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -1,6 +1,3 @@ -import shutil -import textwrap - import click from splent_cli.services.api_client import SplentAPIError, get_packages @@ -11,67 +8,6 @@ def _contract_description(package: dict) -> str: return contract.get("description") or "" -def _contract_items(package: dict, key: str) -> list[str]: - contract = package.get("contract") or {} - values = contract.get(key) or {} - - if isinstance(values, dict): - items = [] - for name, value in sorted(values.items()): - if isinstance(value, list): - if value: - items.append(f"{name}: {', '.join(str(item) for item in value)}") - elif value: - items.append(f"{name}: {value}") - return items - if isinstance(values, list): - return sorted(str(name) for name in values) - if isinstance(values, str): - return [values] - return [] - - -def _terminal_width() -> int: - return min(shutil.get_terminal_size((100, 20)).columns, 120) - - -def _echo_wrapped(label: str, value: str, width: int) -> None: - prefix = f" {label:<12}" - wrapped = textwrap.wrap( - value or "-", - width=max(width - len(prefix), 30), - subsequent_indent=" " * len(prefix), - ) - if not wrapped: - click.echo(prefix + "-") - return - - click.echo(prefix + wrapped[0]) - for line in wrapped[1:]: - click.echo(line) - - -def _echo_contract_section(label: str, items: list[str], width: int) -> None: - click.echo(f" {label}") - if not items: - click.echo(" -") - return - - for item in items: - wrapped = textwrap.wrap( - item, - width=max(width - 6, 30), - initial_indent=" - ", - subsequent_indent=" ", - ) - for line in wrapped: - click.echo(line) - - -def _repo_url(package: dict) -> str | None: - return package.get("html_url") or None - - def _updated_at(package: dict) -> str: value = package.get("updated_at") or "" if "T" in value: @@ -136,26 +72,16 @@ def feature_search(query, show_all): click.secho(f" Found {len(packages)} package(s):\n", fg="cyan") - width = _terminal_width() + name_col = max(len(p.get("name") or "-") for p in packages) + 2 + date_col = 12 for package in sorted(packages, key=lambda p: p.get("name") or ""): name = package.get("name") or "-" desc = _contract_description(package) updated = _updated_at(package) - provides = _contract_items(package, "provides") - requires = _contract_items(package, "requires") - - click.secho(f" {name}", bold=True) - _echo_wrapped("updated", updated, width) - _echo_wrapped("summary", desc, width) - _echo_contract_section("provides", provides, width) - _echo_contract_section("requires", requires, width) - - url = _repo_url(package) - if url: - _echo_wrapped("repo", url, width) + click.echo(f" {name:<{name_col}} {updated:<{date_col}} {desc}") - click.echo() + click.echo() cli_command = feature_search From 891759526b2f33ba67f9a44c21b17e10d469450a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 28 Apr 2026 20:50:44 +0200 Subject: [PATCH 10/13] feat: Implement `feature:publish` command to register features in the marketplace Co-authored-by: Copilot --- .../commands/feature/feature_publish.py | 254 ++++++++++++++++++ src/splent_cli/services/api_client.py | 29 +- 2 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/splent_cli/commands/feature/feature_publish.py diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py new file mode 100644 index 0000000..92bb9c2 --- /dev/null +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -0,0 +1,254 @@ +import os +import re +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +import click +import tomllib + +from splent_cli.commands.feature.feature_contract import _resolve_feature, infer_contract +from splent_cli.services import context +from splent_cli.services.api_client import SplentAPIError, post +from splent_cli.utils.feature_utils import normalize_namespace + +DEFAULT_OWNER = "splent-io" + + +def _read_pyproject(feature_path: Path) -> dict: + pyproject_path = feature_path / "pyproject.toml" + if not pyproject_path.exists(): + return {} + + with open(pyproject_path, "rb") as f: + return tomllib.load(f) + + +def _git_remote_url(feature_path: Path) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(feature_path), "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + timeout=10, + ) + url = result.stdout.strip() + return url or None + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + +def _parse_feature_ref(feature_ref: str) -> tuple[str, str, str | None]: + # Separa owner, nombre y versiΓ³n; sin owner usa splent-io. + if "/" in feature_ref: + owner, rest = feature_ref.split("/", 1) + else: + owner = DEFAULT_OWNER + rest = feature_ref + + name, _, version = rest.partition("@") + return owner.replace("_", "-"), name, version or None + + +def _safe_remote_url(remote_url: str | None) -> str | None: + if not remote_url: + return None + + # Evita publicar tokens si el remote HTTPS los incluye. + return re.sub(r"https://[^/@]+@", "https://", remote_url) + + +def _repo_from_remote(remote_url: str | None) -> tuple[str | None, str | None]: + if not remote_url: + return None, None + + # Extrae owner/repo desde remotes SSH o HTTPS de GitHub. + patterns = [ + r"^git@github\.com:(?P[^/]+)/(?P.+?)(?:\.git)?$", + r"^https://(?:[^/@]+@)?github\.com/(?P[^/]+)/(?P.+?)(?:\.git)?$", + ] + for pattern in patterns: + match = re.match(pattern, remote_url) + if match: + return match.group("owner"), match.group("repo") + + parsed = urlparse(remote_url) + if parsed.netloc.endswith("github.com"): + parts = parsed.path.strip("/").split("/", 1) + if len(parts) == 2: + return parts[0], parts[1].removesuffix(".git") + + return None, None + + +def _canonical_feature_ref(owner: str, name: str, version: str | None) -> str: + ref = f"{owner}/{name}" + if version: + return f"{ref}@{version}" + return ref + + +def _contract_for_marketplace(contract: dict, pyproject: dict, name: str) -> dict: + # Convierte el contrato inferido al formato que consume el marketplace/API. + current_contract = ( + pyproject.get("tool", {}).get("splent", {}).get("contract", {}) + if isinstance(pyproject, dict) + else {} + ) + project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} + + provides = { + "routes": contract.get("routes", []), + "blueprints": contract.get("blueprints", []), + "models": contract.get("models", []), + "commands": contract.get("commands", []), + "hooks": contract.get("hooks", []), + "services": contract.get("services", []), + "signals": contract.get("signals", []), + "translations": contract.get("translations", []), + "docker": contract.get("docker", []), + } + requires = { + "features": contract.get("requires_features", []), + "env_vars": contract.get("env_vars", []), + "signals": contract.get("requires_signals", []), + } + + return { + "description": ( + current_contract.get("description") + or project.get("description") + or f"{name} feature" + ), + "provides": provides, + "requires": requires, + "extensible": { + "services": contract.get("extensible_services", []), + "templates": contract.get("extensible_templates", []), + "models": contract.get("extensible_models", []), + "hooks": contract.get("extensible_hooks", []), + "routes": contract.get("extensible_routes", False), + }, + "docker": contract.get("docker_contract", {}), + } + + +def _build_payload( + feature_path: Path, + namespace: str, + name: str, + feature_ref: str, + owner: str | None = None, +) -> dict: + ref_owner, _, ref_version = _parse_feature_ref(feature_ref) + github_owner = (owner or ref_owner).replace("_", "-") + + # Construye un payload compatible con la API actual y con metadatos extendidos. + inferred_contract = infer_contract(str(feature_path), namespace, name) + pyproject = _read_pyproject(feature_path) + marketplace_contract = _contract_for_marketplace(inferred_contract, pyproject, name) + + project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {} + remote_url = _git_remote_url(feature_path) + repo_owner, repo_name = _repo_from_remote(remote_url) + repo_owner = repo_owner or github_owner + repo_name = repo_name or name + repo_url = _safe_remote_url(remote_url) or f"https://github.com/{repo_owner}/{repo_name}.git" + canonical_ref = _canonical_feature_ref(github_owner, name, ref_version) + + return { + "feature_ref": canonical_ref, + "full_name": canonical_ref, + "name": name, + "description": marketplace_contract["description"], + "provides": marketplace_contract["provides"], + "requires": marketplace_contract["requires"], + "namespace": namespace, + "owner": github_owner, + "repo_url": repo_url, + "repository": f"{repo_owner}/{repo_name}", + "github": { + "owner": github_owner, + "repo": repo_name, + "repository": f"{repo_owner}/{repo_name}", + "url": repo_url, + }, + "contract": marketplace_contract, + "metadata": { + "project_name": project.get("name"), + "version": project.get("version"), + "feature_version": ref_version, + "description": project.get("description"), + "workspace_path": str(feature_path), + "pyproject_present": bool(pyproject), + "source": "splent-cli", + }, + } + + +def _login_to_marketplace(token: str | None) -> None: + # Login simple por consola: guarda el token solo para esta ejecuciΓ³n. + if token: + os.environ["SPLENT_API_TOKEN"] = token + return + + if os.getenv("SPLENT_API_TOKEN"): + return + + click.echo("Marketplace login") + token = click.prompt( + " API token (leave empty for local/dev API)", + default="", + hide_input=True, + show_default=False, + ).strip() + if token: + os.environ["SPLENT_API_TOKEN"] = token + + +@click.command("feature:publish", short_help="Publish feature metadata to the marketplace.") +@click.argument("feature_ref", required=True) +@click.option( + "--token", + default=None, + help="Marketplace API token. If omitted, SPLENT_API_TOKEN is used.", +) +@click.option( + "--owner", + default=None, + help="GitHub user or organization that owns the feature repository.", +) +@context.requires_product +def feature_publish(feature_ref, token, owner): + workspace = str(context.workspace()) + + try: + _login_to_marketplace(token) + feature_path, namespace, name = _resolve_feature(feature_ref, workspace) + namespace = normalize_namespace(namespace) + payload = _build_payload(feature_path, namespace, name, feature_ref, owner) + + click.echo() + click.secho(f"Publishing {payload['feature_ref']}...", bold=True) + click.echo(f" repository: {payload['repository']}") + click.echo(f" owner: {payload['owner']}") + + response = post("/api/packages", json=payload) + + click.secho("Feature published successfully.", fg="green") + if isinstance(response, dict) and response: + click.echo(click.style("Response:", bold=True)) + click.echo(str(response)) + + except SplentAPIError as exc: + click.secho(f"❌ {exc}", fg="red") + raise SystemExit(1) + except SystemExit: + raise + except Exception as exc: + click.secho(f"❌ Unexpected error: {exc}", fg="red") + raise SystemExit(1) + + +cli_command = feature_publish diff --git a/src/splent_cli/services/api_client.py b/src/splent_cli/services/api_client.py index 030c384..50e36b8 100644 --- a/src/splent_cli/services/api_client.py +++ b/src/splent_cli/services/api_client.py @@ -12,13 +12,30 @@ def _base_url() -> str: return os.getenv("SPLENT_API_URL", "http://127.0.0.1:5000").rstrip("/") -def get(path: str): +def _headers() -> dict[str, str]: + token = os.getenv("SPLENT_API_TOKEN") + if not token: + return {} + return {"Authorization": f"Bearer {token}"} + + +def _request(method: str, path: str, json_body: dict | None = None): if not path.startswith("/"): raise ValueError("API path must start with '/'") try: - response = requests.get(f"{_base_url()}{path}", timeout=10) + response = requests.request( + method, + f"{_base_url()}{path}", + timeout=10, + headers=_headers(), + json=json_body, + ) response.raise_for_status() + + if not response.content: + return {} + return response.json() except requests.exceptions.JSONDecodeError as exc: @@ -30,6 +47,14 @@ def get(path: str): raise SplentAPIError(f"Could not connect to the SPLENT API: {exc}") from exc +def get(path: str): + return _request("GET", path) + + +def post(path: str, json: dict | None = None): + return _request("POST", path, json_body=json) + + def get_packages(): return get("/api/packages") From 762f234a339821dc781926c5d64d7c702a252590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Fri, 1 May 2026 11:07:45 +0200 Subject: [PATCH 11/13] refactor: Allow publishing features without selected product --- src/splent_cli/commands/feature/feature_publish.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index 92bb9c2..c81ebc4 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -219,7 +219,6 @@ def _login_to_marketplace(token: str | None) -> None: default=None, help="GitHub user or organization that owns the feature repository.", ) -@context.requires_product def feature_publish(feature_ref, token, owner): workspace = str(context.workspace()) From a4938a3f11bdebaaf1682b5e008224f7ca088d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Fri, 1 May 2026 17:30:34 +0200 Subject: [PATCH 12/13] refactor: change feature_ref by full_name --- .../commands/feature/feature_info.py | 5 ++-- .../commands/feature/feature_install.py | 11 +++++-- .../commands/feature/feature_publish.py | 29 +++++++++---------- .../commands/feature/feature_search.py | 3 +- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/splent_cli/commands/feature/feature_info.py b/src/splent_cli/commands/feature/feature_info.py index 7a2bdfd..05b2a09 100644 --- a/src/splent_cli/commands/feature/feature_info.py +++ b/src/splent_cli/commands/feature/feature_info.py @@ -69,11 +69,12 @@ def _echo_contract_section(label: str, items: list[str], width: int) -> None: def _repo_url(package: dict) -> str | None: - return package.get("html_url") or None + return package.get("repo_url") or None def _updated_at(package: dict) -> str: - value = package.get("updated_at") or "" + metadata = package.get("metadata") or {} + value = metadata.get("updated_at") or package.get("updated_at") or "" if "T" in value: return value.split("T", 1)[0] return value or "-" diff --git a/src/splent_cli/commands/feature/feature_install.py b/src/splent_cli/commands/feature/feature_install.py index f57589f..a0d824b 100644 --- a/src/splent_cli/commands/feature/feature_install.py +++ b/src/splent_cli/commands/feature/feature_install.py @@ -190,9 +190,16 @@ def feature_install(feature_identifier, env_scope, mode, version): package_name = package.get("name") or api_feature_name full_name = package.get("full_name") if isinstance(full_name, str) and "/" in full_name: - feature_identifier = full_name + feature_identifier, _, package_version = full_name.partition("@") + if package_version and not version: + version = package_version else: - feature_identifier = f"{namespace_github}/{package_name}" + repository = package.get("repository") + if isinstance(repository, str) and "/" in repository: + feature_identifier = repository + else: + owner = package.get("owner") or namespace_github + feature_identifier = f"{owner}/{package_name}" namespace, namespace_github, namespace_fs, feature_name = ( compose.parse_feature_identifier(feature_identifier) ) diff --git a/src/splent_cli/commands/feature/feature_publish.py b/src/splent_cli/commands/feature/feature_publish.py index c81ebc4..122ec96 100644 --- a/src/splent_cli/commands/feature/feature_publish.py +++ b/src/splent_cli/commands/feature/feature_publish.py @@ -39,13 +39,13 @@ def _git_remote_url(feature_path: Path) -> str | None: return None -def _parse_feature_ref(feature_ref: str) -> tuple[str, str, str | None]: +def _parse_full_name(full_name: str) -> tuple[str, str, str | None]: # Separa owner, nombre y versiΓ³n; sin owner usa splent-io. - if "/" in feature_ref: - owner, rest = feature_ref.split("/", 1) + if "/" in full_name: + owner, rest = full_name.split("/", 1) else: owner = DEFAULT_OWNER - rest = feature_ref + rest = full_name name, _, version = rest.partition("@") return owner.replace("_", "-"), name, version or None @@ -82,7 +82,7 @@ def _repo_from_remote(remote_url: str | None) -> tuple[str | None, str | None]: return None, None -def _canonical_feature_ref(owner: str, name: str, version: str | None) -> str: +def _canonical_full_name(owner: str, name: str, version: str | None) -> str: ref = f"{owner}/{name}" if version: return f"{ref}@{version}" @@ -138,10 +138,10 @@ def _build_payload( feature_path: Path, namespace: str, name: str, - feature_ref: str, + full_name: str, owner: str | None = None, ) -> dict: - ref_owner, _, ref_version = _parse_feature_ref(feature_ref) + ref_owner, _, ref_version = _parse_full_name(full_name) github_owner = (owner or ref_owner).replace("_", "-") # Construye un payload compatible con la API actual y con metadatos extendidos. @@ -155,11 +155,10 @@ def _build_payload( repo_owner = repo_owner or github_owner repo_name = repo_name or name repo_url = _safe_remote_url(remote_url) or f"https://github.com/{repo_owner}/{repo_name}.git" - canonical_ref = _canonical_feature_ref(github_owner, name, ref_version) + canonical_name = _canonical_full_name(github_owner, name, ref_version) return { - "feature_ref": canonical_ref, - "full_name": canonical_ref, + "full_name": canonical_name, "name": name, "description": marketplace_contract["description"], "provides": marketplace_contract["provides"], @@ -208,7 +207,7 @@ def _login_to_marketplace(token: str | None) -> None: @click.command("feature:publish", short_help="Publish feature metadata to the marketplace.") -@click.argument("feature_ref", required=True) +@click.argument("full_name", required=True) @click.option( "--token", default=None, @@ -219,17 +218,17 @@ def _login_to_marketplace(token: str | None) -> None: default=None, help="GitHub user or organization that owns the feature repository.", ) -def feature_publish(feature_ref, token, owner): +def feature_publish(full_name, token, owner): workspace = str(context.workspace()) try: _login_to_marketplace(token) - feature_path, namespace, name = _resolve_feature(feature_ref, workspace) + feature_path, namespace, name = _resolve_feature(full_name, workspace) namespace = normalize_namespace(namespace) - payload = _build_payload(feature_path, namespace, name, feature_ref, owner) + payload = _build_payload(feature_path, namespace, name, full_name, owner) click.echo() - click.secho(f"Publishing {payload['feature_ref']}...", bold=True) + click.secho(f"Publishing {payload['full_name']}...", bold=True) click.echo(f" repository: {payload['repository']}") click.echo(f" owner: {payload['owner']}") diff --git a/src/splent_cli/commands/feature/feature_search.py b/src/splent_cli/commands/feature/feature_search.py index 9710878..5419e2d 100644 --- a/src/splent_cli/commands/feature/feature_search.py +++ b/src/splent_cli/commands/feature/feature_search.py @@ -9,7 +9,8 @@ def _contract_description(package: dict) -> str: def _updated_at(package: dict) -> str: - value = package.get("updated_at") or "" + metadata = package.get("metadata") or {} + value = metadata.get("updated_at") or package.get("updated_at") or "" if "T" in value: return value.split("T", 1)[0] return value or "-" From b7a8ec5e277f262c5fd98f138b3edcca7c1ef161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Villalba?= Date: Tue, 12 May 2026 17:06:11 +0200 Subject: [PATCH 13/13] feat: gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ca3fecf..7eb010c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv +.env splent_cli.egg-info venv dist