insightfactory-cli 1.0.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from typing import Literal, TypedDict
7
+
8
+ from if_cli.cache import TokenCache, is_fresh, load_cache
9
+ from if_cli.cli import Options, parse_args
10
+ from if_cli.colour import Colour, colourise
11
+ from if_cli.config import config_file, get_profile, load_config
12
+ from if_cli.runtime import format_expiry
13
+
14
+ TokenStatus = Literal["valid", "expired", "none", "unknown"]
15
+
16
+
17
+ class ProfileTokenSummary(TypedDict, total=False):
18
+ status: TokenStatus
19
+ expires_at: float
20
+ refreshable: bool
21
+ error: str
22
+
23
+
24
+ class ProfileSummary(TypedDict, total=False):
25
+ name: str
26
+ host: str | None
27
+ token: ProfileTokenSummary
28
+ error: str
29
+
30
+
31
+ PROFILES_OPTIONS: Options = {
32
+ "json": {"type": "boolean", "default": False},
33
+ }
34
+
35
+
36
+ def summarize_profiles(
37
+ config: dict[str, dict[str, str]],
38
+ cache: TokenCache,
39
+ cache_error: str | None,
40
+ now: float,
41
+ ) -> list[ProfileSummary]:
42
+ summaries: list[ProfileSummary] = []
43
+ for section in config:
44
+ name = "DEFAULT" if section == "" else section
45
+ try:
46
+ profile = get_profile(config, name)
47
+ except Exception as error:
48
+ summaries.append(
49
+ {
50
+ "name": name,
51
+ "host": config[section].get("host") or None,
52
+ "token": {"status": "unknown"},
53
+ "error": str(error),
54
+ }
55
+ )
56
+ continue
57
+
58
+ entry = cache["tokens"].get(profile["host"])
59
+ token: ProfileTokenSummary
60
+ if cache_error:
61
+ token = {"status": "unknown", "error": cache_error}
62
+ elif not entry:
63
+ token = {"status": "none"}
64
+ else:
65
+ token = {
66
+ "status": "valid" if is_fresh(entry, now) else "expired",
67
+ "expires_at": entry["expires_at"],
68
+ "refreshable": "refresh_token" in entry,
69
+ }
70
+ summaries.append({"name": name, "host": profile["host"], "token": token})
71
+ return summaries
72
+
73
+
74
+ def _describe_token(token: ProfileTokenSummary) -> tuple[str, Colour]:
75
+ status = token["status"]
76
+ if status == "unknown":
77
+ return f"token cache unavailable ({token.get('error')})", "red"
78
+ if status == "none":
79
+ return "no token", "red"
80
+ if status == "valid":
81
+ expiry = "" if token.get("expires_at") is None else f" until {format_expiry(int(token['expires_at']))}"
82
+ refreshable = " (refreshable)" if token.get("refreshable") else ""
83
+ return f"valid{expiry}{refreshable}", "green"
84
+ if status == "expired":
85
+ refreshable = " (refreshable)" if token.get("refreshable") else ""
86
+ return f"expired{refreshable}", "yellow" if token.get("refreshable") else "red"
87
+ return str(status), "red"
88
+
89
+
90
+ def profiles_command(argv: list[str] | None = None) -> None:
91
+ values, _positionals = parse_args(argv or [], options=PROFILES_OPTIONS)
92
+ config = load_config()
93
+ if len(config) == 0:
94
+ if values["json"]:
95
+ sys.stdout.write("[]\n")
96
+ return
97
+ sys.stdout.write(f"no profiles configured in {config_file()}\n")
98
+ return
99
+
100
+ cache: TokenCache = {"version": 1, "tokens": {}}
101
+ cache_error: str | None = None
102
+ try:
103
+ cache = load_cache()
104
+ except Exception as error:
105
+ cache_error = str(error)
106
+
107
+ summaries = summarize_profiles(config, cache, cache_error, time.time())
108
+ if values["json"]:
109
+ sys.stdout.write(f"{json.dumps(summaries, indent=2)}\n")
110
+ return
111
+
112
+ for summary in summaries:
113
+ if summary.get("error"):
114
+ status = f"misconfigured ({summary['error']})"
115
+ colour = "red"
116
+ else:
117
+ status, colour = _describe_token(summary["token"])
118
+ host = summary.get("host") or "<no host>"
119
+ sys.stdout.write(f"{summary['name']:<20} {host:<55} {colourise(status, colour)}\n")
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import math
5
+ import sys
6
+ import time
7
+
8
+ from if_cli.cache import store_tokens
9
+ from if_cli.cli import Options, parse_args
10
+ from if_cli.config import get_profile, load_config, resolve_profile_name
11
+ from if_cli.runtime import die, format_expiry, jwt_exp
12
+
13
+ SET_TOKEN_OPTIONS: Options = {
14
+ "profile": {"type": "string", "short": "p"},
15
+ }
16
+
17
+
18
+ def _read_hidden_token() -> str:
19
+ """Read a token without echoing it, matching the Node CLI's hidden-input behaviour."""
20
+ try:
21
+ if sys.stdin.isatty():
22
+ return getpass.getpass("").strip()
23
+ line = sys.stdin.readline()
24
+ if line == "":
25
+ die("no token provided")
26
+ return line.strip()
27
+ except KeyboardInterrupt:
28
+ try:
29
+ sys.stderr.write("\n")
30
+ except BrokenPipeError:
31
+ pass
32
+ die("token input cancelled")
33
+ except EOFError:
34
+ die("no token provided")
35
+
36
+
37
+ def set_token_command(argv: list[str]) -> None:
38
+ values, _positionals = parse_args(argv, options=SET_TOKEN_OPTIONS)
39
+ profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
40
+ sys.stderr.write("Paste the access token (from Profile → Developer → Copy token), then Enter:\n")
41
+ sys.stderr.flush()
42
+ token = _read_hidden_token()
43
+ if not token:
44
+ die("no token provided")
45
+
46
+ expiry = jwt_exp(token)
47
+ now = math.floor(time.time())
48
+ if expiry is not None and expiry <= now:
49
+ die("the provided access token is already expired")
50
+ entry = store_tokens(
51
+ profile["host"],
52
+ {
53
+ "access_token": token,
54
+ "expires_in": 3600 if expiry is None else expiry - now,
55
+ },
56
+ "",
57
+ None,
58
+ replace=True,
59
+ )
60
+ sys.stdout.write(
61
+ f"✓ stored static token for {profile['host']} (expires {format_expiry(int(entry['expires_at']))})\n"
62
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+
6
+ from if_cli.cli import Options, parse_args
7
+ from if_cli.config import get_profile, load_config, resolve_profile_name
8
+ from if_cli.oauth import get_valid_token
9
+ from if_cli.runtime import die
10
+
11
+ TOKEN_OPTIONS: Options = {
12
+ "profile": {"type": "string", "short": "p"},
13
+ "env": {"type": "boolean", "default": False},
14
+ "env-name": {"type": "string"},
15
+ }
16
+
17
+
18
+ def shell_quote(value: str) -> str:
19
+ return "'" + value.replace("'", "'\\''") + "'"
20
+
21
+
22
+ def token_command(argv: list[str]) -> None:
23
+ values, _positionals = parse_args(argv, options=TOKEN_OPTIONS)
24
+ profile = get_profile(load_config(), resolve_profile_name(values["profile"]))
25
+ environment_name = values["env-name"] or "INSIGHTFACTORY_ACCESS_TOKEN"
26
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", environment_name):
27
+ die(f"environment variable name '{environment_name}' is invalid")
28
+ token = get_valid_token(profile)
29
+ if values["env"] or values["env-name"]:
30
+ sys.stdout.write(f"export {environment_name}={shell_quote(token)}\n")
31
+ else:
32
+ sys.stdout.write(f"{token}\n")
if_cli/config.py ADDED
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import uuid
7
+ from typing import TypedDict
8
+
9
+ from if_cli.constants import DEFAULT_CALLBACK_PORT, ENV_PROFILE
10
+ from if_cli.http import normalize_origin, validate_audience_url
11
+ from if_cli.runtime import die
12
+
13
+ INI_UNSAFE_VALUE = re.compile(r"[\r\n]")
14
+ INI_UNSAFE_SECTION = re.compile(r"[\[\]\r\n]")
15
+
16
+
17
+ class Profile(TypedDict):
18
+ name: str
19
+ host: str
20
+ audience: str
21
+ callback_port: int
22
+ client_id: str | None
23
+ organization: str | None
24
+
25
+
26
+ def config_dir() -> str:
27
+ if "INSIGHTFACTORY_CONFIG_DIR" in os.environ:
28
+ return os.environ["INSIGHTFACTORY_CONFIG_DIR"]
29
+ return os.path.join(os.path.expanduser("~"), ".insightfactory")
30
+
31
+
32
+ def config_file() -> str:
33
+ return os.path.join(config_dir(), "config")
34
+
35
+
36
+ def cache_file() -> str:
37
+ return os.path.join(config_dir(), "token-cache.json")
38
+
39
+
40
+ def empty_section() -> dict[str, str]:
41
+ return {}
42
+
43
+
44
+ def load_config() -> dict[str, dict[str, str]]:
45
+ config: dict[str, dict[str, str]] = {}
46
+ try:
47
+ with open(config_file(), encoding="utf-8") as handle:
48
+ raw = handle.read()
49
+ except OSError:
50
+ return config
51
+
52
+ section = ""
53
+ for line in raw.split("\n"):
54
+ trimmed = line.strip()
55
+ if not trimmed or trimmed.startswith("#") or trimmed.startswith(";"):
56
+ continue
57
+
58
+ header = re.match(r"^\[(.+)\]$", trimmed)
59
+ if header:
60
+ section_name = header.group(1)
61
+ section = "" if section_name == "DEFAULT" else section_name
62
+ config.setdefault(section, empty_section())
63
+ continue
64
+
65
+ equals_index = trimmed.find("=")
66
+ if equals_index > 0:
67
+ config.setdefault(section, empty_section())[trimmed[:equals_index].strip()] = trimmed[
68
+ equals_index + 1 :
69
+ ].strip()
70
+
71
+ return config
72
+
73
+
74
+ def save_config(config: dict[str, dict[str, str]]) -> None:
75
+ lines = ["# Managed by if-cli; comments are not preserved when this file is updated.", ""]
76
+ for section, values in config.items():
77
+ profile_name = "DEFAULT" if section == "" else section
78
+ if len(values) == 0:
79
+ continue
80
+ if INI_UNSAFE_SECTION.search(section):
81
+ die(
82
+ f"cannot save profile {json.dumps(profile_name)}: its section name contains [, ], a newline, "
83
+ f"or a carriage return; fix this section in the config file at {config_file()}"
84
+ )
85
+ lines.append(f"[{profile_name}]")
86
+ for key, value in values.items():
87
+ if INI_UNSAFE_VALUE.search(key) or INI_UNSAFE_VALUE.search(value):
88
+ die(
89
+ f"cannot save profile {json.dumps(profile_name)} key {json.dumps(key)}: keys and values "
90
+ f"cannot contain newlines or carriage returns; fix this entry in the config file at {config_file()}"
91
+ )
92
+ lines.append(f"{key} = {value}")
93
+ lines.append("")
94
+ write_private_file(config_file(), "\n".join(lines))
95
+
96
+
97
+ def write_private_file(file: str, contents: str) -> None:
98
+ directory = os.path.dirname(file)
99
+ os.makedirs(directory, mode=0o700, exist_ok=True)
100
+ os.chmod(directory, 0o700)
101
+ temporary_file = f"{file}.{os.getpid()}.{uuid.uuid4()}.tmp"
102
+ descriptor: int | None = None
103
+ try:
104
+ descriptor = os.open(temporary_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
105
+ try:
106
+ os.write(descriptor, contents.encode("utf-8"))
107
+ os.fchmod(descriptor, 0o600)
108
+ os.fsync(descriptor)
109
+ finally:
110
+ os.close(descriptor)
111
+ descriptor = None
112
+ os.replace(temporary_file, file)
113
+ os.chmod(file, 0o600)
114
+ directory_descriptor: int | None = None
115
+ try:
116
+ directory_descriptor = os.open(directory, os.O_RDONLY)
117
+ os.fsync(directory_descriptor)
118
+ except OSError:
119
+ pass
120
+ finally:
121
+ if directory_descriptor is not None:
122
+ os.close(directory_descriptor)
123
+ finally:
124
+ if descriptor is not None:
125
+ try:
126
+ os.close(descriptor)
127
+ except OSError:
128
+ pass
129
+ try:
130
+ os.unlink(temporary_file)
131
+ except OSError:
132
+ pass
133
+
134
+
135
+ def meaningful(value: str | None) -> str | None:
136
+ return value if value and value.strip() else None
137
+
138
+
139
+ def parse_callback_port(value: str | None) -> int:
140
+ if value is None:
141
+ return DEFAULT_CALLBACK_PORT
142
+ try:
143
+ port = float(value)
144
+ except (TypeError, ValueError):
145
+ die(f"callback port must be an integer between 1 and 65535 (received '{value}')")
146
+ if port != int(port) or int(port) < 1 or int(port) > 65_535:
147
+ die(f"callback port must be an integer between 1 and 65535 (received '{value}')")
148
+ return int(port)
149
+
150
+
151
+ def resolve_profile_name(flag: str | None = None) -> str:
152
+ if flag is not None:
153
+ name = flag
154
+ elif ENV_PROFILE in os.environ:
155
+ name = os.environ[ENV_PROFILE]
156
+ else:
157
+ name = "DEFAULT"
158
+ if name == "" or name != name.strip() or re.search(r"[\[\]=\r\n]", name):
159
+ die(
160
+ f"invalid profile name {json.dumps(name)}: names must be non-empty, have no leading or trailing "
161
+ "whitespace, and must not contain [, ], =, newlines, or carriage returns"
162
+ )
163
+ return name
164
+
165
+
166
+ def get_profile(config: dict[str, dict[str, str]], name: str) -> Profile:
167
+ section_key = "" if name == "DEFAULT" else name
168
+ if section_key not in config:
169
+ die(f"profile '{name}' not found in {config_file()} — run: if-cli login -p {name} --host <factory-url>")
170
+ section = config[section_key]
171
+
172
+ host = section.get("host")
173
+ if not host:
174
+ die(f"profile '{name}' has no host configured")
175
+ normalized_host = normalize_origin(host, f"host for profile '{name}'")
176
+ audience_value = section.get("audience")
177
+ return {
178
+ "name": name,
179
+ "host": normalized_host,
180
+ "client_id": meaningful(section.get("client_id")),
181
+ "organization": meaningful(section.get("organization")),
182
+ "audience": (
183
+ validate_audience_url(audience_value, f"audience for profile '{name}'")
184
+ if audience_value
185
+ else f"{normalized_host}/api"
186
+ ),
187
+ "callback_port": parse_callback_port(section.get("callback_port")),
188
+ }
if_cli/constants.py ADDED
@@ -0,0 +1,5 @@
1
+ ENV_PROFILE: str = "INSIGHTFACTORY_CONFIG_PROFILE"
2
+ DEFAULT_SCOPE: str = "openid profile email offline_access"
3
+ LOOPBACK_HOST: str = "127.0.0.1"
4
+ DEFAULT_CALLBACK_PORT: int = 53682
5
+ EXPIRY_SLACK_SECONDS: int = 60
if_cli/http.py ADDED
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import urllib.error
6
+ import urllib.request
7
+ from email.message import Message
8
+ from urllib.parse import ParseResult, urljoin, urlparse
9
+
10
+ from if_cli.runtime import die
11
+
12
+ REQUEST_TIMEOUT_MS = 30_000
13
+
14
+
15
+ def _with_backslashes_as_slashes(value: str) -> str:
16
+ return value.replace("\\", "/")
17
+
18
+
19
+ def parse_url(value: str, base: str | None = None) -> ParseResult:
20
+ normalized = _with_backslashes_as_slashes(value)
21
+ if base is not None:
22
+ return urlparse(urljoin(_with_backslashes_as_slashes(base), normalized))
23
+ return urlparse(normalized)
24
+
25
+
26
+ def _host_for_url(hostname: str) -> str:
27
+ return f"[{hostname}]" if ":" in hostname else hostname
28
+
29
+
30
+ def url_origin(parsed: ParseResult) -> str:
31
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
32
+ return "null"
33
+ host = _host_for_url(parsed.hostname)
34
+ port = parsed.port
35
+ default_port = 443 if parsed.scheme == "https" else 80
36
+ if port is not None and port != default_port:
37
+ return f"{parsed.scheme}://{host}:{port}"
38
+ return f"{parsed.scheme}://{host}"
39
+
40
+
41
+ def url_href(parsed: ParseResult) -> str:
42
+ scheme = parsed.scheme.lower()
43
+ hostname = parsed.hostname
44
+ if not hostname:
45
+ raise ValueError("URL has no hostname")
46
+ host = _host_for_url(hostname)
47
+ userinfo = ""
48
+ if parsed.username:
49
+ userinfo = parsed.username
50
+ if parsed.password is not None:
51
+ userinfo += f":{parsed.password}"
52
+ userinfo += "@"
53
+ port = parsed.port
54
+ default_port = 443 if scheme == "https" else 80 if scheme == "http" else None
55
+ port_part = f":{port}" if port is not None and port != default_port else ""
56
+ path = parsed.path if parsed.path else "/"
57
+ query = f"?{parsed.query}" if parsed.query else ""
58
+ fragment = f"#{parsed.fragment}" if parsed.fragment else ""
59
+ return f"{scheme}://{userinfo}{host}{port_part}{path}{query}{fragment}"
60
+
61
+
62
+ def _is_loopback(hostname: str | None) -> bool:
63
+ if hostname is None:
64
+ return False
65
+ return hostname in {"localhost", "127.0.0.1", "::1", "[::1]"}
66
+
67
+
68
+ def parse_http_url(value: str, label: str) -> ParseResult:
69
+ try:
70
+ parsed = parse_url(value)
71
+ except ValueError:
72
+ die(f"{label} must be a valid URL")
73
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
74
+ die(f"{label} must be a valid URL")
75
+ loopback = _is_loopback(parsed.hostname)
76
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and loopback):
77
+ die(f"{label} must use HTTPS (HTTP is allowed only for localhost)")
78
+ return parsed
79
+
80
+
81
+ def validate_http_url(value: str, label: str) -> str:
82
+ return url_href(parse_http_url(value, label))
83
+
84
+
85
+ def validate_audience_url(value: str, label: str) -> str:
86
+ parse_http_url(value, label)
87
+ if any(ord(character) <= 0x20 or ord(character) == 0x7F for character in value):
88
+ die(f"{label} must not contain ASCII control characters or spaces")
89
+ return value
90
+
91
+
92
+ def normalize_origin(value: str, label: str) -> str:
93
+ parsed = parse_http_url(value, label)
94
+ path = parsed.path if parsed.path else "/"
95
+ if parsed.username or parsed.password or path != "/" or parsed.query or parsed.fragment:
96
+ die(f"{label} must be a bare origin without credentials, a path, query, or fragment")
97
+ return url_origin(parsed)
98
+
99
+
100
+ class HttpResponse:
101
+ def __init__(self, status: int, headers: Message | None, body: bytes) -> None:
102
+ self.status = status
103
+ self.headers = headers
104
+ self._body = body
105
+
106
+ @property
107
+ def ok(self) -> bool:
108
+ return 200 <= self.status < 300
109
+
110
+ def text(self) -> str:
111
+ return self._body.decode("utf-8", errors="replace")
112
+
113
+ def json(self) -> object:
114
+ return json.loads(self.text())
115
+
116
+ def header(self, name: str) -> str | None:
117
+ if self.headers is None:
118
+ return None
119
+ value = self.headers.get(name)
120
+ if value is None:
121
+ return None
122
+ return str(value)
123
+
124
+
125
+ def fetch_with_timeout(
126
+ url: str,
127
+ *,
128
+ method: str = "GET",
129
+ headers: dict[str, str] | None = None,
130
+ body: str | bytes | None = None,
131
+ ) -> HttpResponse:
132
+ data: bytes | None
133
+ if body is None:
134
+ data = None
135
+ elif isinstance(body, bytes):
136
+ data = body
137
+ else:
138
+ data = body.encode("utf-8")
139
+ request = urllib.request.Request(url, data=data, headers=headers or {}, method=method)
140
+ try:
141
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_MS / 1000) as response:
142
+ return HttpResponse(response.status, response.headers, response.read())
143
+ except urllib.error.HTTPError as error:
144
+ return HttpResponse(error.code, error.headers, error.read())
145
+ except urllib.error.URLError as error:
146
+ reason = error.reason
147
+ if isinstance(reason, (TimeoutError, socket.timeout)) or getattr(reason, "errno", None) == getattr(
148
+ socket, "ETIMEDOUT", None
149
+ ):
150
+ die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
151
+ raise
152
+ except TimeoutError:
153
+ die(f"request timed out after {REQUEST_TIMEOUT_MS / 1000}s: {url}")
if_cli/main.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import version
4
+
5
+ from if_cli.cli import run_with_handlers
6
+ from if_cli.commands.api import api_command
7
+ from if_cli.commands.config import config_command
8
+ from if_cli.commands.login import login_command
9
+ from if_cli.commands.logout import logout_command
10
+ from if_cli.commands.profiles import profiles_command
11
+ from if_cli.commands.set_token import set_token_command
12
+ from if_cli.commands.token import token_command
13
+ from if_cli.constants import ENV_PROFILE
14
+ from if_cli.runtime import die
15
+
16
+ USAGE = f"""usage: if-cli <command> [options]
17
+
18
+ commands:
19
+ login browser OAuth login (auth-code + PKCE via the factory /authorize proxy)
20
+ [-p profile] [--host url] [--client-id id] [--organization org]
21
+ [--audience aud] [--callback-port n] [--no-browser]
22
+ token print a valid access token (auto-refreshes)
23
+ [-p profile] [--env] [--env-name VARIABLE]
24
+ logout clear the cached token for a profile [-p profile]
25
+ set-token store a manually copied token for a profile (fallback) [-p profile]
26
+ profiles list profiles and token status [--json]
27
+ config print a non-secret profile value
28
+ get <host|audience|callback_port|client_id|organization> [-p profile]
29
+ api call or discover factory API routes
30
+ [-p profile] [-X METHOD] [-d DATA] <path>
31
+ routes [-p profile] [filter]
32
+ describe [-p profile] METHOD <path>
33
+
34
+ profile selection: -p flag, then ${ENV_PROFILE}, then [DEFAULT].
35
+ """
36
+
37
+
38
+ def run_cli(argv: list[str]) -> None:
39
+ command = argv[0] if argv else None
40
+ rest = argv[1:]
41
+ if command == "login":
42
+ login_command(rest)
43
+ return
44
+ if command == "token":
45
+ token_command(rest)
46
+ return
47
+ if command == "logout":
48
+ logout_command(rest)
49
+ return
50
+ if command == "set-token":
51
+ set_token_command(rest)
52
+ return
53
+ if command == "profiles":
54
+ profiles_command(rest)
55
+ return
56
+ if command == "config":
57
+ config_command(rest)
58
+ return
59
+ if command == "api":
60
+ api_command(rest)
61
+ return
62
+ if command in {None, "-h", "--help"}:
63
+ import sys
64
+
65
+ sys.stdout.write(USAGE)
66
+ return
67
+ if command in {"-v", "--version"}:
68
+ import sys
69
+
70
+ sys.stdout.write(f"{version('insightfactory-cli')}\n")
71
+ return
72
+ die(f"unknown command '{command}'\n\n{USAGE}")
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> None:
76
+ run_with_handlers(run_cli, argv)