Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead.
@@ -38,7 +38,7 @@ Open:
- Create the first admin username/password.
- Then sign in with username/password and start creating projects/configs/secrets.
-For full onboarding screens and flow, see the [First-Time Usage Guide](https://github.com/bearlike/simple-secrets-manager/wiki/First%E2%80%90Time-Usage).
+For full onboarding and bootstrap flow, see [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md).
### Standard usage flow
@@ -48,6 +48,39 @@ For full onboarding screens and flow, see the [First-Time Usage Guide](https://g
4. Export secrets in JSON or `.env` format when needed.
5. Create scoped tokens for services and CI/CD.
+### CLI (Doppler-like workflow)
+
+After `uv sync`, the CLI entrypoint is available at `.venv/bin/ssm`.
+
+Configure base URL and set a token:
+
+```bash
+.venv/bin/ssm configure --base-url http://localhost:8080/api --profile dev
+.venv/bin/ssm auth set-token --token "" --profile dev
+```
+
+Set directory defaults:
+
+```bash
+.venv/bin/ssm setup --project my-project --config dev --profile dev
+```
+
+Run commands with injected secrets:
+
+```bash
+.venv/bin/ssm run --profile dev -- python app.py
+```
+
+Other useful commands:
+
+```bash
+.venv/bin/ssm whoami --profile dev
+.venv/bin/ssm secrets download --profile dev --format json
+.venv/bin/ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
+```
+
+Command reference and detailed CLI behavior are documented in [`docs/CLI.md`](docs/CLI.md).
+
## Contributing
### Prerequisites
@@ -110,6 +143,8 @@ npm run build
### Developer docs
- Full development guide: [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md)
+- First-time setup guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
+- CLI reference: [`docs/CLI.md`](docs/CLI.md)
- Frontend notes: [`frontend/README.md`](frontend/README.md)
## Supplementary Reference
diff --git a/docs/CLI.md b/docs/CLI.md
new file mode 100644
index 0000000..e6160a3
--- /dev/null
+++ b/docs/CLI.md
@@ -0,0 +1,175 @@
+# CLI Reference
+
+Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Rich.
+
+## Install and verify
+
+From repository root:
+
+```bash
+uv sync
+uv run ssm --help
+```
+
+If you prefer direct binary execution after sync:
+
+```bash
+.venv/bin/ssm --help
+```
+
+## Resolution order (DRY/KISS)
+
+The CLI resolves values using one deterministic order.
+
+### Base URL
+
+1. `--base-url`
+2. `SSM_BASE_URL`
+3. active profile in global config
+4. global `base_url`
+
+### Project and config
+
+1. `--project` / `--config`
+2. `SSM_PROJECT` / `SSM_CONFIG`
+3. local directory config (`.ssm/config.json`)
+4. active profile defaults in global config
+
+### Profile
+
+1. `--profile`
+2. `SSM_PROFILE`
+3. local directory profile (`.ssm/config.json`)
+4. global active profile
+5. `default`
+
+### Token
+
+1. `SSM_TOKEN`
+2. stored token for `@` in keyring
+3. stored token in file fallback
+
+## Config and credential files
+
+- Global config: `~/.config/ssm/config.json`
+- Local config: `/.ssm/config.json`
+- Credential fallback: `~/.config/ssm/credentials.json` (mode `0600`)
+- Cache: `~/.cache/ssm/secrets/.json`
+
+Test overrides are supported via env vars:
+
+- `SSM_GLOBAL_CONFIG_FILE`
+- `SSM_LOCAL_CONFIG_FILE`
+- `SSM_CREDENTIALS_FILE`
+- `SSM_CACHE_DIR`
+
+## Authentication
+
+### Login (username/password)
+
+Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token.
+
+```bash
+uv run ssm configure --base-url http://localhost:8080/api --profile dev
+uv run ssm login --profile dev
+```
+
+### Service/personal token (recommended for CI or automation)
+
+```bash
+uv run ssm auth set-token --profile dev --token ""
+```
+
+### Logout
+
+```bash
+uv run ssm logout --profile dev
+```
+
+Clear all local token records:
+
+```bash
+uv run ssm logout --all-profiles
+```
+
+## Project/config setup
+
+Set local defaults for current directory:
+
+```bash
+uv run ssm setup --project my-project --config dev --profile dev
+```
+
+## Core commands
+
+### Run command with injected secrets
+
+```bash
+uv run ssm run --profile dev -- python app.py
+```
+
+Useful flags:
+
+- `--offline`: cache-only
+- `--cache-ttl `: cache freshness window
+- `--print-env`: print resolved keys
+- `--show-values`: print values (only with `--print-env`)
+
+### Download secrets
+
+```bash
+uv run ssm secrets download --profile dev --format json
+uv run ssm secrets download --profile dev --format env
+```
+
+Note: `.env` output fails when any value contains a newline. Use JSON in that case.
+
+### Mount secrets to FIFO
+
+```bash
+uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
+```
+
+- Creates FIFO with `0600` permissions.
+- Writes one payload then removes FIFO unless `--keep` is used.
+
+### Session validation
+
+```bash
+uv run ssm whoami --profile dev
+```
+
+`whoami` validates token by calling `GET /projects` and reports visible scope behavior.
+
+## Profiles
+
+List profiles:
+
+```bash
+uv run ssm profile list
+```
+
+Activate profile:
+
+```bash
+uv run ssm profile use dev
+```
+
+Set profile defaults:
+
+```bash
+uv run ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
+```
+
+## Exit behavior
+
+- `run` exits with the child process exit code.
+- Configuration/auth errors generally use exit code `2`.
+- Offline cache miss uses exit code `4`.
+
+## Local quality checks
+
+```bash
+./scripts/quality.sh check
+cd frontend && npm run lint && npm run build
+```
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 74126be..46dab97 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -84,6 +84,15 @@ npm run lint
npm run build
```
+CLI:
+
+```bash
+uv sync
+uv run ssm --help
+```
+
+Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md).
+
## Integration smoke checks
Backend health endpoint (Swagger index):
@@ -98,6 +107,13 @@ Frontend HTTP check:
curl -sS -I http://localhost:8080
```
+CLI smoke check:
+
+```bash
+uv run ssm configure --base-url http://localhost:8080/api --profile dev
+uv run ssm whoami --profile dev
+```
+
## CI publish flow
Container publishing is handled by `.github/workflows/ci.yml`.
diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md
new file mode 100644
index 0000000..d05db1f
--- /dev/null
+++ b/docs/FIRST_TIME_SETUP.md
@@ -0,0 +1,70 @@
+# First-Time Setup Guide
+
+This guide covers the first initialization of a fresh Simple Secrets Manager deployment.
+
+## Prerequisites
+
+- Backend reachable at `http://localhost:5000/api` or via proxy at `http://localhost:8080/api`
+- MongoDB configured and reachable by the backend
+
+## Step 1: Check onboarding state
+
+```bash
+curl -sS http://localhost:5000/api/onboarding/status
+```
+
+Expected on a fresh install:
+
+```json
+{"isInitialized": false, "state": "not_initialized"}
+```
+
+## Step 2: Bootstrap first admin user
+
+```bash
+curl -sS -X POST "http://localhost:5000/api/onboarding/bootstrap" \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"Str0ng!Passw0rd","issueToken":true}'
+```
+
+This creates the first admin account and marks onboarding complete.
+
+## Step 3: Sign in from UI
+
+- Open `http://localhost:8080`
+- Use the created username/password
+- Create projects/configs/secrets in the admin console
+
+## Step 4: Acquire token for API/CLI use
+
+### Option A: Login from CLI with username/password
+
+```bash
+uv run ssm configure --base-url http://localhost:8080/api --profile dev
+uv run ssm login --profile dev
+```
+
+### Option B: Set existing token
+
+```bash
+uv run ssm auth set-token --profile dev --token ""
+```
+
+## Step 5: Verify access
+
+```bash
+uv run ssm whoami --profile dev
+```
+
+## Common issues
+
+- `System already initialized`: bootstrap was already completed.
+- `Missing API token`: login or set token before calling protected endpoints.
+- `Missing scope: `: token does not include required action scope.
+- CLI `env` export fails for multiline values: use JSON format instead.
+
+## Security notes
+
+- Tokens should be scoped for least privilege.
+- Prefer service tokens for CI/CD and machine workloads.
+- Rotate/revoke tokens regularly via `/api/auth/tokens/v2/revoke`.
diff --git a/pyproject.toml b/pyproject.toml
index 2202121..87ee39e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,10 @@ dependencies = [
"flask-cors>=5.0.0,<6.0.0",
"flask-restx>=0.5.1",
"pymongo>4,<5",
+ "click>=8.1.8,<9.0.0",
+ "rich>=13.9.4,<14.0.0",
+ "requests>=2.32.3,<3.0.0",
+ "keyring>=25.6.0,<26.0.0",
]
[project.urls]
@@ -33,8 +37,11 @@ dev = [
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"
+[project.scripts]
+ssm = "ssm_cli.main:cli"
+
[tool.setuptools]
-packages = ["Access", "Api", "Engines"]
+packages = ["Access", "Api", "Engines", "ssm_cli"]
py-modules = ["connection", "server"]
[tool.ruff]
diff --git a/ssm_cli/__init__.py b/ssm_cli/__init__.py
new file mode 100644
index 0000000..a216f77
--- /dev/null
+++ b/ssm_cli/__init__.py
@@ -0,0 +1,4 @@
+"""Simple Secrets Manager CLI package."""
+
+__all__ = ["__version__"]
+__version__ = "0.1.0"
diff --git a/ssm_cli/__main__.py b/ssm_cli/__main__.py
new file mode 100644
index 0000000..5563dc3
--- /dev/null
+++ b/ssm_cli/__main__.py
@@ -0,0 +1,5 @@
+from ssm_cli.main import cli
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/ssm_cli/api.py b/ssm_cli/api.py
new file mode 100644
index 0000000..1f1bb39
--- /dev/null
+++ b/ssm_cli/api.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import urljoin, urlparse
+
+import requests # type: ignore[import-untyped]
+
+
+@dataclass
+class ApiError(Exception):
+ message: str
+ status_code: int = 1
+ body: Any = None
+
+
+def normalize_base_url(value: str) -> str:
+ url = value.strip().rstrip("/")
+ if not url:
+ return url
+ if not url.startswith("http://") and not url.startswith("https://"):
+ url = f"http://{url}"
+
+ parsed = urlparse(url)
+ path = parsed.path.rstrip("/")
+ if not path.endswith("/api"):
+ path = f"{path}/api" if path else "/api"
+ normalized = parsed._replace(path=path, query="", fragment="").geturl()
+ return normalized.rstrip("/")
+
+
+class ApiClient:
+ def __init__(self, base_url: str, token: str | None = None, timeout: int = 10, retries: int = 2):
+ self.base_url = normalize_base_url(base_url)
+ self.token = token
+ self.timeout = timeout
+ self.retries = retries
+ self.session = requests.Session()
+
+ def _build_url(self, path: str) -> str:
+ clean_path = path if path.startswith("/") else f"/{path}"
+ return urljoin(f"{self.base_url}/", clean_path.lstrip("/"))
+
+ def request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: dict[str, Any] | None = None,
+ json_body: dict[str, Any] | None = None,
+ token: str | None = None,
+ basic_auth: tuple[str, str] | None = None,
+ accept: str | None = None,
+ ) -> Any:
+ headers: dict[str, str] = {}
+ effective_token = token if token is not None else self.token
+ if effective_token:
+ headers["Authorization"] = f"Bearer {effective_token}"
+ if accept:
+ headers["Accept"] = accept
+
+ url = self._build_url(path)
+ last_exception: Exception | None = None
+
+ for attempt in range(self.retries + 1):
+ try:
+ response = self.session.request(
+ method=method.upper(),
+ url=url,
+ params=params,
+ json=json_body,
+ headers=headers,
+ auth=basic_auth,
+ timeout=self.timeout,
+ )
+ return self._parse_response(response)
+ except requests.RequestException as exc:
+ last_exception = exc
+ if attempt >= self.retries:
+ break
+ time.sleep(0.2 * (attempt + 1))
+
+ raise ApiError(message=f"Network error: {last_exception}", status_code=1)
+
+ def _parse_response(self, response: requests.Response) -> Any:
+ content_type = response.headers.get("content-type", "")
+
+ if response.status_code >= 400:
+ body = self._safe_parse(response, content_type)
+ message = self._error_message(body, response.status_code)
+ raise ApiError(message=message, status_code=response.status_code, body=body)
+
+ return self._safe_parse(response, content_type)
+
+ @staticmethod
+ def _safe_parse(response: requests.Response, content_type: str) -> Any:
+ if response.status_code == 204:
+ return None
+ if "application/json" in content_type:
+ try:
+ return response.json()
+ except Exception:
+ return {}
+ return response.text
+
+ @staticmethod
+ def _error_message(body: Any, status_code: int) -> str:
+ if isinstance(body, str) and body.strip():
+ return body.strip()
+ if isinstance(body, dict):
+ for key in ("message", "error", "status"):
+ value = body.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return f"API request failed ({status_code})"
+
+ def login_userpass(self, username: str, password: str) -> dict[str, Any]:
+ payload = self.request("GET", "/auth/tokens/", basic_auth=(username, password))
+ if not isinstance(payload, dict) or not isinstance(payload.get("token"), str):
+ raise ApiError("Token response is invalid", status_code=1, body=payload)
+ return payload
+
+ def export_secrets_json(self, project: str, config: str) -> dict[str, str]:
+ payload = self.request(
+ "GET",
+ f"/projects/{project}/configs/{config}/secrets",
+ params={"format": "json", "include_parent": "true", "include_meta": "false"},
+ accept="application/json",
+ )
+ data = payload.get("data") if isinstance(payload, dict) else None
+ if not isinstance(data, dict):
+ raise ApiError("Secrets response is invalid", status_code=1, body=payload)
+
+ parsed: dict[str, str] = {}
+ for key, value in data.items():
+ if isinstance(key, str) and isinstance(value, str):
+ parsed[key] = value
+ return parsed
+
+ def list_projects(self) -> list[dict[str, Any]]:
+ payload = self.request("GET", "/projects", accept="application/json")
+ projects = payload.get("projects") if isinstance(payload, dict) else None
+ if not isinstance(projects, list):
+ raise ApiError("Projects response is invalid", status_code=1, body=payload)
+ return [item for item in projects if isinstance(item, dict)]
diff --git a/ssm_cli/auth.py b/ssm_cli/auth.py
new file mode 100644
index 0000000..4b4b3ab
--- /dev/null
+++ b/ssm_cli/auth.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from typing import Any, Optional, Tuple
+
+from ssm_cli.config import load_credentials, save_credentials
+
+keyring: Any
+try:
+ import keyring as _keyring # type: ignore[import-untyped]
+
+ keyring = _keyring
+except Exception: # pragma: no cover - import failure depends on platform
+ keyring = None
+
+KEYRING_SERVICE = "simple-secrets-manager-cli"
+KEYRING_SENTINEL = "__KEYRING__"
+
+
+def _token_key(profile: str, base_url: str) -> str:
+ return f"{profile}@{base_url}"
+
+
+def set_token(profile: str, base_url: str, token: str) -> str:
+ key = _token_key(profile, base_url)
+ creds = load_credentials()
+
+ if keyring is not None:
+ try:
+ keyring.set_password(KEYRING_SERVICE, key, token)
+ creds[key] = KEYRING_SENTINEL
+ save_credentials(creds)
+ return "keyring"
+ except Exception:
+ pass
+
+ creds[key] = token
+ save_credentials(creds)
+ return "file"
+
+
+def get_token(profile: str, base_url: str) -> Tuple[Optional[str], Optional[str]]:
+ key = _token_key(profile, base_url)
+
+ if keyring is not None:
+ try:
+ token = keyring.get_password(KEYRING_SERVICE, key)
+ if token:
+ return token, "keyring"
+ except Exception:
+ pass
+
+ creds = load_credentials()
+ token = creds.get(key)
+ if token and token != KEYRING_SENTINEL:
+ return token, "file"
+ return None, None
+
+
+def clear_token(profile: str, base_url: str) -> None:
+ key = _token_key(profile, base_url)
+
+ if keyring is not None:
+ try:
+ keyring.delete_password(KEYRING_SERVICE, key)
+ except Exception:
+ pass
+
+ creds = load_credentials()
+ if key in creds:
+ del creds[key]
+ save_credentials(creds)
+
+
+def clear_all_tokens() -> None:
+ creds = load_credentials()
+ keys = list(creds.keys())
+
+ if keyring is not None:
+ for key in keys:
+ try:
+ keyring.delete_password(KEYRING_SERVICE, key)
+ except Exception:
+ pass
+
+ if creds:
+ save_credentials({})
diff --git a/ssm_cli/cache.py b/ssm_cli/cache.py
new file mode 100644
index 0000000..13e5721
--- /dev/null
+++ b/ssm_cli/cache.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from pathlib import Path
+
+from ssm_cli.config import cache_root
+
+
+def _cache_file(base_url: str, project: str, config: str) -> Path:
+ identity = f"{base_url}|{project}|{config}"
+ digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
+ return cache_root() / "secrets" / f"{digest}.json"
+
+
+def save_secret_cache(base_url: str, project: str, config: str, data: dict[str, str]) -> None:
+ path = _cache_file(base_url, project, config)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ payload = {"fetched_at": int(time.time()), "data": data}
+ temp = path.with_suffix(path.suffix + ".tmp")
+ with temp.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, sort_keys=True)
+ handle.write("\n")
+ temp.replace(path)
+
+
+def load_secret_cache(
+ base_url: str,
+ project: str,
+ config: str,
+ max_age_seconds: int | None = None,
+) -> dict[str, str] | None:
+ path = _cache_file(base_url, project, config)
+ if not path.exists():
+ return None
+
+ with path.open("r", encoding="utf-8") as handle:
+ payload = json.load(handle)
+
+ if not isinstance(payload, dict):
+ return None
+
+ fetched_at = payload.get("fetched_at")
+ data = payload.get("data")
+ if not isinstance(fetched_at, int) or not isinstance(data, dict):
+ return None
+
+ if max_age_seconds is not None:
+ age = int(time.time()) - fetched_at
+ if age > max_age_seconds:
+ return None
+
+ parsed: dict[str, str] = {}
+ for key, value in data.items():
+ if isinstance(key, str) and isinstance(value, str):
+ parsed[key] = value
+ return parsed
diff --git a/ssm_cli/config.py b/ssm_cli/config.py
new file mode 100644
index 0000000..5e77312
--- /dev/null
+++ b/ssm_cli/config.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+DEFAULT_PROFILE = "default"
+
+
+def global_config_path() -> Path:
+ override = os.getenv("SSM_GLOBAL_CONFIG_FILE")
+ if override:
+ return Path(override).expanduser()
+ return Path.home() / ".config" / "ssm" / "config.json"
+
+
+def local_config_path(cwd: Path | None = None) -> Path:
+ override = os.getenv("SSM_LOCAL_CONFIG_FILE")
+ if override:
+ return Path(override).expanduser()
+ base = cwd or Path.cwd()
+ return base / ".ssm" / "config.json"
+
+
+def credentials_path() -> Path:
+ override = os.getenv("SSM_CREDENTIALS_FILE")
+ if override:
+ return Path(override).expanduser()
+ return Path.home() / ".config" / "ssm" / "credentials.json"
+
+
+def cache_root() -> Path:
+ override = os.getenv("SSM_CACHE_DIR")
+ if override:
+ return Path(override).expanduser()
+ return Path.home() / ".cache" / "ssm"
+
+
+@dataclass
+class ProfileConfig:
+ base_url: str | None = None
+ project: str | None = None
+ config: str | None = None
+
+
+@dataclass
+class GlobalConfig:
+ base_url: str | None = None
+ active_profile: str = DEFAULT_PROFILE
+ profiles: dict[str, ProfileConfig] = field(default_factory=dict)
+
+
+@dataclass
+class LocalConfig:
+ profile: str | None = None
+ project: str | None = None
+ config: str | None = None
+
+
+def _read_json(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {}
+ with path.open("r", encoding="utf-8") as handle:
+ data = json.load(handle)
+ if not isinstance(data, dict):
+ return {}
+ return data
+
+
+def _atomic_write_json(path: Path, data: dict[str, Any], mode: int = 0o600) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp = path.with_suffix(path.suffix + ".tmp")
+ with temp.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ os.chmod(temp, mode)
+ temp.replace(path)
+
+
+def load_global_config() -> GlobalConfig:
+ raw = _read_json(global_config_path())
+ profiles_raw_obj = raw.get("profiles")
+ profiles_raw = profiles_raw_obj if isinstance(profiles_raw_obj, dict) else {}
+ profiles: dict[str, ProfileConfig] = {}
+ for name, value in profiles_raw.items():
+ if not isinstance(name, str) or not isinstance(value, dict):
+ continue
+ profiles[name] = ProfileConfig(
+ base_url=_str_or_none(value.get("base_url")),
+ project=_str_or_none(value.get("project")),
+ config=_str_or_none(value.get("config")),
+ )
+
+ active_profile = _str_or_none(raw.get("active_profile")) or DEFAULT_PROFILE
+ if active_profile not in profiles:
+ profiles[active_profile] = ProfileConfig()
+
+ return GlobalConfig(
+ base_url=_str_or_none(raw.get("base_url")),
+ active_profile=active_profile,
+ profiles=profiles,
+ )
+
+
+def save_global_config(cfg: GlobalConfig) -> None:
+ raw_profiles: dict[str, dict[str, str]] = {}
+ for name, profile in cfg.profiles.items():
+ if not name:
+ continue
+ item: dict[str, str] = {}
+ if profile.base_url:
+ item["base_url"] = profile.base_url
+ if profile.project:
+ item["project"] = profile.project
+ if profile.config:
+ item["config"] = profile.config
+ raw_profiles[name] = item
+
+ payload: dict[str, Any] = {
+ "active_profile": cfg.active_profile or DEFAULT_PROFILE,
+ "profiles": raw_profiles,
+ }
+ if cfg.base_url:
+ payload["base_url"] = cfg.base_url
+
+ _atomic_write_json(global_config_path(), payload, mode=0o600)
+
+
+def load_local_config(cwd: Path | None = None) -> LocalConfig:
+ raw = _read_json(local_config_path(cwd))
+ return LocalConfig(
+ profile=_str_or_none(raw.get("profile")),
+ project=_str_or_none(raw.get("project")),
+ config=_str_or_none(raw.get("config")),
+ )
+
+
+def save_local_config(cfg: LocalConfig, cwd: Path | None = None) -> None:
+ payload: dict[str, str] = {}
+ if cfg.profile:
+ payload["profile"] = cfg.profile
+ if cfg.project:
+ payload["project"] = cfg.project
+ if cfg.config:
+ payload["config"] = cfg.config
+ _atomic_write_json(local_config_path(cwd), payload, mode=0o600)
+
+
+def load_credentials() -> dict[str, str]:
+ raw = _read_json(credentials_path())
+ tokens = raw.get("tokens")
+ if not isinstance(tokens, dict):
+ return {}
+ parsed: dict[str, str] = {}
+ for key, value in tokens.items():
+ if isinstance(key, str) and isinstance(value, str):
+ parsed[key] = value
+ return parsed
+
+
+def save_credentials(tokens: dict[str, str]) -> None:
+ _atomic_write_json(credentials_path(), {"tokens": tokens}, mode=0o600)
+
+
+def _str_or_none(value: Any) -> str | None:
+ if isinstance(value, str):
+ stripped = value.strip()
+ if stripped:
+ return stripped
+ return None
diff --git a/ssm_cli/exceptions.py b/ssm_cli/exceptions.py
new file mode 100644
index 0000000..f56ad42
--- /dev/null
+++ b/ssm_cli/exceptions.py
@@ -0,0 +1,8 @@
+from __future__ import annotations
+
+
+class CliError(Exception):
+ def __init__(self, message: str, exit_code: int = 1):
+ super().__init__(message)
+ self.message = message
+ self.exit_code = exit_code
diff --git a/ssm_cli/main.py b/ssm_cli/main.py
new file mode 100644
index 0000000..cc313cc
--- /dev/null
+++ b/ssm_cli/main.py
@@ -0,0 +1,489 @@
+from __future__ import annotations
+
+import json
+import os
+import stat
+from functools import wraps
+from pathlib import Path
+from typing import Any, Callable
+
+import click
+from rich.console import Console
+from rich.table import Table
+
+from ssm_cli import auth
+from ssm_cli.api import ApiClient, ApiError, normalize_base_url
+from ssm_cli.cache import load_secret_cache, save_secret_cache
+from ssm_cli.config import (
+ DEFAULT_PROFILE,
+ GlobalConfig,
+ LocalConfig,
+ ProfileConfig,
+ load_global_config,
+ save_global_config,
+ save_local_config,
+)
+from ssm_cli.exceptions import CliError
+from ssm_cli.resolve import Resolution, resolve_context
+from ssm_cli.run_utils import render_env_lines, run_with_env
+
+console = Console()
+err_console = Console(stderr=True)
+
+
+def _fail(message: str, code: int = 1) -> None:
+ err_console.print(f"[red]Error:[/red] {message}")
+ raise click.exceptions.Exit(code)
+
+
+def _handle_errors(fn: Callable[..., Any]) -> Callable[..., Any]:
+ @wraps(fn)
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
+ try:
+ return fn(*args, **kwargs)
+ except CliError as exc:
+ _fail(exc.message, exc.exit_code)
+ except ApiError as exc:
+ _fail(f"{exc.message} (status={exc.status_code})", 1)
+
+ return wrapper
+
+
+def _profile_name(override: str | None, cfg: GlobalConfig) -> str:
+ env = os.getenv("SSM_PROFILE")
+ return (override or env or cfg.active_profile or DEFAULT_PROFILE).strip() or DEFAULT_PROFILE
+
+
+def _ensure_profile(cfg: GlobalConfig, profile_name: str) -> ProfileConfig:
+ profile = cfg.profiles.get(profile_name)
+ if profile is None:
+ profile = ProfileConfig()
+ cfg.profiles[profile_name] = profile
+ return profile
+
+
+def _fetch_secrets(
+ resolution: Resolution,
+ *,
+ offline: bool,
+ cache_ttl: int,
+) -> tuple[dict[str, str], str]:
+ if not resolution.base_url or not resolution.project or not resolution.config:
+ raise CliError("Missing base_url/project/config for secret retrieval", exit_code=2)
+
+ if offline:
+ cached = load_secret_cache(
+ resolution.base_url,
+ resolution.project,
+ resolution.config,
+ max_age_seconds=cache_ttl,
+ )
+ if cached is None:
+ raise CliError("No cached secrets found for offline mode", exit_code=4)
+ return cached, "cache"
+
+ client = ApiClient(resolution.base_url, token=resolution.token)
+ try:
+ data = client.export_secrets_json(resolution.project, resolution.config)
+ save_secret_cache(resolution.base_url, resolution.project, resolution.config, data)
+ return data, "remote"
+ except ApiError:
+ cached = load_secret_cache(
+ resolution.base_url,
+ resolution.project,
+ resolution.config,
+ max_age_seconds=cache_ttl,
+ )
+ if cached is not None:
+ console.print("[yellow]Using cached secrets because live fetch failed.[/yellow]")
+ return cached, "cache-fallback"
+ raise
+
+
+def _print_env_table(secrets: dict[str, str], show_values: bool) -> None:
+ table = Table(title="Resolved environment", show_lines=False)
+ table.add_column("Key", style="cyan")
+ table.add_column("Value", style="magenta")
+ for key in sorted(secrets.keys()):
+ value = secrets[key] if show_values else "[redacted]"
+ table.add_row(key, value)
+ console.print(table)
+
+
+@click.group(help="Simple Secrets Manager CLI")
+def cli() -> None:
+ pass
+
+
+@cli.command(help="Configure API base URL for a profile")
+@click.option("--base-url", required=True, help="Base URL (for example http://localhost:8080/api)")
+@click.option("--profile", default=None, help="Profile name")
+@click.option("--activate/--no-activate", default=True, help="Set profile as active")
+@_handle_errors
+def configure(base_url: str, profile: str | None, activate: bool) -> None:
+ cfg = load_global_config()
+ profile_name = _profile_name(profile, cfg)
+ normalized = normalize_base_url(base_url)
+ profile_cfg = _ensure_profile(cfg, profile_name)
+ profile_cfg.base_url = normalized
+ cfg.base_url = normalized
+ if activate:
+ cfg.active_profile = profile_name
+ save_global_config(cfg)
+ console.print(f"Configured [bold]{profile_name}[/bold] -> {normalized}")
+
+
+@cli.group(name="auth", help="Authentication helpers")
+def auth_cmd() -> None:
+ pass
+
+
+@auth_cmd.command("set-token", help="Store a token for the current base URL/profile")
+@click.option("--token", prompt=True, hide_input=True, help="Token value")
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--profile", default=None, help="Profile name")
+@_handle_errors
+def auth_set_token(token: str, base_url: str | None, profile: str | None) -> None:
+ resolution = resolve_context(base_url=base_url, profile=profile, require_base_url=True)
+ storage = auth.set_token(resolution.profile, resolution.base_url or "", token.strip())
+ console.print(f"Token saved for profile [bold]{resolution.profile}[/bold] ({storage})")
+
+
+@cli.command(help="Login with username/password and store returned token")
+@click.option("--username", prompt=True, help="Username")
+@click.option("--password", prompt=True, hide_input=True, help="Password")
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--profile", default=None, help="Profile name")
+@_handle_errors
+def login(username: str, password: str, base_url: str | None, profile: str | None) -> None:
+ resolution = resolve_context(base_url=base_url, profile=profile, require_base_url=True)
+ client = ApiClient(resolution.base_url or "")
+ token_payload = client.login_userpass(username.strip(), password)
+ token = token_payload.get("token")
+ if not isinstance(token, str):
+ raise CliError("Login response did not include a token")
+
+ storage = auth.set_token(resolution.profile, resolution.base_url or "", token)
+
+ cfg = load_global_config()
+ profile_cfg = _ensure_profile(cfg, resolution.profile)
+ if not profile_cfg.base_url:
+ profile_cfg.base_url = resolution.base_url
+ if cfg.active_profile != resolution.profile:
+ cfg.active_profile = resolution.profile
+ save_global_config(cfg)
+
+ expires = token_payload.get("expires_at")
+ if isinstance(expires, str) and expires:
+ console.print(f"Logged in. Token stored in {storage}. Expires at {expires}")
+ else:
+ console.print(f"Logged in. Token stored in {storage}.")
+
+
+@cli.command(help="Remove locally stored token")
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--profile", default=None, help="Profile name")
+@click.option("--all-profiles", is_flag=True, default=False, help="Remove all file-backed tokens")
+@_handle_errors
+def logout(base_url: str | None, profile: str | None, all_profiles: bool) -> None:
+ if all_profiles:
+ auth.clear_all_tokens()
+ console.print("Cleared all locally stored tokens.")
+ return
+
+ resolution = resolve_context(base_url=base_url, profile=profile, require_base_url=True)
+ auth.clear_token(resolution.profile, resolution.base_url or "")
+ console.print(f"Cleared token for profile [bold]{resolution.profile}[/bold].")
+
+
+@cli.command(help="Set project/config defaults for current directory")
+@click.option("--project", prompt=True, help="Project slug")
+@click.option("--config", "config_name", prompt=True, help="Config slug")
+@click.option("--profile", default=None, help="Profile name")
+@click.option("--sync-profile/--local-only", default=True, help="Also save as profile defaults")
+@_handle_errors
+def setup(project: str, config_name: str, profile: str | None, sync_profile: bool) -> None:
+ resolution = resolve_context(profile=profile)
+ save_local_config(LocalConfig(profile=resolution.profile, project=project.strip(), config=config_name.strip()))
+
+ if sync_profile:
+ cfg = load_global_config()
+ profile_cfg = _ensure_profile(cfg, resolution.profile)
+ profile_cfg.project = project.strip()
+ profile_cfg.config = config_name.strip()
+ if resolution.base_url and not profile_cfg.base_url:
+ profile_cfg.base_url = resolution.base_url
+ save_global_config(cfg)
+
+ console.print("Directory defaults saved in .ssm/config.json")
+
+
+@cli.command(
+ context_settings={"ignore_unknown_options": True, "allow_interspersed_args": False},
+ help="Run command with secrets injected into child process environment",
+)
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--project", default=None, help="Project slug override")
+@click.option("--config", "config_name", default=None, help="Config slug override")
+@click.option("--profile", default=None, help="Profile name")
+@click.option("--offline", is_flag=True, default=False, help="Use cached secrets only")
+@click.option("--cache-ttl", default=3600, show_default=True, type=int, help="Cache max age in seconds")
+@click.option("--print-env", is_flag=True, default=False, help="Print resolved keys before execution")
+@click.option("--show-values", is_flag=True, default=False, help="Show values with --print-env")
+@click.argument("command", nargs=-1, type=click.UNPROCESSED)
+@_handle_errors
+def run(
+ base_url: str | None,
+ project: str | None,
+ config_name: str | None,
+ profile: str | None,
+ offline: bool,
+ cache_ttl: int,
+ print_env: bool,
+ show_values: bool,
+ command: tuple[str, ...],
+) -> None:
+ if not command:
+ raise CliError("Command is required after `ssm run --`", exit_code=2)
+ resolution = resolve_context(
+ base_url=base_url,
+ project=project,
+ config=config_name,
+ profile=profile,
+ require_base_url=True,
+ require_project_config=True,
+ require_token=True,
+ )
+ secrets, source = _fetch_secrets(resolution, offline=offline, cache_ttl=cache_ttl)
+
+ if print_env:
+ _print_env_table(secrets, show_values=show_values)
+
+ if source != "remote":
+ console.print(f"Using secrets from [bold]{source}[/bold].")
+
+ code = run_with_env(command, secrets)
+ raise click.exceptions.Exit(code)
+
+
+@cli.group(help="Secret export and mount commands")
+def secrets() -> None:
+ pass
+
+
+@secrets.command("download", help="Download secrets to stdout")
+@click.option("--format", "output_format", type=click.Choice(["json", "env"]), default="json", show_default=True)
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--project", default=None, help="Project slug override")
+@click.option("--config", "config_name", default=None, help="Config slug override")
+@click.option("--profile", default=None, help="Profile name")
+@click.option("--offline", is_flag=True, default=False, help="Use cached secrets only")
+@click.option("--cache-ttl", default=3600, show_default=True, type=int, help="Cache max age in seconds")
+@_handle_errors
+def secrets_download(
+ output_format: str,
+ base_url: str | None,
+ project: str | None,
+ config_name: str | None,
+ profile: str | None,
+ offline: bool,
+ cache_ttl: int,
+) -> None:
+ resolution = resolve_context(
+ base_url=base_url,
+ project=project,
+ config=config_name,
+ profile=profile,
+ require_base_url=True,
+ require_project_config=True,
+ require_token=True,
+ )
+ secrets_data, source = _fetch_secrets(resolution, offline=offline, cache_ttl=cache_ttl)
+ if source != "remote":
+ console.print(f"Using secrets from [bold]{source}[/bold].", style="yellow")
+
+ if output_format == "json":
+ console.print_json(json.dumps(secrets_data, sort_keys=True))
+ return
+
+ console.print(render_env_lines(secrets_data), soft_wrap=True)
+
+
+@secrets.command("mount", help="Write secrets to a named pipe (FIFO)")
+@click.option("--path", "fifo_path", required=True, type=click.Path(path_type=Path), help="FIFO path")
+@click.option("--format", "output_format", type=click.Choice(["json", "env"]), default="env", show_default=True)
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--project", default=None, help="Project slug override")
+@click.option("--config", "config_name", default=None, help="Config slug override")
+@click.option("--profile", default=None, help="Profile name")
+@click.option("--offline", is_flag=True, default=False, help="Use cached secrets only")
+@click.option("--cache-ttl", default=3600, show_default=True, type=int, help="Cache max age in seconds")
+@click.option("--keep", is_flag=True, default=False, help="Keep FIFO after write")
+@_handle_errors
+def secrets_mount(
+ fifo_path: Path,
+ output_format: str,
+ base_url: str | None,
+ project: str | None,
+ config_name: str | None,
+ profile: str | None,
+ offline: bool,
+ cache_ttl: int,
+ keep: bool,
+) -> None:
+ resolution = resolve_context(
+ base_url=base_url,
+ project=project,
+ config=config_name,
+ profile=profile,
+ require_base_url=True,
+ require_project_config=True,
+ require_token=True,
+ )
+ secrets_data, source = _fetch_secrets(resolution, offline=offline, cache_ttl=cache_ttl)
+
+ payload = json.dumps(secrets_data, sort_keys=True) if output_format == "json" else render_env_lines(secrets_data)
+
+ if fifo_path.exists():
+ file_mode = fifo_path.stat().st_mode
+ if not stat.S_ISFIFO(file_mode):
+ raise CliError(f"Path exists and is not a FIFO: {fifo_path}")
+ fifo_path.unlink()
+
+ fifo_path.parent.mkdir(parents=True, exist_ok=True)
+ os.mkfifo(fifo_path, mode=0o600)
+
+ console.print(f"FIFO created at {fifo_path}. Waiting for reader...")
+ if source != "remote":
+ console.print(f"Using secrets from [bold]{source}[/bold].", style="yellow")
+
+ try:
+ with fifo_path.open("w", encoding="utf-8") as handle:
+ handle.write(payload)
+ handle.write("\n")
+ console.print("Payload written to FIFO.")
+ finally:
+ if not keep and fifo_path.exists():
+ fifo_path.unlink()
+
+
+@cli.command(help="Validate the active token and print context")
+@click.option("--base-url", default=None, help="Base URL override")
+@click.option("--profile", default=None, help="Profile name")
+@_handle_errors
+def whoami(base_url: str | None, profile: str | None) -> None:
+ resolution = resolve_context(
+ base_url=base_url,
+ profile=profile,
+ require_base_url=True,
+ require_token=True,
+ )
+
+ client = ApiClient(resolution.base_url or "", token=resolution.token)
+ scope_message = ""
+ try:
+ projects = client.list_projects()
+ scope_message = f"projects:read available ({len(projects)} visible)"
+ except ApiError as exc:
+ if exc.status_code == 403:
+ scope_message = "token valid, but missing projects:read"
+ else:
+ raise
+
+ token_preview = "[hidden]"
+ if resolution.token and len(resolution.token) >= 8:
+ token_preview = f"{resolution.token[:4]}...{resolution.token[-4:]}"
+
+ table = Table(title="Current session")
+ table.add_column("Field", style="cyan")
+ table.add_column("Value", style="green")
+ table.add_row("Profile", resolution.profile)
+ table.add_row("Base URL", resolution.base_url or "")
+ table.add_row("Token Source", resolution.token_source or "unknown")
+ table.add_row("Token", token_preview)
+ table.add_row("Scopes", scope_message)
+ console.print(table)
+
+
+@cli.group(help="Manage CLI profiles")
+def profile() -> None:
+ pass
+
+
+@profile.command("list", help="List profiles")
+@_handle_errors
+def profile_list() -> None:
+ cfg = load_global_config()
+ table = Table(title="Profiles")
+ table.add_column("Profile", style="cyan")
+ table.add_column("Active")
+ table.add_column("Base URL")
+ table.add_column("Project")
+ table.add_column("Config")
+
+ names = sorted(cfg.profiles.keys())
+ if not names:
+ names = [cfg.active_profile]
+
+ for name in names:
+ profile_cfg = cfg.profiles.get(name) or ProfileConfig()
+ table.add_row(
+ name,
+ "yes" if name == cfg.active_profile else "",
+ profile_cfg.base_url or "",
+ profile_cfg.project or "",
+ profile_cfg.config or "",
+ )
+ console.print(table)
+
+
+@profile.command("use", help="Set active profile")
+@click.argument("name")
+@_handle_errors
+def profile_use(name: str) -> None:
+ profile_name = name.strip()
+ if not profile_name:
+ raise CliError("Profile name cannot be empty", exit_code=2)
+
+ cfg = load_global_config()
+ _ensure_profile(cfg, profile_name)
+ cfg.active_profile = profile_name
+ save_global_config(cfg)
+ console.print(f"Active profile set to [bold]{profile_name}[/bold]")
+
+
+@profile.command("set", help="Set profile fields")
+@click.argument("name")
+@click.option("--base-url", default=None, help="Base URL")
+@click.option("--project", default=None, help="Default project")
+@click.option("--config", "config_name", default=None, help="Default config")
+@click.option("--activate", is_flag=True, default=False, help="Set as active profile")
+@_handle_errors
+def profile_set(name: str, base_url: str | None, project: str | None, config_name: str | None, activate: bool) -> None:
+ if not any([base_url, project, config_name, activate]):
+ raise CliError("No changes requested. Provide at least one option.", exit_code=2)
+
+ profile_name = name.strip()
+ if not profile_name:
+ raise CliError("Profile name cannot be empty", exit_code=2)
+
+ cfg = load_global_config()
+ profile_cfg = _ensure_profile(cfg, profile_name)
+
+ if base_url:
+ normalized = normalize_base_url(base_url)
+ profile_cfg.base_url = normalized
+ cfg.base_url = normalized
+ if project:
+ profile_cfg.project = project.strip()
+ if config_name:
+ profile_cfg.config = config_name.strip()
+ if activate:
+ cfg.active_profile = profile_name
+
+ save_global_config(cfg)
+ console.print(f"Profile [bold]{profile_name}[/bold] updated.")
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/ssm_cli/resolve.py b/ssm_cli/resolve.py
new file mode 100644
index 0000000..5baa19b
--- /dev/null
+++ b/ssm_cli/resolve.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+from ssm_cli import auth
+from ssm_cli.api import normalize_base_url
+from ssm_cli.config import DEFAULT_PROFILE, load_global_config, load_local_config
+from ssm_cli.exceptions import CliError
+
+
+@dataclass
+class Resolution:
+ profile: str
+ base_url: str | None
+ project: str | None
+ config: str | None
+ token: str | None
+ token_source: str | None
+
+
+def _pick(*values: str | None) -> str | None:
+ for value in values:
+ if value is not None:
+ stripped = value.strip()
+ if stripped:
+ return stripped
+ return None
+
+
+def resolve_context(
+ *,
+ base_url: str | None = None,
+ project: str | None = None,
+ config: str | None = None,
+ profile: str | None = None,
+ cwd: Path | None = None,
+ require_base_url: bool = False,
+ require_project_config: bool = False,
+ require_token: bool = False,
+) -> Resolution:
+ global_cfg = load_global_config()
+ local_cfg = load_local_config(cwd)
+
+ resolved_profile = _pick(
+ profile,
+ os.getenv("SSM_PROFILE"),
+ local_cfg.profile,
+ global_cfg.active_profile,
+ DEFAULT_PROFILE,
+ )
+ if resolved_profile is None:
+ resolved_profile = DEFAULT_PROFILE
+
+ profile_cfg = global_cfg.profiles.get(resolved_profile)
+
+ resolved_base_url = _pick(
+ base_url,
+ os.getenv("SSM_BASE_URL"),
+ profile_cfg.base_url if profile_cfg else None,
+ global_cfg.base_url,
+ )
+ if resolved_base_url:
+ resolved_base_url = normalize_base_url(resolved_base_url)
+
+ resolved_project = _pick(
+ project,
+ os.getenv("SSM_PROJECT"),
+ local_cfg.project,
+ profile_cfg.project if profile_cfg else None,
+ )
+ resolved_config = _pick(
+ config,
+ os.getenv("SSM_CONFIG"),
+ local_cfg.config,
+ profile_cfg.config if profile_cfg else None,
+ )
+
+ env_token = _pick(os.getenv("SSM_TOKEN"))
+ if env_token is not None:
+ token = env_token
+ token_source = "env"
+ else:
+ token = None
+ token_source = None
+ if resolved_base_url:
+ token, token_source = auth.get_token(resolved_profile, resolved_base_url)
+
+ result = Resolution(
+ profile=resolved_profile,
+ base_url=resolved_base_url,
+ project=resolved_project,
+ config=resolved_config,
+ token=token,
+ token_source=token_source,
+ )
+
+ if require_base_url and not result.base_url:
+ raise CliError("Base URL is not configured. Run `ssm configure` or pass --base-url.", exit_code=2)
+
+ if require_project_config and (not result.project or not result.config):
+ raise CliError(
+ "Project/config is not configured. Run `ssm setup` or pass --project and --config.",
+ exit_code=2,
+ )
+
+ if require_token and not result.token:
+ raise CliError("Token is not configured. Run `ssm login` or `ssm auth set-token`.", exit_code=2)
+
+ return result
diff --git a/ssm_cli/run_utils.py b/ssm_cli/run_utils.py
new file mode 100644
index 0000000..9390085
--- /dev/null
+++ b/ssm_cli/run_utils.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import os
+import subprocess
+from typing import Iterable
+
+from ssm_cli.exceptions import CliError
+
+
+def merge_env(secrets: dict[str, str], base_env: dict[str, str] | None = None) -> dict[str, str]:
+ env = dict(base_env if base_env is not None else os.environ)
+ env.update(secrets)
+ return env
+
+
+def render_env_lines(secrets: dict[str, str]) -> str:
+ lines = []
+ for key, value in secrets.items():
+ if "\n" in value:
+ raise CliError(f"Value for {key} contains newline; env format does not support it", exit_code=3)
+ lines.append(f"{key}={value}")
+ return "\n".join(lines)
+
+
+def run_with_env(command: Iterable[str], secrets: dict[str, str]) -> int:
+ merged_env = merge_env(secrets)
+ completed = subprocess.run(list(command), env=merged_env, check=False)
+ return completed.returncode
diff --git a/tests/test_cli_api.py b/tests/test_cli_api.py
new file mode 100644
index 0000000..0adbca9
--- /dev/null
+++ b/tests/test_cli_api.py
@@ -0,0 +1,68 @@
+import json
+
+import requests # type: ignore[import-untyped]
+
+from ssm_cli.api import ApiClient, ApiError, normalize_base_url
+
+
+def _response(status_code: int, payload, content_type: str = "application/json") -> requests.Response:
+ response = requests.Response()
+ response.status_code = status_code
+ response.headers["content-type"] = content_type
+ if isinstance(payload, (dict, list)):
+ response._content = json.dumps(payload).encode("utf-8")
+ elif isinstance(payload, str):
+ response._content = payload.encode("utf-8")
+ else:
+ response._content = b""
+ response.url = "http://localhost/api"
+ return response
+
+
+def test_normalize_base_url_appends_api_path():
+ assert normalize_base_url("localhost:8080") == "http://localhost:8080/api"
+ assert normalize_base_url("http://localhost:8080/") == "http://localhost:8080/api"
+ assert normalize_base_url("http://localhost:8080/api") == "http://localhost:8080/api"
+
+
+def test_login_userpass_parses_token(monkeypatch):
+ client = ApiClient("http://localhost:8080")
+
+ def fake_request(**kwargs):
+ assert kwargs["auth"] == ("admin", "password")
+ return _response(200, {"token": "abc123", "status": "OK"})
+
+ monkeypatch.setattr(client.session, "request", fake_request)
+
+ payload = client.login_userpass("admin", "password")
+ assert payload["token"] == "abc123"
+
+
+def test_export_secrets_json_parses_values(monkeypatch):
+ client = ApiClient("http://localhost:8080", token="t")
+
+ def fake_request(**kwargs):
+ assert kwargs["headers"]["Authorization"] == "Bearer t"
+ assert kwargs["params"]["format"] == "json"
+ return _response(200, {"data": {"API_KEY": "value", "PORT": "8080"}, "status": "OK"})
+
+ monkeypatch.setattr(client.session, "request", fake_request)
+
+ data = client.export_secrets_json("proj", "cfg")
+ assert data == {"API_KEY": "value", "PORT": "8080"}
+
+
+def test_request_raises_api_error_on_http_failure(monkeypatch):
+ client = ApiClient("http://localhost:8080", token="t")
+
+ def fake_request(**kwargs):
+ return _response(401, {"message": "Not Authorized"})
+
+ monkeypatch.setattr(client.session, "request", fake_request)
+
+ try:
+ client.request("GET", "/projects")
+ raise AssertionError("ApiError expected")
+ except ApiError as exc:
+ assert exc.status_code == 401
+ assert "Not Authorized" in exc.message
diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py
new file mode 100644
index 0000000..897f475
--- /dev/null
+++ b/tests/test_cli_commands.py
@@ -0,0 +1,36 @@
+import json
+
+from click.testing import CliRunner
+
+from ssm_cli.main import cli
+
+
+def _read_json(path):
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def test_configure_and_setup_write_configs(monkeypatch, tmp_path):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("SSM_GLOBAL_CONFIG_FILE", str(tmp_path / "global.json"))
+ monkeypatch.setenv("SSM_LOCAL_CONFIG_FILE", str(tmp_path / ".ssm" / "config.json"))
+ monkeypatch.setenv("SSM_CREDENTIALS_FILE", str(tmp_path / "credentials.json"))
+
+ runner = CliRunner()
+
+ configured = runner.invoke(cli, ["configure", "--base-url", "http://localhost:8080", "--profile", "dev"])
+ assert configured.exit_code == 0, configured.output
+
+ setup = runner.invoke(
+ cli,
+ ["setup", "--project", "payments", "--config", "dev", "--profile", "dev", "--local-only"],
+ )
+ assert setup.exit_code == 0, setup.output
+
+ global_data = _read_json(tmp_path / "global.json")
+ assert global_data["active_profile"] == "dev"
+ assert global_data["profiles"]["dev"]["base_url"] == "http://localhost:8080/api"
+
+ local_data = _read_json(tmp_path / ".ssm" / "config.json")
+ assert local_data["project"] == "payments"
+ assert local_data["config"] == "dev"
+ assert local_data["profile"] == "dev"
diff --git a/tests/test_cli_fetch_cache.py b/tests/test_cli_fetch_cache.py
new file mode 100644
index 0000000..96458a3
--- /dev/null
+++ b/tests/test_cli_fetch_cache.py
@@ -0,0 +1,53 @@
+from ssm_cli.api import ApiError
+from ssm_cli.main import _fetch_secrets
+from ssm_cli.resolve import Resolution
+
+
+def _resolution() -> Resolution:
+ return Resolution(
+ profile="default",
+ base_url="http://localhost:8080/api",
+ project="project-a",
+ config="dev",
+ token="token",
+ token_source="file",
+ )
+
+
+def test_fetch_secrets_uses_cache_when_offline(monkeypatch, tmp_path):
+ monkeypatch.setenv("SSM_CACHE_DIR", str(tmp_path))
+ # Seed with the exact hashed filename by doing one successful save flow.
+ from ssm_cli.cache import save_secret_cache
+
+ save_secret_cache("http://localhost:8080/api", "project-a", "dev", {"A": "1"})
+
+ data, source = _fetch_secrets(_resolution(), offline=True, cache_ttl=3600)
+ assert data == {"A": "1"}
+ assert source == "cache"
+
+
+def test_fetch_secrets_falls_back_to_cache_on_api_error(monkeypatch, tmp_path):
+ monkeypatch.setenv("SSM_CACHE_DIR", str(tmp_path))
+
+ from ssm_cli.cache import save_secret_cache
+
+ save_secret_cache("http://localhost:8080/api", "project-a", "dev", {"CACHED": "yes"})
+
+ def fail_export(self, project, config):
+ raise ApiError("boom", status_code=503)
+
+ monkeypatch.setattr("ssm_cli.main.ApiClient.export_secrets_json", fail_export)
+
+ data, source = _fetch_secrets(_resolution(), offline=False, cache_ttl=3600)
+ assert data == {"CACHED": "yes"}
+ assert source == "cache-fallback"
+
+
+def test_fetch_secrets_raises_when_offline_cache_missing(monkeypatch, tmp_path):
+ monkeypatch.setenv("SSM_CACHE_DIR", str(tmp_path))
+
+ try:
+ _fetch_secrets(_resolution(), offline=True, cache_ttl=60)
+ raise AssertionError("Expected failure")
+ except Exception as exc:
+ assert "No cached secrets found" in str(exc)
diff --git a/tests/test_cli_resolve.py b/tests/test_cli_resolve.py
new file mode 100644
index 0000000..206ee13
--- /dev/null
+++ b/tests/test_cli_resolve.py
@@ -0,0 +1,89 @@
+import json
+from pathlib import Path
+
+from ssm_cli.resolve import resolve_context
+
+
+def _write_json(path: Path, payload: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(payload), encoding="utf-8")
+
+
+def test_resolve_precedence_flags_over_env_and_files(monkeypatch, tmp_path):
+ global_file = tmp_path / "global.json"
+ local_file = tmp_path / "project" / ".ssm" / "config.json"
+
+ _write_json(
+ global_file,
+ {
+ "active_profile": "globalprof",
+ "base_url": "http://global:8080/api",
+ "profiles": {
+ "globalprof": {
+ "base_url": "http://profile:8080/api",
+ "project": "profile-project",
+ "config": "profile-config",
+ },
+ "flagprof": {
+ "base_url": "http://flag-profile:8080/api",
+ "project": "flag-profile-project",
+ "config": "flag-profile-config",
+ },
+ },
+ },
+ )
+ _write_json(local_file, {"profile": "localprof", "project": "local-project", "config": "local-config"})
+
+ monkeypatch.setenv("SSM_GLOBAL_CONFIG_FILE", str(global_file))
+ monkeypatch.setenv("SSM_LOCAL_CONFIG_FILE", str(local_file))
+ monkeypatch.setenv("SSM_BASE_URL", "http://env:8080/api")
+ monkeypatch.setenv("SSM_PROJECT", "env-project")
+ monkeypatch.setenv("SSM_CONFIG", "env-config")
+ monkeypatch.setenv("SSM_TOKEN", "env-token")
+
+ result = resolve_context(
+ base_url="http://flag:8080/api",
+ project="flag-project",
+ config="flag-config",
+ profile="flagprof",
+ )
+
+ assert result.profile == "flagprof"
+ assert result.base_url == "http://flag:8080/api"
+ assert result.project == "flag-project"
+ assert result.config == "flag-config"
+ assert result.token == "env-token"
+ assert result.token_source == "env"
+
+
+def test_resolve_uses_env_then_local_then_global(monkeypatch, tmp_path):
+ global_file = tmp_path / "global.json"
+ local_file = tmp_path / "project" / ".ssm" / "config.json"
+
+ _write_json(
+ global_file,
+ {
+ "active_profile": "dev",
+ "profiles": {
+ "dev": {
+ "base_url": "http://profile-host:8080/api",
+ "project": "global-project",
+ "config": "global-config",
+ }
+ },
+ },
+ )
+ _write_json(local_file, {"project": "local-project", "config": "local-config"})
+
+ monkeypatch.setenv("SSM_GLOBAL_CONFIG_FILE", str(global_file))
+ monkeypatch.setenv("SSM_LOCAL_CONFIG_FILE", str(local_file))
+ monkeypatch.delenv("SSM_PROJECT", raising=False)
+ monkeypatch.delenv("SSM_CONFIG", raising=False)
+ monkeypatch.delenv("SSM_TOKEN", raising=False)
+
+ result = resolve_context()
+
+ assert result.profile == "dev"
+ assert result.base_url == "http://profile-host:8080/api"
+ assert result.project == "local-project"
+ assert result.config == "local-config"
diff --git a/tests/test_cli_run_utils.py b/tests/test_cli_run_utils.py
new file mode 100644
index 0000000..f5481f0
--- /dev/null
+++ b/tests/test_cli_run_utils.py
@@ -0,0 +1,26 @@
+import os
+import sys
+
+from ssm_cli.run_utils import merge_env, run_with_env
+
+
+def test_merge_env_overrides_without_mutating_parent(monkeypatch):
+ monkeypatch.setenv("BASE_ONLY", "1")
+ parent = dict(os.environ)
+
+ merged = merge_env({"BASE_ONLY": "2", "NEW_KEY": "x"}, base_env=parent)
+
+ assert parent["BASE_ONLY"] == "1"
+ assert "NEW_KEY" not in parent
+ assert merged["BASE_ONLY"] == "2"
+ assert merged["NEW_KEY"] == "x"
+
+
+def test_run_with_env_injects_into_child_process():
+ command = [
+ sys.executable,
+ "-c",
+ "import os,sys; sys.exit(0 if os.getenv('SSM_TEST_KEY') == 'injected' else 7)",
+ ]
+ code = run_with_env(command, {"SSM_TEST_KEY": "injected"})
+ assert code == 0
diff --git a/uv.lock b/uv.lock
index 2dfd77f..6653a08 100644
--- a/uv.lock
+++ b/uv.lock
@@ -43,6 +43,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
+[[package]]
+name = "backports-tarfile"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" },
+]
+
[[package]]
name = "blinker"
version = "1.9.0"
@@ -52,6 +61,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
+]
+
[[package]]
name = "cffi"
version = "2.0.0"
@@ -147,6 +165,111 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" },
]
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
+ { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
+ { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
+ { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
+ { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
+ { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609, upload-time = "2025-10-14T04:42:10.922Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029, upload-time = "2025-10-14T04:42:12.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580, upload-time = "2025-10-14T04:42:13.549Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340, upload-time = "2025-10-14T04:42:14.892Z" },
+ { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619, upload-time = "2025-10-14T04:42:16.676Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980, upload-time = "2025-10-14T04:42:17.917Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174, upload-time = "2025-10-14T04:42:19.018Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666, upload-time = "2025-10-14T04:42:20.171Z" },
+ { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550, upload-time = "2025-10-14T04:42:21.324Z" },
+ { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721, upload-time = "2025-10-14T04:42:22.46Z" },
+ { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127, upload-time = "2025-10-14T04:42:23.712Z" },
+ { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175, upload-time = "2025-10-14T04:42:24.87Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375, upload-time = "2025-10-14T04:42:27.246Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692, upload-time = "2025-10-14T04:42:28.425Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192, upload-time = "2025-10-14T04:42:29.482Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220, upload-time = "2025-10-14T04:42:30.632Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
+]
+
[[package]]
name = "click"
version = "8.1.8"
@@ -196,6 +319,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
]
+[[package]]
+name = "cryptography"
+version = "46.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
+ { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
+ { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
+ { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
+ { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
+ { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
+ { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
+ { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
+ { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
+ { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
+ { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
+ { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" },
+]
+
[[package]]
name = "debugpy"
version = "1.8.20"
@@ -347,12 +519,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/3f/b82cd8e733a355db1abb8297afbf59ec972c00ef90bf8d4eed287958b204/flask_restx-1.3.2-py2.py3-none-any.whl", hash = "sha256:6e035496e8223668044fc45bf769e526352fd648d9e159bd631d94fd645a687b", size = 2799859, upload-time = "2025-09-23T20:34:23.055Z" },
]
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+]
+
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "zipp", marker = "python_full_version < '3.10'" },
+ { name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
@@ -520,6 +701,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
+]
+
+[[package]]
+name = "jaraco-context"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backports-tarfile", marker = "python_full_version < '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
+]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
+]
+
[[package]]
name = "jedi"
version = "0.19.2"
@@ -532,6 +749,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" },
]
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
+]
+
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -668,6 +894,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
]
+[[package]]
+name = "keyring"
+version = "25.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "importlib-metadata", marker = "python_full_version < '3.12'" },
+ { name = "jaraco-classes" },
+ { name = "jaraco-context" },
+ { name = "jaraco-functools" },
+ { name = "jeepney", marker = "sys_platform == 'linux'" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and sys_platform == 'linux'" },
+ { name = "secretstorage", version = "3.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and sys_platform == 'linux'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
+]
+
[[package]]
name = "librt"
version = "0.8.1"
@@ -778,6 +1023,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.11'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -886,6 +1162,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
]
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "more-itertools"
+version = "10.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
+]
+
[[package]]
name = "mypy"
version = "1.19.1"
@@ -1268,6 +1562,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" },
]
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
[[package]]
name = "pyzmq"
version = "27.1.0"
@@ -1391,6 +1694,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "rich"
+version = "13.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pygments" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
+]
+
[[package]]
name = "rpds-py"
version = "0.27.1"
@@ -1708,17 +2041,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" },
]
+[[package]]
+name = "secretstorage"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+dependencies = [
+ { name = "cryptography", marker = "python_full_version < '3.10'" },
+ { name = "jeepney", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" },
+]
+
+[[package]]
+name = "secretstorage"
+version = "3.5.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.11'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "cryptography", marker = "python_full_version >= '3.10'" },
+ { name = "jeepney", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
+]
+
[[package]]
name = "simple-secrets-manager"
version = "1.3.0"
source = { editable = "." }
dependencies = [
+ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
{ name = "flask-cors" },
{ name = "flask-httpauth" },
{ name = "flask-restx" },
+ { name = "keyring" },
{ name = "loguru" },
{ name = "pymongo" },
{ name = "python-dotenv" },
+ { name = "requests" },
+ { name = "rich" },
]
[package.dev-dependencies]
@@ -1731,12 +2102,16 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "click", specifier = ">=8.1.8,<9.0.0" },
{ name = "flask-cors", specifier = ">=5.0.0,<6.0.0" },
{ name = "flask-httpauth", specifier = ">4,<5" },
{ name = "flask-restx", specifier = ">=0.5.1" },
+ { name = "keyring", specifier = ">=25.6.0,<26.0.0" },
{ name = "loguru", specifier = ">=0.7.3,<0.8.0" },
{ name = "pymongo", specifier = ">4,<5" },
{ name = "python-dotenv", specifier = ">=1.1.0,<2.0.0" },
+ { name = "requests", specifier = ">=2.32.3,<3.0.0" },
+ { name = "rich", specifier = ">=13.9.4,<14.0.0" },
]
[package.metadata.requires-dev]
@@ -1861,6 +2236,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
[[package]]
name = "wcwidth"
version = "0.6.0"
From bbe8b507d772a7ca865ca7525c940324170ff0fc Mon Sep 17 00:00:00 2001
From: Krishnakanth Alagiri
Date: Mon, 23 Feb 2026 14:58:55 -0800
Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=93=9D=20Clarify=20API-only=20deplo?=
=?UTF-8?q?yment=20instructions=20in=20setup=20guide?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Updated the first-time setup guide to clarify API-only deployment instructions and added a note about using the API for sign-in.
---
docs/FIRST_TIME_SETUP.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md
index d05db1f..8e8c6eb 100644
--- a/docs/FIRST_TIME_SETUP.md
+++ b/docs/FIRST_TIME_SETUP.md
@@ -1,6 +1,6 @@
# First-Time Setup Guide
-This guide covers the first initialization of a fresh Simple Secrets Manager deployment.
+This guide walks you through initializing a fresh Simple Secrets Manager deployment using only the API. You’ll skip the front-end completely. We usually recommend the first-time user wizard in the UI, so stick to these instructions only if you're deploying in a restrictive environment where browser access isn't an option.
## Prerequisites
@@ -29,7 +29,7 @@ curl -sS -X POST "http://localhost:5000/api/onboarding/bootstrap" \
This creates the first admin account and marks onboarding complete.
-## Step 3: Sign in from UI
+## Step 3: Sign in from UI (or use the API)
- Open `http://localhost:8080`
- Use the created username/password
From bbbc1f3bf83aa83945cfd0671d5deb991e6a41ad Mon Sep 17 00:00:00 2001
From: Krishnakanth Alagiri
Date: Mon, 23 Feb 2026 15:00:56 -0800
Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=93=9D=20Update=20README=20to=20rep?=
=?UTF-8?q?lace=20'.venv/bin/ssm'=20with=20'uv=20run=20ssm'?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 3098564..4efc850 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,7 @@ Simple Secrets Manager is a lightweight, self-hosted secret manager for teams th
-## Product
-
-### What it is for
+## What it is for?
- Store secrets by `project` and `config` (`dev`, `staging`, `prod`, etc.).
- Inherit values across configs and override only where needed.
@@ -55,32 +53,34 @@ After `uv sync`, the CLI entrypoint is available at `.venv/bin/ssm`.
Configure base URL and set a token:
```bash
-.venv/bin/ssm configure --base-url http://localhost:8080/api --profile dev
-.venv/bin/ssm auth set-token --token "" --profile dev
+uv run ssm configure --base-url http://localhost:8080/api --profile dev
+uv run ssm auth set-token --token "" --profile dev
```
Set directory defaults:
```bash
-.venv/bin/ssm setup --project my-project --config dev --profile dev
+uv run ssm setup --project my-project --config dev --profile dev
```
Run commands with injected secrets:
```bash
-.venv/bin/ssm run --profile dev -- python app.py
+uv run ssm run --profile dev -- python app.py
```
Other useful commands:
```bash
-.venv/bin/ssm whoami --profile dev
-.venv/bin/ssm secrets download --profile dev --format json
-.venv/bin/ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
+uv run ssm whoami --profile dev
+uv run ssm secrets download --profile dev --format json
+uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
```
Command reference and detailed CLI behavior are documented in [`docs/CLI.md`](docs/CLI.md).
+---
+
## Contributing
### Prerequisites
From c60a257ca74d594d4d0cb402ec62720baba518eb Mon Sep 17 00:00:00 2001
From: Krishna
Date: Mon, 23 Feb 2026 15:07:38 -0800
Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=93=9D=20docs(cli):=20split=20produ?=
=?UTF-8?q?ct=20docs=20and=20adopt=20uvx-first=20usage?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactor root README into product-facing documentation only and move developer workflows into dedicated developer docs.\n\nAdd docs/DEVELOPER_GUIDE.md, update development references, and switch CLI documentation to UVX-first commands for running outside repository directories.\n\nAlso harden mypy configuration to ignore build artifacts generated during UVX package builds.
---
README.md | 119 ++++++++-------------------------------
docs/CLI.md | 55 ++++++++++--------
docs/DEVELOPER_GUIDE.md | 101 +++++++++++++++++++++++++++++++++
docs/DEVELOPMENT.md | 12 +++-
docs/FIRST_TIME_SETUP.md | 8 +--
pyproject.toml | 6 +-
6 files changed, 177 insertions(+), 124 deletions(-)
create mode 100644 docs/DEVELOPER_GUIDE.md
diff --git a/README.md b/README.md
index 4efc850..de2bac1 100644
--- a/README.md
+++ b/README.md
@@ -4,21 +4,21 @@
-
+
-Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead.
+Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need project/config-based secret organization without enterprise overhead.
-## What it is for?
+## Product Overview
- Store secrets by `project` and `config` (`dev`, `staging`, `prod`, etc.).
- Inherit values across configs and override only where needed.
- Manage values from UI or API.
- Use username/password for humans and scoped tokens for automation.
-### Quick start (Docker)
+## Quick Start (Docker)
```bash
docker compose up -d --build
@@ -30,15 +30,15 @@ Open:
- Backend API (proxy): `http://localhost:8080/api`
- Backend API (direct): `http://localhost:5000/api`
-### First-time setup
+## First-Time Setup
- On a fresh install, login shows initial setup.
- Create the first admin username/password.
-- Then sign in with username/password and start creating projects/configs/secrets.
+- Then sign in and create projects/configs/secrets.
-For full onboarding and bootstrap flow, see [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md).
+Step-by-step guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
-### Standard usage flow
+## Standard Usage Flow
1. Create a project.
2. Create one or more configs.
@@ -46,106 +46,28 @@ For full onboarding and bootstrap flow, see [`docs/FIRST_TIME_SETUP.md`](docs/FI
4. Export secrets in JSON or `.env` format when needed.
5. Create scoped tokens for services and CI/CD.
-### CLI (Doppler-like workflow)
+## CLI (UVX-first)
-After `uv sync`, the CLI entrypoint is available at `.venv/bin/ssm`.
-
-Configure base URL and set a token:
-
-```bash
-uv run ssm configure --base-url http://localhost:8080/api --profile dev
-uv run ssm auth set-token --token "" --profile dev
-```
-
-Set directory defaults:
-
-```bash
-uv run ssm setup --project my-project --config dev --profile dev
-```
-
-Run commands with injected secrets:
-
-```bash
-uv run ssm run --profile dev -- python app.py
-```
-
-Other useful commands:
-
-```bash
-uv run ssm whoami --profile dev
-uv run ssm secrets download --profile dev --format json
-uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
-```
-
-Command reference and detailed CLI behavior are documented in [`docs/CLI.md`](docs/CLI.md).
-
----
-
-## Contributing
-
-### Prerequisites
-
-- Docker + Docker Compose
-- Python + `uv`
-- Node.js + npm
-
-### Local backend setup
-
-```bash
-git clone --depth 1 https://github.com/bearlike/simple-secrets-manager
-cd simple-secrets-manager
-```
-
-Create `.env` at repository root:
-
-```bash
-CONNECTION_STRING=mongodb://username:password@mongo.hostname:27017
-TOKEN_SALT=change-me
-CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080,http://127.0.0.1:8080
-BIND_HOST=0.0.0.0
-PORT=5000
-```
-
-Run backend:
+Run the CLI from anywhere without cloning this repository:
```bash
-uv sync
-uv run python3 server.py
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help
```
-### Local frontend setup
+Configure and authenticate:
```bash
-cd frontend
-npm install
-echo "VITE_API_BASE_URL=/api" > .env.local
-npm run dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --token "" --profile dev
```
-Open `http://localhost:5173`.
-
-### Quality checks
-
-Backend:
-
-```bash
-./scripts/quality.sh check
-```
-
-Frontend:
+Run any process with injected secrets:
```bash
-cd frontend
-npm run lint
-npm run build
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py
```
-### Developer docs
-
-- Full development guide: [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md)
-- First-time setup guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
-- CLI reference: [`docs/CLI.md`](docs/CLI.md)
-- Frontend notes: [`frontend/README.md`](frontend/README.md)
+Detailed CLI reference: [`docs/CLI.md`](docs/CLI.md)
## Supplementary Reference
@@ -199,4 +121,9 @@ curl -sS -X POST "$BASE_URL/auth/tokens/v2/revoke" \
-d '{"token_id":""}'
```
-Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md)
+## Documentation Index
+
+- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
+- CLI reference: [`docs/CLI.md`](docs/CLI.md)
+- Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md)
+- Developer docs: [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md)
diff --git a/docs/CLI.md b/docs/CLI.md
index e6160a3..ffb4389 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -2,19 +2,30 @@
Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Rich.
-## Install and verify
+## Install and run with UVX (recommended)
-From repository root:
+Run from anywhere without cloning this repository:
```bash
-uv sync
-uv run ssm --help
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help
+```
+
+Pin to a release tag:
+
+```bash
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm --help
```
-If you prefer direct binary execution after sync:
+Pin to a commit SHA:
```bash
-.venv/bin/ssm --help
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm --help
+```
+
+Local development fallback (inside this repo):
+
+```bash
+uv run ssm --help
```
## Resolution order (DRY/KISS)
@@ -56,7 +67,7 @@ The CLI resolves values using one deterministic order.
- Credential fallback: `~/.config/ssm/credentials.json` (mode `0600`)
- Cache: `~/.cache/ssm/secrets/.json`
-Test overrides are supported via env vars:
+Test overrides via env vars:
- `SSM_GLOBAL_CONFIG_FILE`
- `SSM_LOCAL_CONFIG_FILE`
@@ -70,26 +81,26 @@ Test overrides are supported via env vars:
Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token.
```bash
-uv run ssm configure --base-url http://localhost:8080/api --profile dev
-uv run ssm login --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev
```
### Service/personal token (recommended for CI or automation)
```bash
-uv run ssm auth set-token --profile dev --token ""
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token ""
```
### Logout
```bash
-uv run ssm logout --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --profile dev
```
Clear all local token records:
```bash
-uv run ssm logout --all-profiles
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --all-profiles
```
## Project/config setup
@@ -97,7 +108,7 @@ uv run ssm logout --all-profiles
Set local defaults for current directory:
```bash
-uv run ssm setup --project my-project --config dev --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm setup --project my-project --config dev --profile dev
```
## Core commands
@@ -105,7 +116,7 @@ uv run ssm setup --project my-project --config dev --profile dev
### Run command with injected secrets
```bash
-uv run ssm run --profile dev -- python app.py
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py
```
Useful flags:
@@ -118,8 +129,8 @@ Useful flags:
### Download secrets
```bash
-uv run ssm secrets download --profile dev --format json
-uv run ssm secrets download --profile dev --format env
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format json
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format env
```
Note: `.env` output fails when any value contains a newline. Use JSON in that case.
@@ -127,7 +138,7 @@ Note: `.env` output fails when any value contains a newline. Use JSON in that ca
### Mount secrets to FIFO
```bash
-uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
```
- Creates FIFO with `0600` permissions.
@@ -136,7 +147,7 @@ uv run ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format jso
### Session validation
```bash
-uv run ssm whoami --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev
```
`whoami` validates token by calling `GET /projects` and reports visible scope behavior.
@@ -146,19 +157,19 @@ uv run ssm whoami --profile dev
List profiles:
```bash
-uv run ssm profile list
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile list
```
Activate profile:
```bash
-uv run ssm profile use dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile use dev
```
Set profile defaults:
```bash
-uv run ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
```
## Exit behavior
@@ -167,7 +178,7 @@ uv run ssm profile set dev --base-url http://localhost:8080/api --project my-pro
- Configuration/auth errors generally use exit code `2`.
- Offline cache miss uses exit code `4`.
-## Local quality checks
+## Local quality checks (for maintainers)
```bash
./scripts/quality.sh check
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
new file mode 100644
index 0000000..5755b3e
--- /dev/null
+++ b/docs/DEVELOPER_GUIDE.md
@@ -0,0 +1,101 @@
+# Developer Guide
+
+This document contains developer-facing setup and maintenance workflows.
+
+## Scope
+
+- Backend API lives at repository root.
+- Frontend admin console lives in `frontend/`.
+- CLI package lives in `ssm_cli/`.
+
+## Prerequisites
+
+- Docker + Docker Compose
+- Python + `uv`
+- Node.js + npm
+
+## Clone and bootstrap
+
+```bash
+git clone --depth 1 https://github.com/bearlike/simple-secrets-manager
+cd simple-secrets-manager
+uv sync
+```
+
+## Local backend setup
+
+Create `.env` at repository root:
+
+```bash
+CONNECTION_STRING=mongodb://username:password@mongo.hostname:27017
+TOKEN_SALT=change-me
+CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080,http://127.0.0.1:8080
+BIND_HOST=0.0.0.0
+PORT=5000
+```
+
+Start backend:
+
+```bash
+uv run python3 server.py
+```
+
+## Local frontend setup
+
+```bash
+cd frontend
+npm install
+echo "VITE_API_BASE_URL=/api" > .env.local
+npm run dev
+```
+
+## Local CLI setup
+
+Run CLI from source during development:
+
+```bash
+uv run ssm --help
+```
+
+## Full stack via Docker Compose
+
+```bash
+docker compose up -d --build
+```
+
+Endpoints:
+
+- Frontend: `http://localhost:8080`
+- Backend API proxy: `http://localhost:8080/api`
+- Backend API direct: `http://localhost:5000/api`
+
+## Quality gates
+
+Backend:
+
+```bash
+./scripts/quality.sh check
+```
+
+Frontend:
+
+```bash
+cd frontend
+npm run lint
+npm run build
+```
+
+## Smoke checks
+
+CLI smoke check against Docker stack:
+
+```bash
+uv run ssm configure --base-url http://localhost:8080/api --profile dev
+uv run ssm whoami --profile dev
+```
+
+## Additional references
+
+- Development details: [`docs/DEVELOPMENT.md`](DEVELOPMENT.md)
+- CLI reference (user-facing): [`docs/CLI.md`](CLI.md)
+- Frontend-specific notes: [`frontend/README.md`](../frontend/README.md)
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 46dab97..4d18a2c 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -1,4 +1,8 @@
-# Development Guide
+# Development Details
+
+This document is a deeper engineering reference.
+
+For day-to-day developer onboarding, use [`docs/DEVELOPER_GUIDE.md`](DEVELOPER_GUIDE.md) first.
## Monorepo overview
@@ -93,6 +97,12 @@ uv run ssm --help
Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md).
+UVX distribution smoke check (outside repository path):
+
+```bash
+uvx --from /absolute/path/to/Simple-Secrets-Manager ssm --help
+```
+
## Integration smoke checks
Backend health endpoint (Swagger index):
diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md
index 8e8c6eb..6099ae3 100644
--- a/docs/FIRST_TIME_SETUP.md
+++ b/docs/FIRST_TIME_SETUP.md
@@ -40,20 +40,20 @@ This creates the first admin account and marks onboarding complete.
### Option A: Login from CLI with username/password
```bash
-uv run ssm configure --base-url http://localhost:8080/api --profile dev
-uv run ssm login --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev
```
### Option B: Set existing token
```bash
-uv run ssm auth set-token --profile dev --token ""
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token ""
```
## Step 5: Verify access
```bash
-uv run ssm whoami --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev
```
## Common issues
diff --git a/pyproject.toml b/pyproject.toml
index 87ee39e..e782897 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,4 +55,8 @@ select = ["E", "F"]
[tool.mypy]
python_version = "3.9"
ignore_missing_imports = true
-exclude = ["^\\.venv/"]
+exclude = [
+ "^\\.venv/",
+ "^build/",
+ "^simple_secrets_manager\\.egg-info/",
+]
From 6ea3283b32578b5db4b63ca4b6b39fd843a7e478 Mon Sep 17 00:00:00 2001
From: Krishna
Date: Mon, 23 Feb 2026 15:16:37 -0800
Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=A8=20feat(cli):=20add=20ssm-cli=20?=
=?UTF-8?q?uvx=20entrypoint=20and=20docs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expose as a first-class console script while keeping as a backward-compatible alias.\n\nUpdate product, CLI, onboarding, and developer documentation to use UVX with so users can run the CLI outside repository directories.\n\nKeep mypy excludes for generated build artifacts to avoid duplicate-module failures during local packaging workflows.
---
README.md | 8 ++++----
docs/CLI.md | 38 ++++++++++++++++++++------------------
docs/DEVELOPER_GUIDE.md | 6 +++---
docs/DEVELOPMENT.md | 8 ++++----
docs/FIRST_TIME_SETUP.md | 8 ++++----
pyproject.toml | 1 +
6 files changed, 36 insertions(+), 33 deletions(-)
diff --git a/README.md b/README.md
index de2bac1..6ccd455 100644
--- a/README.md
+++ b/README.md
@@ -51,20 +51,20 @@ Step-by-step guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
Run the CLI from anywhere without cloning this repository:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help
```
Configure and authenticate:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --token "" --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --token "" --profile dev
```
Run any process with injected secrets:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py
```
Detailed CLI reference: [`docs/CLI.md`](docs/CLI.md)
diff --git a/docs/CLI.md b/docs/CLI.md
index ffb4389..80f1737 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -7,27 +7,29 @@ Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Ri
Run from anywhere without cloning this repository:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm --help
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help
```
Pin to a release tag:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm --help
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm-cli --help
```
Pin to a commit SHA:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm --help
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm-cli --help
```
Local development fallback (inside this repo):
```bash
-uv run ssm --help
+uv run ssm-cli --help
```
+`ssm` remains available as a backward-compatible alias.
+
## Resolution order (DRY/KISS)
The CLI resolves values using one deterministic order.
@@ -81,26 +83,26 @@ Test overrides via env vars:
Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token.
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev
```
### Service/personal token (recommended for CI or automation)
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token ""
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token ""
```
### Logout
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --profile dev
```
Clear all local token records:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout --all-profiles
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --all-profiles
```
## Project/config setup
@@ -108,7 +110,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm logout
Set local defaults for current directory:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm setup --project my-project --config dev --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli setup --project my-project --config dev --profile dev
```
## Core commands
@@ -116,7 +118,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm setup
### Run command with injected secrets
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm run --profile dev -- python app.py
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py
```
Useful flags:
@@ -129,8 +131,8 @@ Useful flags:
### Download secrets
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format json
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets download --profile dev --format env
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format json
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format env
```
Note: `.env` output fails when any value contains a newline. Use JSON in that case.
@@ -138,7 +140,7 @@ Note: `.env` output fails when any value contains a newline. Use JSON in that ca
### Mount secrets to FIFO
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
```
- Creates FIFO with `0600` permissions.
@@ -147,7 +149,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm secret
### Session validation
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev
```
`whoami` validates token by calling `GET /projects` and reports visible scope behavior.
@@ -157,19 +159,19 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami
List profiles:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile list
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile list
```
Activate profile:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile use dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile use dev
```
Set profile defaults:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
```
## Exit behavior
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 5755b3e..fbae522 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -54,7 +54,7 @@ npm run dev
Run CLI from source during development:
```bash
-uv run ssm --help
+uv run ssm-cli --help
```
## Full stack via Docker Compose
@@ -90,8 +90,8 @@ npm run build
CLI smoke check against Docker stack:
```bash
-uv run ssm configure --base-url http://localhost:8080/api --profile dev
-uv run ssm whoami --profile dev
+uv run ssm-cli configure --base-url http://localhost:8080/api --profile dev
+uv run ssm-cli whoami --profile dev
```
## Additional references
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 4d18a2c..fbdfb55 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -92,7 +92,7 @@ CLI:
```bash
uv sync
-uv run ssm --help
+uv run ssm-cli --help
```
Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md).
@@ -100,7 +100,7 @@ Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md).
UVX distribution smoke check (outside repository path):
```bash
-uvx --from /absolute/path/to/Simple-Secrets-Manager ssm --help
+uvx --from /absolute/path/to/Simple-Secrets-Manager ssm-cli --help
```
## Integration smoke checks
@@ -120,8 +120,8 @@ curl -sS -I http://localhost:8080
CLI smoke check:
```bash
-uv run ssm configure --base-url http://localhost:8080/api --profile dev
-uv run ssm whoami --profile dev
+uv run ssm-cli configure --base-url http://localhost:8080/api --profile dev
+uv run ssm-cli whoami --profile dev
```
## CI publish flow
diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md
index 6099ae3..6aea618 100644
--- a/docs/FIRST_TIME_SETUP.md
+++ b/docs/FIRST_TIME_SETUP.md
@@ -40,20 +40,20 @@ This creates the first admin account and marks onboarding complete.
### Option A: Login from CLI with username/password
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm configure --base-url http://localhost:8080/api --profile dev
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm login --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev
```
### Option B: Set existing token
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm auth set-token --profile dev --token ""
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token ""
```
## Step 5: Verify access
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm whoami --profile dev
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev
```
## Common issues
diff --git a/pyproject.toml b/pyproject.toml
index e782897..f5a6d57 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,7 @@ build-backend = "setuptools.build_meta"
[project.scripts]
ssm = "ssm_cli.main:cli"
+ssm-cli = "ssm_cli.main:cli"
[tool.setuptools]
packages = ["Access", "Api", "Engines", "ssm_cli"]
From 557dcf872b4bdc6d809ee055d70084fdd0cc060a Mon Sep 17 00:00:00 2001
From: Krishna
Date: Mon, 23 Feb 2026 15:37:13 -0800
Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=93=9D=20docs(readme):=20simplify?=
=?UTF-8?q?=20product=20docs=20and=20remove=20branch=20assumptions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Restructure product and supporting docs around install-once CLI usage with uv tools, plus clear auth/use flows for backend access.\n\nMake documentation branch-agnostic by removing feature-branch references and defaulting to mainline-safe install instructions.\n\nUpdate root README, CLI reference, first-time setup, developer guides, Docker runtime docs, and frontend README for consistency.
---
README.md | 110 ++++++++--------------
docs/CLI.md | 193 +++++++++++++++++----------------------
docs/DEVELOPER_GUIDE.md | 14 +++
docs/DEVELOPMENT.md | 13 ++-
docs/FIRST_TIME_SETUP.md | 61 ++++++++-----
docs/README_dockerhub.md | 8 ++
frontend/README.md | 2 +-
7 files changed, 191 insertions(+), 210 deletions(-)
diff --git a/README.md b/README.md
index 6ccd455..eef991b 100644
--- a/README.md
+++ b/README.md
@@ -2,128 +2,94 @@
-
+
-
-Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need project/config-based secret organization without enterprise overhead.
+Simple Secrets Manager is a lightweight, self-hosted secrets platform with:
-
+- Backend API
+- Frontend Admin Console
+- `ssm-cli` command-line client
-## Product Overview
+## Install the Product (Backend + Frontend Bundle)
-- Store secrets by `project` and `config` (`dev`, `staging`, `prod`, etc.).
-- Inherit values across configs and override only where needed.
-- Manage values from UI or API.
-- Use username/password for humans and scoped tokens for automation.
-
-## Quick Start (Docker)
+Start the full stack with Docker Compose:
```bash
docker compose up -d --build
```
-Open:
+Endpoints:
- Frontend: `http://localhost:8080`
-- Backend API (proxy): `http://localhost:8080/api`
-- Backend API (direct): `http://localhost:5000/api`
+- Backend API via proxy: `http://localhost:8080/api`
+- Backend API direct: `http://localhost:5000/api`
## First-Time Setup
-- On a fresh install, login shows initial setup.
-- Create the first admin username/password.
-- Then sign in and create projects/configs/secrets.
-
-Step-by-step guide: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
+On a fresh install:
-## Standard Usage Flow
+1. Open `http://localhost:8080`
+2. Complete initial setup (create first admin user)
+3. Sign in and create projects/configs/secrets
-1. Create a project.
-2. Create one or more configs.
-3. Add secrets manually or import a `.env` file from the config page.
-4. Export secrets in JSON or `.env` format when needed.
-5. Create scoped tokens for services and CI/CD.
+API-only bootstrap steps are in [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md).
-## CLI (UVX-first)
+## Install CLI Once, Run Anywhere
-Run the CLI from anywhere without cloning this repository:
+Install `ssm-cli` globally via uv:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help
+uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git
+uv tool update-shell
+ssm-cli --help
```
-Configure and authenticate:
+If `ssm-cli` is not found, ensure uv's tool bin is on `PATH`:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --token "" --profile dev
+export PATH="$(uv tool dir --bin):$PATH"
```
-Run any process with injected secrets:
-
-```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py
-```
-
-Detailed CLI reference: [`docs/CLI.md`](docs/CLI.md)
-
-## Supplementary Reference
+## Authenticate CLI to Your Backend
-### Environment variables
-
-| Variable | Description |
-|----------|-------------|
-| `CONNECTION_STRING` | MongoDB connection string. |
-| `TOKEN_SALT` | Salt used before hashing API tokens. |
-| `CORS_ORIGINS` | Comma-separated allowed origins for direct backend access on port `5000`. |
-| `BIND_HOST` | Flask bind host (default `0.0.0.0`). |
-| `PORT` | Flask port (default `5000`). |
-| `VITE_API_BASE_URL` | Frontend API base URL override (`frontend/.env.local`), defaults to `/api`. |
-
-### API examples
-
-Set variables:
+Set backend URL and token:
```bash
-export BASE_URL="http://localhost:5000/api"
-export TOKEN=""
+ssm-cli configure --base-url http://localhost:8080/api --profile dev
+ssm-cli auth set-token --token "" --profile dev
```
-List projects:
+Or login with username/password:
```bash
-curl -sS "$BASE_URL/projects" \
- -H "Authorization: Bearer $TOKEN"
+ssm-cli login --profile dev
```
-List configs in a project:
+## Use the Application from CLI
+
+Inject secrets into a process:
```bash
-curl -sS "$BASE_URL/projects/my-project/configs" \
- -H "Authorization: Bearer $TOKEN"
+ssm-cli run --profile dev -- python app.py
```
-Export secrets (with inherited values and metadata):
+Download secrets:
```bash
-curl -sS "$BASE_URL/projects/my-project/configs/dev/secrets?format=json&include_parent=true&include_meta=true" \
- -H "Authorization: Bearer $TOKEN"
+ssm-cli secrets download --profile dev --format json
```
-Revoke a token by `token_id`:
+Check active CLI session:
```bash
-curl -sS -X POST "$BASE_URL/auth/tokens/v2/revoke" \
- -H "Authorization: Bearer $TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"token_id":""}'
+ssm-cli whoami --profile dev
```
-## Documentation Index
+## Documentation
-- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
- CLI reference: [`docs/CLI.md`](docs/CLI.md)
+- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
- Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md)
- Developer docs: [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md)
diff --git a/docs/CLI.md b/docs/CLI.md
index 80f1737..20f8121 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -1,186 +1,157 @@
# CLI Reference
-Simple Secrets Manager ships with a Doppler-like CLI implemented with Click + Rich.
+`ssm-cli` is the command-line client for Simple Secrets Manager.
-## Install and run with UVX (recommended)
+## 1) Install Once (Recommended)
-Run from anywhere without cloning this repository:
+Install globally with uv tools:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help
+uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git
+uv tool update-shell
+ssm-cli --help
```
-Pin to a release tag:
+If command is not found in a new shell:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm-cli --help
+export PATH="$(uv tool dir --bin):$PATH"
```
-Pin to a commit SHA:
+Upgrade later:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@ ssm-cli --help
+uv tool upgrade simple-secrets-manager
```
-Local development fallback (inside this repo):
+Uninstall:
```bash
-uv run ssm-cli --help
+uv tool uninstall simple-secrets-manager
```
-`ssm` remains available as a backward-compatible alias.
-
-## Resolution order (DRY/KISS)
-
-The CLI resolves values using one deterministic order.
-
-### Base URL
-
-1. `--base-url`
-2. `SSM_BASE_URL`
-3. active profile in global config
-4. global `base_url`
+## 2) Ephemeral Run (No Install)
-### Project and config
+Use UVX directly when you do not want a persistent install:
-1. `--project` / `--config`
-2. `SSM_PROJECT` / `SSM_CONFIG`
-3. local directory config (`.ssm/config.json`)
-4. active profile defaults in global config
-
-### Profile
-
-1. `--profile`
-2. `SSM_PROFILE`
-3. local directory profile (`.ssm/config.json`)
-4. global active profile
-5. `default`
-
-### Token
-
-1. `SSM_TOKEN`
-2. stored token for `@` in keyring
-3. stored token in file fallback
-
-## Config and credential files
-
-- Global config: `~/.config/ssm/config.json`
-- Local config: `/.ssm/config.json`
-- Credential fallback: `~/.config/ssm/credentials.json` (mode `0600`)
-- Cache: `~/.cache/ssm/secrets/.json`
+```bash
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli --help
+```
-Test overrides via env vars:
+Pin to tag:
-- `SSM_GLOBAL_CONFIG_FILE`
-- `SSM_LOCAL_CONFIG_FILE`
-- `SSM_CREDENTIALS_FILE`
-- `SSM_CACHE_DIR`
-
-## Authentication
+```bash
+uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.3.0 ssm-cli --help
+```
-### Login (username/password)
+## 3) Quick Start
-Uses legacy `GET /api/auth/tokens/` with HTTP Basic auth and stores returned token.
+Configure backend URL:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev
+ssm-cli configure --base-url http://localhost:8080/api --profile dev
```
-### Service/personal token (recommended for CI or automation)
+Authenticate:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token ""
+ssm-cli login --profile dev
+# or
+ssm-cli auth set-token --profile dev --token ""
```
-### Logout
+Set default project/config for current directory:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --profile dev
+ssm-cli setup --project my-project --config dev --profile dev
```
-Clear all local token records:
+Run your app with injected secrets:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli logout --all-profiles
+ssm-cli run --profile dev -- python app.py
```
-## Project/config setup
+## Core Commands
-Set local defaults for current directory:
+Download secrets:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli setup --project my-project --config dev --profile dev
+ssm-cli secrets download --profile dev --format json
+ssm-cli secrets download --profile dev --format env
```
-## Core commands
-
-### Run command with injected secrets
+Mount secrets to FIFO:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli run --profile dev -- python app.py
+ssm-cli secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
```
-Useful flags:
-
-- `--offline`: cache-only
-- `--cache-ttl `: cache freshness window
-- `--print-env`: print resolved keys
-- `--show-values`: print values (only with `--print-env`)
-
-### Download secrets
+Validate current session:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format json
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets download --profile dev --format env
+ssm-cli whoami --profile dev
```
-Note: `.env` output fails when any value contains a newline. Use JSON in that case.
-
-### Mount secrets to FIFO
+Profile management:
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli secrets mount --profile dev --path /tmp/ssm-secrets.fifo --format json
+ssm-cli profile list
+ssm-cli profile use dev
+ssm-cli profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
```
-- Creates FIFO with `0600` permissions.
-- Writes one payload then removes FIFO unless `--keep` is used.
+## Resolution Order
-### Session validation
+### Base URL
-```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev
-```
+1. `--base-url`
+2. `SSM_BASE_URL`
+3. active profile in global config
+4. global `base_url`
-`whoami` validates token by calling `GET /projects` and reports visible scope behavior.
+### Project and config
-## Profiles
+1. `--project` / `--config`
+2. `SSM_PROJECT` / `SSM_CONFIG`
+3. local directory config (`.ssm/config.json`)
+4. active profile defaults in global config
-List profiles:
+### Profile
-```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile list
-```
+1. `--profile`
+2. `SSM_PROFILE`
+3. local directory profile (`.ssm/config.json`)
+4. global active profile
+5. `default`
-Activate profile:
+### Token
-```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile use dev
-```
+1. `SSM_TOKEN`
+2. stored token for `@` in keyring
+3. stored token in file fallback
-Set profile defaults:
+## File Locations
-```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli profile set dev --base-url http://localhost:8080/api --project my-project --config dev --activate
-```
+- Global config: `~/.config/ssm/config.json`
+- Local config: `/.ssm/config.json`
+- Credential fallback: `~/.config/ssm/credentials.json` (`0600`)
+- Cache: `~/.cache/ssm/secrets/.json`
+
+Test overrides via env vars:
+
+- `SSM_GLOBAL_CONFIG_FILE`
+- `SSM_LOCAL_CONFIG_FILE`
+- `SSM_CREDENTIALS_FILE`
+- `SSM_CACHE_DIR`
-## Exit behavior
+## Exit Behavior
-- `run` exits with the child process exit code.
-- Configuration/auth errors generally use exit code `2`.
-- Offline cache miss uses exit code `4`.
+- `run` exits with child process exit code.
+- Configuration/auth errors typically exit `2`.
+- Offline cache miss exits `4`.
-## Local quality checks (for maintainers)
+## Maintainer Checks
```bash
./scripts/quality.sh check
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index fbae522..06be599 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -57,6 +57,20 @@ Run CLI from source during development:
uv run ssm-cli --help
```
+Install CLI globally for manual QA outside repo:
+
+```bash
+uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git
+uv tool update-shell
+ssm-cli --help
+```
+
+If needed:
+
+```bash
+export PATH="$(uv tool dir --bin):$PATH"
+```
+
## Full stack via Docker Compose
```bash
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index fbdfb55..2cf43be 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -97,7 +97,15 @@ uv run ssm-cli --help
Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md).
-UVX distribution smoke check (outside repository path):
+Global CLI install smoke check:
+
+```bash
+uv tool install /absolute/path/to/Simple-Secrets-Manager
+uv tool update-shell
+ssm-cli --help
+```
+
+UVX distribution smoke check (ephemeral, no install):
```bash
uvx --from /absolute/path/to/Simple-Secrets-Manager ssm-cli --help
@@ -128,6 +136,7 @@ uv run ssm-cli whoami --profile dev
Container publishing is handled by `.github/workflows/ci.yml`.
-- Push to `main` or `feat/v1.3.0` with container/app changes triggers build+push to GHCR.
+- Push to `main` with container/app changes triggers build+push to GHCR.
+- Other branch pushes produce branch-ref tags and short SHA tags.
- Tag push `vX.Y.Z` additionally publishes semantic tags.
- Manual dispatch can publish an extra custom tag.
diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md
index 6aea618..21664d4 100644
--- a/docs/FIRST_TIME_SETUP.md
+++ b/docs/FIRST_TIME_SETUP.md
@@ -1,11 +1,12 @@
# First-Time Setup Guide
-This guide walks you through initializing a fresh Simple Secrets Manager deployment using only the API. You’ll skip the front-end completely. We usually recommend the first-time user wizard in the UI, so stick to these instructions only if you're deploying in a restrictive environment where browser access isn't an option.
+This guide initializes a fresh Simple Secrets Manager deployment.
## Prerequisites
-- Backend reachable at `http://localhost:5000/api` or via proxy at `http://localhost:8080/api`
-- MongoDB configured and reachable by the backend
+- Backend reachable at `http://localhost:5000/api` or `http://localhost:8080/api`
+- MongoDB configured and reachable by backend
+- `uv` installed if you want CLI access
## Step 1: Check onboarding state
@@ -13,7 +14,7 @@ This guide walks you through initializing a fresh Simple Secrets Manager deploym
curl -sS http://localhost:5000/api/onboarding/status
```
-Expected on a fresh install:
+Expected on fresh install:
```json
{"isInitialized": false, "state": "not_initialized"}
@@ -27,44 +28,56 @@ curl -sS -X POST "http://localhost:5000/api/onboarding/bootstrap" \
-d '{"username":"admin","password":"Str0ng!Passw0rd","issueToken":true}'
```
-This creates the first admin account and marks onboarding complete.
-
-## Step 3: Sign in from UI (or use the API)
+## Step 3: Sign in from UI
- Open `http://localhost:8080`
-- Use the created username/password
-- Create projects/configs/secrets in the admin console
+- Sign in with created username/password
+- Create projects/configs/secrets
+
+## Step 4: Install CLI (once)
+
+```bash
+uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git
+uv tool update-shell
+ssm-cli --help
+```
+
+If needed:
+
+```bash
+export PATH="$(uv tool dir --bin):$PATH"
+```
-## Step 4: Acquire token for API/CLI use
+## Step 5: Authenticate CLI
-### Option A: Login from CLI with username/password
+Option A: Login with username/password
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli configure --base-url http://localhost:8080/api --profile dev
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli login --profile dev
+ssm-cli configure --base-url http://localhost:8080/api --profile dev
+ssm-cli login --profile dev
```
-### Option B: Set existing token
+Option B: Use an existing token
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli auth set-token --profile dev --token ""
+ssm-cli auth set-token --profile dev --token ""
```
-## Step 5: Verify access
+## Step 6: Verify access
```bash
-uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli whoami --profile dev
+ssm-cli whoami --profile dev
```
## Common issues
-- `System already initialized`: bootstrap was already completed.
-- `Missing API token`: login or set token before calling protected endpoints.
-- `Missing scope: `: token does not include required action scope.
-- CLI `env` export fails for multiline values: use JSON format instead.
+- `System already initialized`: bootstrap already completed.
+- `Missing API token`: login or set token first.
+- `Missing scope: `: token lacks required scope.
+- `.env` export fails for multiline values: use JSON format.
## Security notes
-- Tokens should be scoped for least privilege.
-- Prefer service tokens for CI/CD and machine workloads.
-- Rotate/revoke tokens regularly via `/api/auth/tokens/v2/revoke`.
+- Scope tokens with least privilege.
+- Prefer service tokens for CI/CD.
+- Rotate/revoke tokens via `/api/auth/tokens/v2/revoke`.
diff --git a/docs/README_dockerhub.md b/docs/README_dockerhub.md
index fffe212..311b853 100644
--- a/docs/README_dockerhub.md
+++ b/docs/README_dockerhub.md
@@ -40,6 +40,14 @@ Open:
- backend via proxy: `http://localhost:8080/api`
- backend direct: `http://localhost:5000/api`
+CLI from anywhere:
+
+```bash
+uv tool install git+https://github.com/bearlike/Simple-Secrets-Manager.git
+uv tool update-shell
+ssm-cli --help
+```
+
## Minimal compose example
```yaml
diff --git a/frontend/README.md b/frontend/README.md
index 22ea406..7c9cd7d 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -50,7 +50,7 @@ All API calls are made relative to that base and include `Authorization: Bearer
## Local Backend + Frontend Workflow
-1. Start `Simple-Secrets-Manager` backend (branch `feat/v1.3.0`) on `localhost:5000`.
+1. Start `Simple-Secrets-Manager` backend on `localhost:5000`.
2. Confirm backend routes are available under `/api`.
3. Start this frontend with `npm run dev`.
4. In browser, log in with a valid token and verify:
From 5011d72d171adbffcf7a46216bad9201aa847a61 Mon Sep 17 00:00:00 2001
From: Krishna
Date: Mon, 23 Feb 2026 15:41:16 -0800
Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=93=9D=20docs(product):=20add=20CLI?=
=?UTF-8?q?=20and=20Docker=20update=20instructions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Document how to update an existing ssm-cli installation () and how to force reinstall from Git when needed.\n\nAdd explicit Docker Compose update instructions for both prebuilt-image deployments ( + ) and source rebuild deployments (Already up to date. + ).
---
README.md | 28 ++++++++++++++++++++++++++++
docs/CLI.md | 6 ++++++
docs/FIRST_TIME_SETUP.md | 6 ++++++
docs/README_dockerhub.md | 16 ++++++++++++++++
4 files changed, 56 insertions(+)
diff --git a/README.md b/README.md
index eef991b..70382ec 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,18 @@ If `ssm-cli` is not found, ensure uv's tool bin is on `PATH`:
export PATH="$(uv tool dir --bin):$PATH"
```
+Already installed? Update to latest:
+
+```bash
+uv tool upgrade simple-secrets-manager
+```
+
+If you installed from Git and want a fresh reinstall:
+
+```bash
+uv tool install --force git+https://github.com/bearlike/Simple-Secrets-Manager.git
+```
+
## Authenticate CLI to Your Backend
Set backend URL and token:
@@ -93,3 +105,19 @@ ssm-cli whoami --profile dev
- First-time setup: [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md)
- Container runtime reference: [`docs/README_dockerhub.md`](docs/README_dockerhub.md)
- Developer docs: [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md)
+
+## Update Existing Deployment
+
+If you run from this repository source:
+
+```bash
+git pull
+docker compose up -d --build
+```
+
+If you run prebuilt images only:
+
+```bash
+docker compose pull
+docker compose up -d
+```
diff --git a/docs/CLI.md b/docs/CLI.md
index 20f8121..93467c2 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -24,6 +24,12 @@ Upgrade later:
uv tool upgrade simple-secrets-manager
```
+If installed from Git and you want a fresh reinstall:
+
+```bash
+uv tool install --force git+https://github.com/bearlike/Simple-Secrets-Manager.git
+```
+
Uninstall:
```bash
diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md
index 21664d4..0b28929 100644
--- a/docs/FIRST_TIME_SETUP.md
+++ b/docs/FIRST_TIME_SETUP.md
@@ -42,6 +42,12 @@ uv tool update-shell
ssm-cli --help
```
+If already installed, update:
+
+```bash
+uv tool upgrade simple-secrets-manager
+```
+
If needed:
```bash
diff --git a/docs/README_dockerhub.md b/docs/README_dockerhub.md
index 311b853..bbb3a23 100644
--- a/docs/README_dockerhub.md
+++ b/docs/README_dockerhub.md
@@ -40,6 +40,22 @@ Open:
- backend via proxy: `http://localhost:8080/api`
- backend direct: `http://localhost:5000/api`
+## Update Existing Docker Deployment
+
+If using prebuilt images:
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+If running from source and rebuilding locally:
+
+```bash
+git pull
+docker compose up -d --build
+```
+
CLI from anywhere:
```bash
From 4dbb24b08acb9e1dbd036ebb7dbb606a2e73cfe6 Mon Sep 17 00:00:00 2001
From: Krishna
Date: Mon, 23 Feb 2026 15:42:40 -0800
Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=93=9D=20docs(agents):=20add=20work?=
=?UTF-8?q?flow=20and=20commit=20guidelines?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add repository knowledge notes plus conventional commit workflow guidance to AGENTS.md.
---
AGENTS.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 103 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index 339e6d2..d94ed3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,3 +26,106 @@ This is a monorepo with:
- Frontend: `http://localhost:8080`
- Backend API via proxy: `http://localhost:8080/api`
- Backend API direct: `http://localhost:5000/api`
+
+## Knowledge
+
+- When you have access to MCP tools like DeepWiki, use them to investigate the `bearlike/Simple-Secrets-Manager` repository directly.
+ - Ask targeted questions about the codebase to understand how it works. You can use DeepWiki for any open source repository.
+ - Focus specifically on the open-source, publicly available GitHub repository. This lets you dig into the implementation details without guessing.
+- Always test and lint the codebase after changes.
+- Playwright MCP tool, when accessible, can be used for testing front-end components and changes.
+- Always scan related components to ensure consistency. Keep things stupid simple (KISS) and don't repeat yourself (DRY). This prevents code bloat. We need to avoid overengineering.
+
+
+## Conventional Commit Guidelines
+
+Only use when asked to commit.
+
+Commit Message Format
+```
+():
+empty line as separator
+
+empty line as separator
+
+```
+
+Type Reference
+--------------
+| Type | Title | Emoji | Description | Example Scopes (non-exaustive) |
+| --- | --- | --- | --- | --- |
+| build | Builds | 🏗️ | Changes that affect the build system or external dependencies | gulp, broccoli, npm |
+| chore | Chores | 🔧 | Other changes that don't modify src or test files | scripts, config |
+| ci | Continuous Integrations | 👷 | Changes to our CI configuration files and scripts | Travis, Circle, BrowserStack, SauceLabs,github actions, husky |
+| docs | Documentation | 📝 | Documentation only changes | README, API |
+| feat | Features | ✨ | A new feature | user, payment, gallery |
+| fix | Bug Fixes | 🐛 | A bug fix | auth, data |
+| perf | Performance Improvements | ⚡️ | A code change that improves performance | query, cache |
+| refactor | Code Refactoring | ♻️ | A code change that neither fixes a bug nor adds a feature | utils, helpers |
+| revert | Reverts | ⏪️ | Reverts a previous commit | query, utils, |
+| style | Styles | 💄 | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) | formatting |
+| test | Tests | ✅ | Adding missing tests or correcting existing tests | unit, e2e |
+| i18n | | 🌐 | Internationalization | locale, translation |
+
+### Workflow
+
+**Follow these steps:**
+
+1. Run `git status` to review changed files.
+2. Run `git diff` or `git diff --cached` to inspect changes.
+3. Stage your changes with `git add `.
+4. Construct your commit message using the following XML structure.
+5. After generating your commit message, Copilot will automatically run the following command in your integrated terminal (no confirmation needed):
+
+```bash
+git commit -m "type(scope): description"
+```
+
+6. Just execute this prompt and Copilot will handle the commit for you in the terminal.
+
+### Commit Message Structure
+
+```xml
+
+ feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert
+ (api|frontend|cli|...) What core project/lib is being worked on
+ A short, imperative summary of the change.
+ (optional: more detailed explanation)
+
+
+```
+
+### Examples
+
+```xml
+
+ ✨ feat(api): add ability to parse arrays
+ 🐛 fix(frontend): correct button alignment
+ 📝 chore(docs): update README with usage instructions
+ ♻️ refactor(database): improve performance of data processing
+ 🔧 chore(api): update dependencies
+
+```
+
+### Validation
+
+```xml
+
+ ✨| 🐛 | ⚡️|🚨 |etc
+ Must be one of the allowed types. See https://www.conventionalcommits.org/en/v1.0.0/#specification
+ Optional, but recommended for clarity.
+ Required. Use the imperative mood (e.g., "add", not "added").
+ Optional. Use for additional context.
+
+
+```
+
+### Final Step
+
+```xml
+
+ git commit -m "type(scope): description"
+ Replace with your constructed message. Include body and footer if needed.
+
+```
+
From e8e54057b22a92cc11ca776d95e4adc12e3ad700 Mon Sep 17 00:00:00 2001
From: Krishnakanth Alagiri
Date: Mon, 23 Feb 2026 15:55:29 -0800
Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=93=9D=20Revise=20README=20for=20be?=
=?UTF-8?q?tter=20clarity=20and=20structure?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Updated README to improve clarity and organization of content, including sections on getting started, CLI installation, and contributing.
---
README.md | 47 ++++++++++++++++++++++++++++++++++++++---------
1 file changed, 38 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 70382ec..a6b75a2 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,13 @@
-Simple Secrets Manager is a lightweight, self-hosted secrets platform with:
+Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead. Comes with a `ssm-cli` command-line client.
-- Backend API
-- Frontend Admin Console
-- `ssm-cli` command-line client
+
-## Install the Product (Backend + Frontend Bundle)
+## Getting Started
+
+### 1️⃣ Deploying the SSM Server
Start the full stack with Docker Compose:
@@ -26,7 +26,7 @@ Endpoints:
- Backend API via proxy: `http://localhost:8080/api`
- Backend API direct: `http://localhost:5000/api`
-## First-Time Setup
+#### First-Time Setup
On a fresh install:
@@ -36,7 +36,12 @@ On a fresh install:
API-only bootstrap steps are in [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SETUP.md).
-## Install CLI Once, Run Anywhere
+---
+
+### 2️⃣ Installing `ssm-cli` locally
+
+`ssm-cli` is a lightweight command-line client that securely authenticates to Simple Secrets Manager and injects your project/config secrets into any command or runtime on demand.
+
Install `ssm-cli` globally via uv:
@@ -64,7 +69,7 @@ If you installed from Git and want a fresh reinstall:
uv tool install --force git+https://github.com/bearlike/Simple-Secrets-Manager.git
```
-## Authenticate CLI to Your Backend
+#### Authenticate CLI to Your Backend
Set backend URL and token:
@@ -79,7 +84,7 @@ Or login with username/password:
ssm-cli login --profile dev
```
-## Use the Application from CLI
+#### Use the Application from CLI
Inject secrets into a process:
@@ -99,6 +104,8 @@ Check active CLI session:
ssm-cli whoami --profile dev
```
+---
+
## Documentation
- CLI reference: [`docs/CLI.md`](docs/CLI.md)
@@ -121,3 +128,25 @@ If you run prebuilt images only:
docker compose pull
docker compose up -d
```
+
+---
+
+## Contributing 👏
+
+We welcome contributions from the community to improve Meeseeks. Use the steps below.
+
+1. Fork the repository and clone it to your local machine.
+2. Use the pre-commit hook to automate linting and testing, catching errors early.
+3. Create a new branch for your contribution.
+4. Make your changes, commit them, and push to your fork.
+5. Open a pull request describing the change and the problem it solves.
+
+## Bug Reports and Feature Requests 🐞
+
+If you encounter bugs or have ideas for features, open an issue on the [issue tracker](https://github.com/bearlike/Simple-Secrets-Manager/issues). Include reproduction steps and error messages when possible.
+
+Thank you for contributing.
+
+---
+
+Licensed under [CC0 1.0 Universal](./LICENSE).
From 1bffd8a25732c5c7cd6a23f9b959d1c279a5b72a Mon Sep 17 00:00:00 2001
From: Krishnakanth Alagiri
Date: Mon, 23 Feb 2026 15:57:33 -0800
Subject: [PATCH 10/10] Update license badge in README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index a6b75a2..d57792a 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-
+
Simple Secrets Manager is a lightweight, self-hosted secret manager for teams that need clean project/config-based secret organization without enterprise overhead. Comes with a `ssm-cli` command-line client.