hookstride-cli 0.2.0__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,3 @@
1
+ """Hookstride developer CLI."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,456 @@
1
+ """Interactive, read-only Hookstride console.
2
+
3
+ The console intentionally does not execute shell commands. It uses the same
4
+ project-scoped Control API commands as the CLI, keeps tokens only in memory,
5
+ and stores no history on disk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import getpass
12
+ import os
13
+ import shlex
14
+ import uuid
15
+ import warnings
16
+ from collections.abc import Iterator
17
+
18
+ import click
19
+ from prompt_toolkit import PromptSession
20
+ from prompt_toolkit.completion import WordCompleter
21
+ from prompt_toolkit.formatted_text import FormattedText
22
+ from prompt_toolkit.history import InMemoryHistory
23
+ from typer.main import get_command
24
+
25
+ from . import main as cli
26
+
27
+ # Only these typed commands may execute through the console.
28
+ # Future write actions must be explicitly reviewed, not implicitly unlocked.
29
+ READ_COMMANDS: dict[str, set[str]] = {
30
+ "auth": {"status"},
31
+ "projects": {"list", "use"},
32
+ "destinations": {"list"},
33
+ "keys": {"list"},
34
+ "events": {"list", "show"},
35
+ "billing": {"status", "history"},
36
+ "incoming": {"sources", "routes", "bindings", "registration-keys"},
37
+ "config": {"show"},
38
+ }
39
+ ROOT_COMMANDS = {"status", "stats"}
40
+
41
+ # Aliases that feel like a familiar operator console, but remain read-only.
42
+ SHOW_TARGETS: dict[str, tuple[str, ...] | None] = {
43
+ "options": None,
44
+ "projects": ("projects", "list"),
45
+ "destinations": ("destinations", "list"),
46
+ "keys": ("keys", "list"),
47
+ "events": ("events", "list"),
48
+ "billing": ("billing", "status"),
49
+ "payments": ("billing", "history"),
50
+ "sources": ("incoming", "sources"),
51
+ "routes": ("incoming", "routes"),
52
+ "bindings": ("incoming", "bindings"),
53
+ "registration-keys": ("incoming", "registration-keys"),
54
+ "stats": ("stats",),
55
+ "status": ("status",),
56
+ }
57
+
58
+ SAFE_HISTORY_COMMANDS = frozenset(
59
+ [
60
+ "help", "?", "clear", "exit", "quit", "back", "status", "stats",
61
+ "history", "auth login", "auth logout", "auth status",
62
+ "projects list", "destinations list", "keys list", "billing status",
63
+ "billing history", "events list", "incoming sources",
64
+ "incoming routes", "incoming bindings", "incoming registration-keys",
65
+ "config show",
66
+ *(f"show {name}" for name in SHOW_TARGETS),
67
+ "set env dev", "set env production",
68
+ ]
69
+ )
70
+ COMMAND_COMPLETIONS = sorted(
71
+ {
72
+ *SAFE_HISTORY_COMMANDS,
73
+ "use ", "projects use ", "events show ", "events list --status ",
74
+ "events list --origin ", "events list --page ", "set project ",
75
+ "set env ", "json ", "show ", "auth ",
76
+ }
77
+ )
78
+
79
+
80
+ def _project_uuid(value: str) -> str:
81
+ try:
82
+ return str(uuid.UUID(value))
83
+ except (ValueError, AttributeError):
84
+ raise cli.CLIError("Expected a valid project UUID.") from None
85
+
86
+
87
+ def _saved_project(environment: str) -> str | None:
88
+ settings = cli._read_config().get("projects", {})
89
+ value = settings.get(environment) if isinstance(settings, dict) else None
90
+ if not value:
91
+ return None
92
+ # An old/corrupt settings file should not crash the console.
93
+ try:
94
+ return _project_uuid(value)
95
+ except cli.CLIError:
96
+ return None
97
+
98
+
99
+ def _history_safe(line: str) -> bool:
100
+ """Only retain recognized non-sensitive commands in process memory."""
101
+ if len(line) > 180 or any(ord(ch) < 32 or ord(ch) > 126 for ch in line):
102
+ return False
103
+ line = line.strip()
104
+ if line in SAFE_HISTORY_COMMANDS:
105
+ return True
106
+ tokens = line.split()
107
+ if len(tokens) == 2 and tokens[0] == "use":
108
+ try:
109
+ _project_uuid(tokens[1])
110
+ return True
111
+ except cli.CLIError:
112
+ return False
113
+ if len(tokens) == 3 and tokens[:2] in (
114
+ ["projects", "use"], ["set", "project"], ["events", "show"],
115
+ ):
116
+ try:
117
+ _project_uuid(tokens[2])
118
+ return True
119
+ except cli.CLIError:
120
+ return False
121
+ return False
122
+
123
+
124
+ class SafeMemoryHistory(InMemoryHistory):
125
+ """Only safe, non-sensitive recognized commands enter arrow-key history."""
126
+
127
+ def append_string(self, string: str) -> None:
128
+ if _history_safe(string):
129
+ super().append_string(string)
130
+
131
+
132
+ class HookstrideConsole:
133
+ def __init__(self, session: cli.Session) -> None:
134
+ self.session = session
135
+ self.start_environment = session.environment
136
+ self.origins = dict(cli.ORIGINS)
137
+ self.origins[session.environment] = session.origin
138
+ self.project = session.project or _saved_project(session.environment)
139
+ self.session.project = self.project
140
+ self.tokens: dict[str, str] = {} # volatile; never persisted or printed
141
+ self.logged_out: set[str] = set()
142
+ self.history = SafeMemoryHistory()
143
+ self.click_command = get_command(cli.app)
144
+ self.completer = WordCompleter(
145
+ COMMAND_COMPLETIONS, ignore_case=True, sentence=True,
146
+ )
147
+
148
+ @property
149
+ def environment(self) -> str:
150
+ return self.session.environment
151
+
152
+ def _current_token(self) -> str | None:
153
+ env = self.environment
154
+ if env in self.logged_out:
155
+ return None
156
+ if token := self.tokens.get(env):
157
+ return token
158
+ if token := os.getenv(f"HOOKSTRIDE_{env.upper()}_CONTROL_TOKEN"):
159
+ return token if token.startswith("whrctl_") else None
160
+ # The legacy generic token is bound ONLY to the initial environment.
161
+ # Do not forward a development token to production on "set env".
162
+ if env == self.start_environment:
163
+ token = os.getenv("HOOKSTRIDE_CONTROL_TOKEN", "")
164
+ return token if token.startswith("whrctl_") else None
165
+ return None
166
+
167
+ @contextlib.contextmanager
168
+ def _token_scope(self) -> Iterator[None]:
169
+ """Temporarily expose just the selected environment's token to the CLI."""
170
+ previous = os.environ.get("HOOKSTRIDE_CONTROL_TOKEN")
171
+ current = self._current_token()
172
+ if current:
173
+ os.environ["HOOKSTRIDE_CONTROL_TOKEN"] = current
174
+ else:
175
+ os.environ.pop("HOOKSTRIDE_CONTROL_TOKEN", None)
176
+ try:
177
+ yield
178
+ finally:
179
+ if previous is None:
180
+ os.environ.pop("HOOKSTRIDE_CONTROL_TOKEN", None)
181
+ else:
182
+ os.environ["HOOKSTRIDE_CONTROL_TOKEN"] = previous
183
+
184
+ def _write(self, message: str) -> None:
185
+ # Message is trusted markup. Never interpolate raw API fields.
186
+ self.session.console.print(message)
187
+
188
+ def _error(self, message: str) -> None:
189
+ self._write(f"[red]✗[/red] {cli._escaped(message)}")
190
+
191
+ def _show_help(self) -> None:
192
+ self._write(
193
+ "\n"
194
+ "[bold]help[/bold] Show console help\n"
195
+ "[bold]show options[/bold] Display environment, project and authentication\n"
196
+ "[bold]show projects[/bold] List accessible projects\n"
197
+ "[bold]use UUID[/bold] Select a project (also saves preference)\n"
198
+ "[bold]back[/bold] Leave the active project context\n"
199
+ "[bold]set env dev[/bold] Switch to development API\n"
200
+ "[bold]set env production[/bold] Switch to production API\n"
201
+ "[bold]auth login[/bold] Enter a management token invisibly\n"
202
+ "[bold]auth logout[/bold] Clear the current environment's session token\n"
203
+ "[bold]auth status[/bold] Validate current token\n"
204
+ "[bold]show destinations[/bold] Inspect outgoing webhooks\n"
205
+ "[bold]show events[/bold] View recent events\n"
206
+ "[bold]events list --status failed[/bold] Filter delivery history\n"
207
+ "[bold]events show UUID[/bold] Inspect a delivery and attempts\n"
208
+ "[bold]show billing[/bold] View plan and credits\n"
209
+ "[bold]show payments[/bold] View payment history\n"
210
+ "[bold]show sources[/bold] Inspect incoming sources\n"
211
+ "[bold]show keys[/bold] List masked keys\n"
212
+ "[bold]json projects list[/bold] Print the complete JSON response\n"
213
+ "[bold]history[/bold] Recall recognized safe commands (memory only)\n"
214
+ "[bold]clear[/bold] Clear screen\n"
215
+ "[bold]exit[/bold] Close this console\n\n"
216
+
217
+ )
218
+
219
+ def _show_options(self) -> None:
220
+ project = self.project or "none"
221
+ rows = [
222
+ ["Environment", self.environment],
223
+ ["API", self.session.origin],
224
+ ["Project", project],
225
+ ["Authenticated token", "configured" if self._current_token() else "missing"],
226
+ ["Mode", "read-only"],
227
+ ]
228
+ cli._table(_FakeContext(self.session), "Session options", ["Setting", "Value"], rows)
229
+
230
+ def _history(self) -> None:
231
+ commands = list(self.history.get_strings())
232
+ if not commands:
233
+ self._write("[dim]No safe commands in history.[/dim]")
234
+ return
235
+ for i, command in enumerate(reversed(commands[-20:]), 1):
236
+ self._write(f"[dim]{i:>3}[/dim] {cli._escaped(command)}")
237
+
238
+ def _set_environment(self, target: str) -> None:
239
+ if target not in cli.ORIGINS:
240
+ raise cli.CLIError("Use 'set env dev' or 'set env production'.")
241
+ self.session.environment = target
242
+ self.session.origin = self.origins[target]
243
+ self.project = _saved_project(target)
244
+ self.session.project = self.project
245
+ self._write(
246
+ f"[green]✓[/green] Environment: [bold]{target}[/bold] "
247
+ f"({_env_hint(self._current_token())})"
248
+ )
249
+
250
+ def _login(self) -> None:
251
+ # getpass does not echo, and is not part of the PromptSession history.
252
+ try:
253
+ # getpass normally degrades to VISIBLE input on unsupported terminals.
254
+ # Treat that warning as an exception before it can read a credential.
255
+ with warnings.catch_warnings():
256
+ warnings.simplefilter("error", getpass.GetPassWarning)
257
+ token = getpass.getpass("Control token (hidden): ").strip()
258
+ except getpass.GetPassWarning:
259
+ raise cli.CLIError(
260
+ "Cannot hide token input on this terminal. Authentication cancelled."
261
+ ) from None
262
+ except (KeyboardInterrupt, EOFError):
263
+ raise cli.CLIError("Authentication cancelled.") from None
264
+ if (
265
+ not token.startswith("whrctl_")
266
+ or not 16 <= len(token) <= 512
267
+ or any(ch.isspace() for ch in token)
268
+ ):
269
+ raise cli.CLIError("Expected a valid Control API management token.")
270
+ previous = self.tokens.get(self.environment)
271
+ logged_out = self.environment in self.logged_out
272
+ self.tokens[self.environment] = token
273
+ self.logged_out.discard(self.environment)
274
+ try:
275
+ with self._token_scope():
276
+ info = cli._request(self.session, "/control/v1/me/")
277
+ except (cli.CLIError, ValueError, TypeError):
278
+ if previous is None:
279
+ self.tokens.pop(self.environment, None)
280
+ else:
281
+ self.tokens[self.environment] = previous
282
+ if logged_out:
283
+ self.logged_out.add(self.environment)
284
+ raise cli.CLIError("Token verification failed for this environment.") from None
285
+ account = (info.get("user") or {}).get("email") if isinstance(info, dict) else None
286
+ if account:
287
+ self._write(f"[green]✓[/green] Authenticated as {cli._escaped(account)}")
288
+ else:
289
+ self._write("[green]✓[/green] Management token verified.")
290
+
291
+ def _logout(self) -> None:
292
+ self.tokens.pop(self.environment, None)
293
+ self.logged_out.add(self.environment)
294
+ self._write("[green]✓[/green] Signed out for this console environment.")
295
+
296
+ def _execute_cli(self, args: list[str], *, json_output: bool = False) -> None:
297
+ """Invoke existing read-only Click/Typer commands, never a shell."""
298
+ if not _allowed_cli(args):
299
+ raise cli.CLIError("Unknown or unavailable command. Type 'help'.")
300
+ group = args[0]
301
+ action = args[1] if len(args) > 1 else None
302
+ if _requires_project(group, action) and not self.project:
303
+ raise cli.CLIError("No project selected. Run 'show projects' then 'use UUID'.")
304
+ if _requires_token(group, action) and not self._current_token():
305
+ raise cli.CLIError("No management token for this environment. Run 'auth login'.")
306
+ options = ["--env", self.environment, "--api-url", self.session.origin]
307
+ if self.project:
308
+ options += ["--project", self.project]
309
+ if json_output:
310
+ options.append("--json")
311
+ try:
312
+ with self._token_scope():
313
+ self.click_command.main(
314
+ args=[*options, *args],
315
+ prog_name="hookstride",
316
+ standalone_mode=False,
317
+ )
318
+ except click.exceptions.Exit as exc:
319
+ # 'status' already displayed a red Offline panel. Do not add an
320
+ # unrelated error when returning to the interactive prompt.
321
+ if group == "status" and not json_output and exc.exit_code == 1:
322
+ return
323
+ raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
324
+ except (click.ClickException, click.Abort):
325
+ # Click errors can quote user-provided arguments; don't echo them.
326
+ raise cli.CLIError("Invalid command arguments. Type 'help'.") from None
327
+ if args[:2] == ["projects", "use"]:
328
+ self.project = _project_uuid(args[2])
329
+ self.session.project = self.project
330
+
331
+ def execute_line(self, line: str) -> bool:
332
+ """Run one input line. False means exit; all other outcomes continue."""
333
+ line = line.strip()
334
+ if not line:
335
+ return True
336
+ try:
337
+ parts = shlex.split(line, posix=True)
338
+ if not parts:
339
+ return True
340
+ command = parts[0].lower()
341
+ if command in {"exit", "quit"} and len(parts) == 1:
342
+ return False
343
+ if command in {"help", "?"} and len(parts) == 1:
344
+ self._show_help()
345
+ elif command == "clear" and len(parts) == 1:
346
+ self.session.console.clear()
347
+ elif command == "history" and len(parts) == 1:
348
+ self._history()
349
+ elif command == "back" and len(parts) == 1:
350
+ self.project = None
351
+ self.session.project = None
352
+ self._write("[dim]Project context cleared. Run 'use UUID' to select one.[/dim]")
353
+ elif command == "set" and len(parts) == 3 and parts[1].lower() == "env":
354
+ self._set_environment(parts[2].lower())
355
+ elif command == "set" and len(parts) == 3 and parts[1].lower() == "project":
356
+ self._execute_cli(["projects", "use", parts[2]])
357
+ elif command == "use" and len(parts) == 2:
358
+ self._execute_cli(["projects", "use", parts[1]])
359
+ elif command == "auth" and len(parts) == 2 and parts[1] == "login":
360
+ self._login()
361
+ elif command == "auth" and len(parts) == 2 and parts[1] == "logout":
362
+ self._logout()
363
+ elif command == "show" and len(parts) == 2:
364
+ target = SHOW_TARGETS.get(parts[1].lower(), ())
365
+ if target is None:
366
+ self._show_options()
367
+ elif target:
368
+ self._execute_cli(list(target))
369
+ else:
370
+ raise cli.CLIError("Unknown display target. Type 'help'.")
371
+ elif command == "json" and len(parts) > 1:
372
+ self._execute_cli(parts[1:], json_output=True)
373
+ else:
374
+ self._execute_cli(parts)
375
+ except ValueError:
376
+ self._error("Invalid command syntax. Type 'help'.")
377
+ except cli.CLIError as exc:
378
+ self._error(str(exc))
379
+ except (click.ClickException, click.Abort, click.exceptions.Exit):
380
+ self._error("Invalid command arguments. Type 'help'.")
381
+ return True
382
+
383
+ def run(self) -> None:
384
+ self._write(
385
+ "\n[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CONSOLE v0.2[/dim]\n"
386
+ "[dim]Type 'help' for commands, 'auth login' for a token, "
387
+ "or 'exit' to quit.[/dim]\n"
388
+ )
389
+ terminal = PromptSession(
390
+ history=self.history,
391
+ completer=self.completer,
392
+ complete_while_typing=False,
393
+ )
394
+ try:
395
+ while True:
396
+ tag = self.environment
397
+ if self.project:
398
+ tag += ":" + self.project[:8]
399
+ prompt = FormattedText(
400
+ [("ansicyan bold", "hookstride"),
401
+ ("", " ["), ("ansigreen", tag), ("", "] > ")]
402
+ )
403
+ try:
404
+ line = terminal.prompt(prompt)
405
+ except KeyboardInterrupt:
406
+ self._write("[dim]Interrupted; type 'exit' to quit.[/dim]")
407
+ continue
408
+ except EOFError:
409
+ break
410
+ if not self.execute_line(line):
411
+ break
412
+ finally:
413
+ self.tokens.clear()
414
+ self.logged_out.clear()
415
+ self._write("[dim]Console closed.[/dim]")
416
+
417
+
418
+ class _FakeContext:
419
+ def __init__(self, obj: cli.Session) -> None:
420
+ self.obj = obj
421
+
422
+
423
+ def _env_hint(token: str | None) -> str:
424
+ return "token configured" if token else "run 'auth login' to authenticate"
425
+
426
+
427
+ def _allowed_cli(parts: list[str]) -> bool:
428
+ if not parts:
429
+ return False
430
+ if parts[0] in ROOT_COMMANDS:
431
+ return len(parts) == 1
432
+ return (
433
+ len(parts) >= 2
434
+ and parts[0] in READ_COMMANDS
435
+ and parts[1] in READ_COMMANDS[parts[0]]
436
+ )
437
+
438
+
439
+ def _requires_project(group: str, action: str | None) -> bool:
440
+ return group in {
441
+ "destinations", "keys", "events", "billing", "incoming",
442
+ } or group == "stats"
443
+
444
+
445
+ def _requires_token(group: str, action: str | None) -> bool:
446
+ return group != "status" and not (group == "config" and action == "show")
447
+
448
+
449
+ def run_console(session: cli.Session) -> None:
450
+ if session.json_output:
451
+ raise cli.CLIError("Interactive console does not support --json; use 'json COMMAND' inside it.")
452
+ if not os.isatty(0):
453
+ raise cli.CLIError(
454
+ "Interactive console requires a terminal. Use direct Hookstride CLI commands in scripts."
455
+ )
456
+ HookstrideConsole(session).run()
hookstride_cli/main.py ADDED
@@ -0,0 +1,687 @@
1
+ """Hookstride CLI: developer-friendly, project-scoped Control API inspection.
2
+
3
+ Read-only commands use control tokens. This module never persists a token.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ import unicodedata
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import urlsplit
18
+
19
+ import httpx
20
+ import typer
21
+ from rich.cells import cell_len
22
+ from rich.console import Console
23
+ from rich.markup import escape
24
+ from rich.panel import Panel
25
+ from rich.text import Text
26
+
27
+ VERSION = "0.2.0"
28
+ ORIGINS = {
29
+ "dev": "https://api-dev.hookstride.voteeasy.net",
30
+ "production": "https://api.hookstride.voteeasy.net",
31
+ }
32
+ app = typer.Typer(
33
+ help="Hookstride · inspect your projects and webhook deliveries.",
34
+ add_completion=False,
35
+ no_args_is_help=False,
36
+ pretty_exceptions_show_locals=False,
37
+ )
38
+ auth_app = typer.Typer(help="Check Control API authentication.")
39
+ projects_app = typer.Typer(help="List and select a workspace project.")
40
+ destinations_app = typer.Typer(help="Inspect configured webhook destinations.")
41
+ events_app = typer.Typer(help="Inspect delivery history and attempts.")
42
+ billing_app = typer.Typer(help="Inspect project-isolated billing.")
43
+ incoming_app = typer.Typer(help="Inspect incoming webhook sources.")
44
+ keys_app = typer.Typer(help="Inspect masked project API keys.")
45
+ config_app = typer.Typer(help="Inspect local CLI configuration.")
46
+ app.add_typer(auth_app, name="auth")
47
+ app.add_typer(projects_app, name="projects")
48
+ app.add_typer(destinations_app, name="destinations")
49
+ app.add_typer(events_app, name="events")
50
+ app.add_typer(billing_app, name="billing")
51
+ app.add_typer(incoming_app, name="incoming")
52
+ app.add_typer(keys_app, name="keys")
53
+ app.add_typer(config_app, name="config")
54
+
55
+
56
+ class CLIError(Exception):
57
+ """An error safe to display without leaking credentials."""
58
+
59
+
60
+ class Session:
61
+ def __init__(
62
+ self, environment: str, api_url: str | None, project: str | None,
63
+ json_output: bool, no_color: bool,
64
+ ) -> None:
65
+ if environment not in ORIGINS:
66
+ raise CLIError("Choose --env dev or --env production.")
67
+ self.environment = environment
68
+ self.origin = _origin(api_url or ORIGINS[environment])
69
+ self.project = _uuid(project) if project else None
70
+ self.json_output = json_output
71
+ self.console = Console(
72
+ stderr=False, no_color=no_color or json_output,
73
+ force_terminal=False if json_output else None,
74
+ )
75
+
76
+
77
+ def _origin(value: str) -> str:
78
+ try:
79
+ parts = urlsplit(value)
80
+ # urlsplit accepts malformed ports unless the port property is accessed.
81
+ port = parts.port
82
+ hostname = parts.hostname
83
+ except ValueError:
84
+ raise CLIError("Invalid API origin. Specify a hostname and valid port.") from None
85
+ if (
86
+ not hostname or port == 0 or parts.username or parts.password
87
+ or parts.query or parts.fragment or parts.path not in ("", "/")
88
+ ):
89
+ raise CLIError("API URL must be an origin, without path, credentials or query.")
90
+ if parts.scheme != "https" and not (
91
+ parts.scheme == "http" and hostname in ("localhost", "127.0.0.1", "::1")
92
+ ):
93
+ raise CLIError("Use HTTPS for remote APIs; HTTP is allowed only on loopback.")
94
+ return value.rstrip("/")
95
+
96
+
97
+ def _uuid(value: str) -> str:
98
+ try:
99
+ return str(uuid.UUID(value))
100
+ except (ValueError, AttributeError) as exc:
101
+ raise CLIError("Expected a valid project or event UUID.") from exc
102
+
103
+
104
+ def _config_file() -> Path:
105
+ configured = os.environ.get("HOOKSTRIDE_CONFIG_FILE")
106
+ return Path(configured).expanduser() if configured else (
107
+ Path.home() / ".config" / "hookstride" / "settings.json"
108
+ )
109
+
110
+
111
+ def _read_config() -> dict[str, Any]:
112
+ path = _config_file()
113
+ try:
114
+ if not path.exists():
115
+ return {}
116
+ parsed = json.loads(path.read_text(encoding="utf-8"))
117
+ return parsed if isinstance(parsed, dict) else {}
118
+ except (OSError, ValueError) as exc:
119
+ raise CLIError("Cannot read Hookstride CLI settings.") from exc
120
+
121
+
122
+ def _save_project(env: str, project_id: str) -> None:
123
+ settings = _read_config()
124
+ profiles = settings.setdefault("projects", {})
125
+ if not isinstance(profiles, dict):
126
+ profiles = settings["projects"] = {}
127
+ profiles[env] = project_id
128
+ path = _config_file()
129
+ temporary = None
130
+ try:
131
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
132
+ # Replace atomically: don't truncate a working config if interrupted.
133
+ # The temporary file is owner-only, and never contains auth tokens.
134
+ fd, temporary = tempfile.mkstemp(prefix=".settings-", dir=path.parent)
135
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
136
+ os.fchmod(stream.fileno(), 0o600)
137
+ json.dump(settings, stream, indent=2, sort_keys=True)
138
+ stream.write("\n")
139
+ os.replace(temporary, path)
140
+ except OSError as exc:
141
+ raise CLIError("Cannot save Hookstride CLI settings.") from exc
142
+ finally:
143
+ if temporary is not None:
144
+ with contextlib.suppress(FileNotFoundError):
145
+ os.unlink(temporary)
146
+
147
+
148
+ def _session(ctx: typer.Context) -> Session:
149
+ return ctx.obj
150
+
151
+
152
+ def _project(ctx: typer.Context) -> str:
153
+ session = _session(ctx)
154
+ if session.project:
155
+ return session.project
156
+ saved = _read_config().get("projects", {})
157
+ selected = saved.get(session.environment) if isinstance(saved, dict) else None
158
+ if not selected:
159
+ raise CLIError("Select a project with 'hookstride projects use UUID' or --project UUID.")
160
+ return _uuid(selected)
161
+
162
+
163
+ def _token() -> str:
164
+ token = os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").strip()
165
+ if not token.startswith("whrctl_"):
166
+ raise CLIError(
167
+ "Control token missing. Set HOOKSTRIDE_CONTROL_TOKEN to an MFA-verified "
168
+ "management token from the Hookstride dashboard."
169
+ )
170
+ return token
171
+
172
+
173
+ def _request(
174
+ session: Session, path: str, *,
175
+ authenticate: bool = True, params: dict[str, Any] | None = None,
176
+ ) -> Any:
177
+ headers = {"Accept": "application/json"}
178
+ if authenticate:
179
+ headers["Authorization"] = "Bearer " + _token()
180
+ try:
181
+ with httpx.Client(
182
+ base_url=session.origin, headers=headers, timeout=15,
183
+ follow_redirects=False,
184
+ ) as client:
185
+ response = client.get(path, params=params)
186
+ except (httpx.HTTPError, httpx.InvalidURL, ValueError):
187
+ # Never print provider errors: transport exceptions may include headers.
188
+ raise CLIError("Unable to reach the Hookstride API. Check the URL and connection.") from None
189
+ if response.status_code == 401:
190
+ raise CLIError("Authentication rejected. Check that your control token is valid.")
191
+ if response.status_code == 403:
192
+ raise CLIError("Access denied for this account or project (HTTP 403).")
193
+ if response.status_code == 404:
194
+ raise CLIError("Resource not found or not enabled for this project (HTTP 404).")
195
+ if not 200 <= response.status_code < 300:
196
+ raise CLIError(f"API request failed (HTTP {response.status_code}).")
197
+ try:
198
+ return response.json()
199
+ except ValueError as exc:
200
+ raise CLIError("API returned a non-JSON response.") from exc
201
+
202
+
203
+ def _print(ctx: typer.Context, data: Any) -> None:
204
+ session = _session(ctx)
205
+ if session.json_output:
206
+ # ASCII escapes prevent C1/bidi controls from reaching the terminal
207
+ # while retaining all original JSON values for scripts.
208
+ typer.echo(json.dumps(data, indent=2, ensure_ascii=True))
209
+ else:
210
+ session.console.print_json(data=data)
211
+
212
+
213
+ def _safe_terminal_text(value: Any) -> str:
214
+ """Display untrusted values without executable terminal or bidi controls.
215
+
216
+ Rich escaping handles markup, but not ESC/C1/OSC/CSI control sequences.
217
+ Make control/format characters visible as literal Unicode escapes before
218
+ any response field reaches a console renderer. Keep API/JSON values intact.
219
+ """
220
+ safe = []
221
+ for char in str(value):
222
+ number = ord(char)
223
+ if unicodedata.category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"}:
224
+ if number <= 0xFF:
225
+ safe.append(f"\\x{number:02x}")
226
+ elif number <= 0xFFFF:
227
+ safe.append(f"\\u{number:04x}")
228
+ else:
229
+ safe.append(f"\\U{number:08x}")
230
+ else:
231
+ safe.append(char)
232
+ return "".join(safe)
233
+
234
+
235
+ def _plain(value: Any) -> Text:
236
+ return Text("—" if value is None or value == "" else _safe_terminal_text(value))
237
+
238
+
239
+ def _escaped(value: Any) -> str:
240
+ """Render API-controlled values literally inside Rich markup templates."""
241
+ return escape(str(_plain(value)))
242
+
243
+
244
+ def _colored_status(value: Any) -> Text:
245
+ label = _safe_terminal_text(value or "unknown")
246
+ style = {
247
+ "active": "green", "delivered": "green", "succeeded": "green",
248
+ "success": "green", "failed": "red", "revoked": "red",
249
+ "abandoned": "yellow", "pending": "yellow", "delivering": "cyan",
250
+ }.get(label.lower(), "white")
251
+ return Text(label.upper(), style=style)
252
+
253
+
254
+ def _table(
255
+ ctx: typer.Context, title: str, columns: list[str],
256
+ rows: list[list[Any]], *, status_column: int | None = None,
257
+ ) -> None:
258
+ """Render Metasploit-style columns without borders or wrapped identifiers."""
259
+ session = _session(ctx)
260
+ if not rows:
261
+ session.console.print(f"[dim]No {title.lower()} found.[/dim]")
262
+ return
263
+
264
+ styled_rows = [
265
+ [
266
+ _colored_status(value) if i == status_column else _plain(value)
267
+ for i, value in enumerate(values)
268
+ ]
269
+ for values in rows
270
+ ]
271
+ widths = [
272
+ max(cell_len(header), *(row[i].cell_len for row in styled_rows))
273
+ for i, header in enumerate(columns)
274
+ ]
275
+ gap = 2
276
+ available = max(20, session.console.width - 1)
277
+
278
+ # Keep names, dates, plans and statuses readable. On narrow terminals,
279
+ # trim identifiers/references first instead of wrapping them across lines.
280
+ priority = [
281
+ 100 if ("id" in header.lower() or "reference" in header.lower())
282
+ else 50 if header.lower() in {"url", "error"}
283
+ else 0
284
+ for header in columns
285
+ ]
286
+ minimums = [
287
+ min(width, 8 if priority[i] == 100 else 4)
288
+ for i, width in enumerate(widths)
289
+ ]
290
+ while sum(widths) + gap * (len(widths) - 1) > available:
291
+ candidates = [
292
+ i for i in range(len(widths)) if widths[i] > minimums[i]
293
+ ]
294
+ if not candidates:
295
+ break
296
+ index = max(candidates, key=lambda i: (priority[i], widths[i] - minimums[i]))
297
+ widths[index] -= 1
298
+
299
+ session.console.print(title, highlight=False)
300
+ session.console.print("=" * cell_len(title), highlight=False)
301
+
302
+ def row_text(values: list[Text]) -> Text:
303
+ result = Text()
304
+ for i, value in enumerate(values):
305
+ if i:
306
+ result.append(" " * gap)
307
+ rendered = value.copy()
308
+ rendered.truncate(widths[i], overflow="ellipsis")
309
+ result.append(rendered)
310
+ if i < len(values) - 1:
311
+ result.append(" " * max(0, widths[i] - rendered.cell_len))
312
+ return result
313
+
314
+ session.console.print(row_text([Text(col) for col in columns]), highlight=False)
315
+ session.console.print(row_text([Text("-" * size) for size in widths]), highlight=False)
316
+ for values in styled_rows:
317
+ session.console.print(row_text(values), highlight=False)
318
+
319
+
320
+ def _date(value: Any) -> str:
321
+ return str(value or "—")[:19].replace("T", " ")
322
+
323
+
324
+ def _entries(data: Any, label: str) -> list[dict[str, Any]]:
325
+ items = data.get("results") if isinstance(data, dict) else data
326
+ if not isinstance(items, list):
327
+ raise CLIError(f"Unexpected API response for {label}.")
328
+ return [entry for entry in items if isinstance(entry, dict)]
329
+
330
+
331
+ def _print_or_table(
332
+ ctx: typer.Context, data: Any, title: str, columns: list[str],
333
+ rows: list[list[Any]], *, status_column: int | None = None,
334
+ ) -> None:
335
+ if _session(ctx).json_output:
336
+ _print(ctx, data)
337
+ else:
338
+ _table(ctx, title, columns, rows, status_column=status_column)
339
+
340
+
341
+ @app.command("console")
342
+ def console_entry(ctx: typer.Context) -> None:
343
+ """Start the interactive read-only Hookstride operator console."""
344
+ from .console import run_console
345
+
346
+ run_console(_session(ctx))
347
+
348
+
349
+ @app.callback(invoke_without_command=True)
350
+ def root(
351
+ ctx: typer.Context,
352
+ env: str = typer.Option("dev", "--env", help="dev or production."),
353
+ api_url: str | None = typer.Option(None, "--api-url", help="Override the API origin."),
354
+ project: str | None = typer.Option(None, "--project", help="Project UUID override."),
355
+ json_output: bool = typer.Option(False, "--json", help="Machine-readable JSON, no styling."),
356
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors."),
357
+ version: bool = typer.Option(False, "--version", is_eager=True, help="Show CLI version."),
358
+ ) -> None:
359
+ if version:
360
+ typer.echo(f"hookstride {VERSION}")
361
+ raise typer.Exit()
362
+ try:
363
+ ctx.obj = Session(env, api_url, project, json_output, no_color)
364
+ except CLIError as exc:
365
+ typer.echo(f"Error: {exc}", err=True)
366
+ raise typer.Exit(2) from exc
367
+ if ctx.invoked_subcommand is None:
368
+ _session(ctx).console.print(
369
+ Panel.fit(
370
+ "[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CLI[/dim]\n"
371
+ "[green]●[/green] Webhook delivery, from your terminal\n\n"
372
+ "Start with [bold]hookstride auth status[/bold] or "
373
+ "[bold]hookstride projects list[/bold].\n"
374
+ "Run [bold]hookstride --help[/bold] for all commands.",
375
+ border_style="cyan",
376
+ )
377
+ )
378
+
379
+
380
+ @app.command("status")
381
+ def status(ctx: typer.Context) -> None:
382
+ """Check whether Hookstride is online."""
383
+ session = _session(ctx)
384
+ try:
385
+ checks = {
386
+ label: _request(session, f"/{label}/", authenticate=False)
387
+ for label in ("health", "ready")
388
+ }
389
+ health = checks["health"]
390
+ readiness = checks["ready"]
391
+ online = (
392
+ isinstance(health, dict)
393
+ and health.get("status") in {"ok", "healthy"}
394
+ and isinstance(readiness, dict)
395
+ and readiness.get("status") == "ready"
396
+ and (
397
+ not isinstance(readiness.get("checks"), dict)
398
+ or all(value is True for value in readiness["checks"].values())
399
+ )
400
+ )
401
+ except CLIError:
402
+ if session.json_output:
403
+ raise
404
+ online = False
405
+ if session.json_output:
406
+ _print(ctx, checks)
407
+ if not online:
408
+ raise typer.Exit(code=1)
409
+ return
410
+ color = "green" if online else "red"
411
+ label = "Online" if online else "Offline"
412
+ session.console.print(
413
+ Panel.fit(f"[{color}]● {label}[/{color}]", title="Hookstride", border_style=color)
414
+ )
415
+ if not online:
416
+ raise typer.Exit(code=1)
417
+
418
+
419
+ @auth_app.command("status")
420
+ def auth_status(ctx: typer.Context) -> None:
421
+ """Verify your Control API token without printing it."""
422
+ data = _request(_session(ctx), "/control/v1/me/")
423
+ if _session(ctx).json_output:
424
+ # Authentication status never needs to dump membership/user PII.
425
+ _print(ctx, {"authenticated": True, "email": data.get("user", {}).get("email")})
426
+ return
427
+ user = data.get("user", {})
428
+ _session(ctx).console.print(Panel.fit(
429
+ f"[green]✓ Authenticated[/green]\n"
430
+ f"Account: {_escaped(user.get('email'))}\n"
431
+ f"Workspace memberships: {len(data.get('memberships', []))}",
432
+ title="Control API", border_style="green",
433
+ ))
434
+
435
+
436
+ @projects_app.command("list")
437
+ def projects_list(ctx: typer.Context) -> None:
438
+ """List projects accessible to the signed-in account."""
439
+ data = _request(_session(ctx), "/control/v1/projects/")
440
+ items = _entries(data, "projects")
441
+ _print_or_table(
442
+ ctx, data, "Projects", ["Name", "Workspace", "Role", "Plan", "Project ID"],
443
+ [[p.get("name"), p.get("account_name"), p.get("role"),
444
+ (p.get("subscription") or {}).get("plan_code"), p.get("id")]
445
+ for p in items],
446
+ )
447
+
448
+
449
+ @projects_app.command("use")
450
+ def projects_use(ctx: typer.Context, project_id: str = typer.Argument(..., help="Project UUID.")) -> None:
451
+ """Choose a default project for this environment (only saves its UUID)."""
452
+ session = _session(ctx)
453
+ project_id = _uuid(project_id)
454
+ data = _request(session, f"/control/v1/projects/{project_id}/")
455
+ _save_project(session.environment, project_id)
456
+ if session.json_output:
457
+ _print(ctx, {"selected_project": project_id, "name": data.get("name")})
458
+ else:
459
+ session.console.print(
460
+ f"[green]✓[/green] Using [bold]{_escaped(data.get('name'))}[/bold] "
461
+ f"({project_id}) in {session.environment}."
462
+ )
463
+
464
+
465
+ @destinations_app.command("list")
466
+ def destinations_list(ctx: typer.Context, page: int = typer.Option(1, min=1)) -> None:
467
+ """List the selected project's outgoing destinations."""
468
+ project = _project(ctx)
469
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/destinations/", params={"page": page})
470
+ items = _entries(data, "destinations")
471
+ _print_or_table(
472
+ ctx, data, "Destinations", ["Name", "URL", "Active", "Destination ID"],
473
+ [[x.get("name"), x.get("url"), "yes" if x.get("is_active") else "no", x.get("id")]
474
+ for x in items],
475
+ )
476
+
477
+
478
+ @keys_app.command("list")
479
+ def keys_list(ctx: typer.Context) -> None:
480
+ """List API key metadata; the server never returns the raw secret."""
481
+ project = _project(ctx)
482
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/api-keys/")
483
+ items = _entries(data, "API keys")
484
+ _print_or_table(
485
+ ctx, data, "API keys",
486
+ ["Name", "Masked key", "Enabled", "Expires", "Key ID"],
487
+ [[key.get("name"), key.get("masked_key"),
488
+ "yes" if key.get("is_active") else "no",
489
+ _date(key.get("expires_at")), key.get("id")]
490
+ for key in items],
491
+ )
492
+
493
+
494
+ @events_app.command("list")
495
+ def events_list(
496
+ ctx: typer.Context,
497
+ page: int = typer.Option(1, min=1),
498
+ status_filter: str | None = typer.Option(None, "--status", help="delivered, failed, pending..."),
499
+ origin: str | None = typer.Option(None, "--origin", help="api, incoming or replay."),
500
+ search: str | None = typer.Option(None, "--search", help="Event ID, type or destination."),
501
+ ) -> None:
502
+ """List recent delivery activity with optional filters."""
503
+ project = _project(ctx)
504
+ params: dict[str, Any] = {"page": page}
505
+ if status_filter:
506
+ params["status"] = status_filter
507
+ if origin:
508
+ params["origin"] = origin
509
+ if search:
510
+ params["q"] = search
511
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/events/", params=params)
512
+ items = _entries(data, "events")
513
+ _print_or_table(
514
+ ctx, data, "Activity", ["Created", "Event", "Destination", "Status", "Attempts", "Event ID"],
515
+ [[_date(x.get("created_at")), x.get("event_type"), x.get("destination_name"),
516
+ x.get("status"), x.get("attempt_count"), x.get("id")] for x in items],
517
+ status_column=3,
518
+ )
519
+
520
+
521
+ @events_app.command("show")
522
+ def events_show(ctx: typer.Context, event_id: str = typer.Argument(...)) -> None:
523
+ """Inspect an event and its delivery attempts (redacted by the API)."""
524
+ project = _project(ctx)
525
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/events/{_uuid(event_id)}/")
526
+ if _session(ctx).json_output:
527
+ _print(ctx, data)
528
+ return
529
+ session = _session(ctx)
530
+ session.console.print(Panel.fit(
531
+ f"[bold]{_escaped(data.get('event_type'))}[/bold]\n"
532
+ f"Status: {_escaped(str(_colored_status(data.get('status'))))}\n"
533
+ f"Destination: {_escaped(data.get('destination_name'))}\n"
534
+ f"Event ID: {_escaped(data.get('id'))}",
535
+ title="Delivery", border_style="cyan",
536
+ ))
537
+ _table(
538
+ ctx, "Delivery attempts", ["Attempt", "HTTP", "Duration (ms)", "Time", "Error"],
539
+ [[x.get("attempt_number"), x.get("status_code"), x.get("duration_ms"),
540
+ _date(x.get("created_at")), x.get("error")] for x in data.get("attempts", [])],
541
+ )
542
+
543
+
544
+ @billing_app.command("status")
545
+ def billing_status(ctx: typer.Context) -> None:
546
+ """Show the selected project's plan and Flex credit balance."""
547
+ project = _project(ctx)
548
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/billing/")
549
+ if _session(ctx).json_output:
550
+ _print(ctx, data)
551
+ return
552
+ plan = data.get("subscription") or {}
553
+ _session(ctx).console.print(Panel.fit(
554
+ f"Plan: [bold]{_escaped(plan.get('plan_code'))}[/bold]\n"
555
+ f"Status: {_escaped(str(_colored_status(plan.get('status'))))}\n"
556
+ f"Flex credits: {_escaped(plan.get('flex_credits'))}\n"
557
+ f"Current period ends: {_escaped(_date(plan.get('current_period_end')))}\n"
558
+ f"Payment attempts: {len(data.get('records', []))}",
559
+ title="Project Billing", border_style="cyan",
560
+ ))
561
+
562
+
563
+ @billing_app.command("history")
564
+ def billing_history(ctx: typer.Context) -> None:
565
+ """Display per-project Paystack payment attempts and their statuses."""
566
+ project = _project(ctx)
567
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/billing/")
568
+ records = data.get("records", [])
569
+ _print_or_table(
570
+ ctx, records, "Payment history",
571
+ ["Date", "Plan", "Amount", "Status", "Reference"],
572
+ [[_date(r.get("created_at")), r.get("plan_code"),
573
+ f"{r.get('currency', '')} {int(r.get('amount_minor', 0)) / 100:.2f}",
574
+ r.get("status"), r.get("reference")] for r in records],
575
+ status_column=3,
576
+ )
577
+
578
+
579
+ @incoming_app.command("sources")
580
+ def incoming_sources(ctx: typer.Context) -> None:
581
+ """List configured incoming sources, when ingress is enabled."""
582
+ project = _project(ctx)
583
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/sources/")
584
+ items = data.get("sources", [])
585
+ _print_or_table(
586
+ ctx, data, "Incoming sources", ["Name", "Provider", "Routing", "Active", "Source ID"],
587
+ [[s.get("name"), s.get("provider"), s.get("routing"),
588
+ "yes" if s.get("is_active") else "no", s.get("id")] for s in items],
589
+ )
590
+
591
+
592
+ @incoming_app.command("routes")
593
+ def incoming_routes(ctx: typer.Context) -> None:
594
+ """List incoming event-routing rules for the selected project."""
595
+ project = _project(ctx)
596
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/routes/")
597
+ routes = data.get("routes", [])
598
+ _print_or_table(
599
+ ctx, data, "Incoming routes",
600
+ ["Source", "Destination", "Pattern", "Active", "Route ID"],
601
+ [[route.get("source"), route.get("endpoint"),
602
+ route.get("event_pattern"), "yes" if route.get("is_active") else "no",
603
+ route.get("id")] for route in routes],
604
+ )
605
+
606
+
607
+ @incoming_app.command("bindings")
608
+ def incoming_bindings(ctx: typer.Context) -> None:
609
+ """List existing reference-to-destination bindings."""
610
+ project = _project(ctx)
611
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/bindings/")
612
+ bindings = data.get("bindings", [])
613
+ _print_or_table(
614
+ ctx, data, "Incoming bindings",
615
+ ["Reference", "Source", "Destination", "Binding ID"],
616
+ [[item.get("reference"), item.get("source"),
617
+ item.get("endpoint"), item.get("id")] for item in bindings],
618
+ )
619
+
620
+
621
+ @incoming_app.command("registration-keys")
622
+ def incoming_registration_keys(ctx: typer.Context) -> None:
623
+ """List masked incoming registration keys (never raw credentials)."""
624
+ project = _project(ctx)
625
+ data = _request(
626
+ _session(ctx), f"/control/v1/projects/{project}/incoming/registration-keys/"
627
+ )
628
+ keys = data.get("keys", [])
629
+ _print_or_table(
630
+ ctx, data, "Registration keys",
631
+ ["Name", "Masked key", "Active", "Expiry", "Key ID"],
632
+ [[item.get("name"), item.get("masked_key"),
633
+ "yes" if item.get("is_active") else "no",
634
+ _date(item.get("expires_at")), item.get("id")] for item in keys],
635
+ )
636
+
637
+
638
+ @app.command("stats")
639
+ def delivery_stats(ctx: typer.Context) -> None:
640
+ """Show delivery success/failure totals for the selected project."""
641
+ project = _project(ctx)
642
+ data = _request(_session(ctx), f"/control/v1/projects/{project}/delivery-stats/")
643
+ if _session(ctx).json_output:
644
+ _print(ctx, data)
645
+ return
646
+ counts = data.get("events", {})
647
+ _table(
648
+ ctx, "Delivery overview", ["Total", "Delivered", "Failed", "Pending", "Success"],
649
+ [[counts.get("total"), counts.get("delivered"), counts.get("failed"),
650
+ counts.get("pending"), f"{counts.get('success_rate', 0)}%"]],
651
+ )
652
+
653
+
654
+ @config_app.command("show")
655
+ def config_show(ctx: typer.Context) -> None:
656
+ """Print the active API/profile settings, never the token value."""
657
+ session = _session(ctx)
658
+ saved = _read_config().get("projects", {})
659
+ selected = session.project or (saved.get(session.environment) if isinstance(saved, dict) else None)
660
+ data = {
661
+ "environment": session.environment,
662
+ "api_url": session.origin,
663
+ "project": selected,
664
+ "token_configured": bool(os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_")),
665
+ "token_source": "environment" if os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_") else None,
666
+ }
667
+ if session.json_output:
668
+ _print(ctx, data)
669
+ else:
670
+ _table(ctx, "Configuration", ["Setting", "Value"], [[k, v] for k, v in data.items()])
671
+
672
+
673
+ def main() -> None:
674
+ try:
675
+ app()
676
+ except CLIError as exc:
677
+ # Error messages are intentionally generic. No auth tokens or response bodies.
678
+ is_json = "--json" in sys.argv[1:]
679
+ if is_json:
680
+ typer.echo(json.dumps({"error": str(exc)}), err=True)
681
+ else:
682
+ Console(stderr=True).print(f"[red]✗[/red] {exc}")
683
+ raise SystemExit(1) from exc
684
+
685
+
686
+ if __name__ == "__main__":
687
+ main()
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.5
2
+ Name: hookstride-cli
3
+ Version: 0.2.0
4
+ Summary: Readable, project-scoped Hookstride Control API terminal client.
5
+ License: Proprietary
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: click<9,>=8.1
8
+ Requires-Dist: httpx<1,>=0.28
9
+ Requires-Dist: prompt-toolkit<4,>=3.0.52
10
+ Requires-Dist: rich<15,>=14
11
+ Requires-Dist: typer<1,>=0.20
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Hookstride CLI
15
+
16
+ A fast, colorful, read-only terminal for the Hookstride Control API. Inspired by the clarity of tools like Nmap, it keeps ordinary REST responses unchanged for apps and integrations.
17
+
18
+ ## Install
19
+
20
+ Once the first package release is available on PyPI, install with:
21
+
22
+ ```bash
23
+ uv tool install hookstride-cli
24
+ hookstride --version
25
+ hookstride console
26
+ ```
27
+
28
+ Alternative: `pipx install hookstride-cli`. Update with `uv tool upgrade hookstride-cli`.
29
+ Requires Python 3.11 or newer. No checkout or Django backend installation is needed.
30
+
31
+ ## Authentication
32
+
33
+ In the Hookstride dashboard, generate an MFA-verified **Control API / Management token**, beginning with `whrctl_`. Do **not** use a `whrk_` delivery API key, and never paste a token directly into commands or issue comments.
34
+
35
+ The CLI reads `HOOKSTRIDE_CONTROL_TOKEN` from your current shell; it **does not persist tokens** on disk. For a Bash/Zsh terminal, paste interactively without writing the token in your command history:
36
+
37
+ ```bash
38
+ printf "Control token: "
39
+ read -rs HOOKSTRIDE_CONTROL_TOKEN
40
+ printf "\\n"
41
+ export HOOKSTRIDE_CONTROL_TOKEN
42
+
43
+ hookstride auth status
44
+ ```
45
+
46
+ The token may remain in your shell environment until you run `unset HOOKSTRIDE_CONTROL_TOKEN`. Environment values may be inspectable by processes with the appropriate local privileges; use an ephemeral, limited-life management token. Avoid recording terminal sessions while entering secrets.
47
+
48
+ ## Explore development
49
+
50
+ ```bash
51
+ hookstride status
52
+ hookstride auth status
53
+ hookstride projects list
54
+ hookstride projects use 00000000-0000-0000-0000-000000000000
55
+
56
+ hookstride destinations list
57
+ hookstride keys list
58
+ hookstride incoming sources
59
+ hookstride incoming routes
60
+ hookstride incoming bindings
61
+ hookstride incoming registration-keys
62
+ hookstride events list --status failed
63
+ hookstride events show 00000000-0000-0000-0000-000000000000
64
+ hookstride stats
65
+ hookstride billing status
66
+ hookstride billing history
67
+ hookstride config show
68
+ ```
69
+
70
+ Replace the sample UUIDs with your own project/event IDs.
71
+
72
+ The CLI starts on `https://api-dev.hookstride.voteeasy.net`. Use `--env production` (before the command) to target `https://api.hookstride.voteeasy.net`; configure each environment with its own corresponding Control token. The apex `https://hookstride.voteeasy.net` serves the frontend. You can also use `--api-url https://your-api-host` for a custom origin, or HTTP on loopback for local testing.
73
+
74
+ **Project selection is environment-specific.** The only persisted data is a project UUID in `~/.config/hookstride/settings.json`, owner-readable; not your credentials. Override temporarily with `--project PROJECT_UUID`.
75
+
76
+ ## Development checks
77
+
78
+ When the GitHub Actions runner is unavailable, run the **same** CLI tests locally
79
+ without sending anything to the live Hookstride API:
80
+
81
+ ```bash
82
+ bash cli/scripts/check.sh
83
+ ```
84
+
85
+ This checks Python syntax, Ruff, isolated unit tests using mocked HTTP responses,
86
+ and basic commands. A successful local run is not a substitute for a live
87
+ read-only smoke test against the deployed development API.
88
+
89
+ Tables use compact, borderless columns. Identifiers and long URLs are shortened
90
+ only when necessary to fit the terminal; the actual API response is not changed.
91
+ Use `--json` to copy complete project IDs, payment references and destination URLs.
92
+
93
+ ## Interactive operator console (v0.2)
94
+
95
+ Start a lightweight, Metasploit-inspired, **read-only** interactive console:
96
+
97
+ ```bash
98
+ hookstride console
99
+ ```
100
+
101
+ The shell starts on the **development API** by default. Enter `help` for
102
+ available commands, use Tab for completion, Ctrl+C to cancel the current line,
103
+ and Ctrl+D or `exit` to close the console.
104
+
105
+ ```text
106
+ HOOKSTRIDE CONSOLE v0.2
107
+ hookstride [dev] > auth login
108
+ Control token (hidden):
109
+ ✓ Authenticated as developer@example.com
110
+ hookstride [dev] > show projects
111
+ Projects
112
+ ========
113
+ Name Workspace Role Plan Project ID
114
+ ---------- ------------ ----- ------ ------------------------------------
115
+ Production Beta Testing owner flex ddc21845-d0f1-49af-8d35-2719dc641f69
116
+
117
+ hookstride [dev] > use ddc21845-d0f1-49af-8d35-2719dc641f69
118
+ hookstride [dev:ddc21845] > show payments
119
+ hookstride [dev:ddc21845] > events list --status failed
120
+ hookstride [dev:ddc21845] > show destinations
121
+ hookstride [dev:ddc21845] > json events list
122
+ hookstride [dev:ddc21845] > back
123
+ hookstride [dev] > exit
124
+ ```
125
+
126
+ This console accepts the existing read-only CLI commands, plus convenient
127
+ `show` aliases, `set env dev|production`, `use UUID`,
128
+ `back`, `clear`, `history`, and `auth login|logout`.
129
+ `projects use UUID` and `use UUID` persist only the active project's UUID
130
+ in local settings. The console does not support a shell escape, external
131
+ processes, POST/DELETE operations, or purchases.
132
+
133
+ ### Token handling
134
+
135
+ `auth login` securely prompts for a Control API token via hidden input. Tokens
136
+ are kept **in memory for this console process only**, are discarded at exit,
137
+ are never written to a config or history file, and are never echoed. The
138
+ console stores only a **safe subset** of commands in volatile arrow-key history;
139
+ it never records tokens or arbitrary query/search strings.
140
+
141
+ You may also provide an existing token through environment variables:
142
+
143
+ ```bash
144
+ # Use a development token with the default development console
145
+ export HOOKSTRIDE_DEV_CONTROL_TOKEN="…" # do not put real secrets in shell history
146
+ hookstride console
147
+
148
+ # To start directly against production, explicitly select it
149
+ hookstride --env production console
150
+ ```
151
+
152
+ For safest handling, prefer `auth login` over typing tokens in shell commands.
153
+ For interactive environment changes, the generic legacy
154
+ `HOOKSTRIDE_CONTROL_TOKEN` is **never forwarded to another environment**.
155
+ Use `auth login` separately after `set env production`, or provide a
156
+ matching `HOOKSTRIDE_PRODUCTION_CONTROL_TOKEN` token. This prevents a dev
157
+ token from accidentally being sent to the production API.
158
+
159
+ The console requires a real interactive terminal and refuses `--json console`.
160
+ For CI and scripting, use ordinary commands such as
161
+ `hookstride --json events list` instead.
162
+
163
+ ## Output for scripts
164
+
165
+ Pass `--json` **before** the command:
166
+
167
+ ```bash
168
+ hookstride --json projects list | jq .
169
+ hookstride --json events list --status failed | jq '.results'
170
+ hookstride --env production --json billing history
171
+ ```
172
+
173
+ The raw structured response is printed to stdout without terminal formatting. Network and HTTP errors go to stderr and cause a nonzero exit status.
174
+
175
+ ## Scope
176
+
177
+ Version 0.2 includes interactive access to project, destination, incoming-source, delivery-stat, activity and billing **reads**. It does not yet create destinations, emit events, replay events, rotate credentials, or trigger payments. Those workflows require additional confirmation and integration tests before exposing them in a CLI. Server-side authorization, project scoping, and response redaction remain enforced by the Control API.
178
+
179
+ Do not store passwords, signing secrets, private keys, or Paystack keys in Hookstride CLI configuration.
@@ -0,0 +1,7 @@
1
+ hookstride_cli/__init__.py,sha256=QF0aMDPMekvBntMFgFH6tE4BkHN7vXuw45c6RMu4BOw,55
2
+ hookstride_cli/console.py,sha256=uB4gqFlIwUaajYJ0Q_Mt4KOLImGEVbDFKBaDKJ2zgYU,18701
3
+ hookstride_cli/main.py,sha256=ZS07efEzUQrfTBtvfKjct6YqHiUTRSDmhPhIccQU7_s,26191
4
+ hookstride_cli-0.2.0.dist-info/METADATA,sha256=vC6z5xwcSjhr0loFni9yYJvvGo_rLqwurOssCG9h8Mg,7347
5
+ hookstride_cli-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ hookstride_cli-0.2.0.dist-info/entry_points.txt,sha256=E9bqvnCFVO-lYULID8x-HTp0gYnfrYprB2R8n-DKy6Q,56
7
+ hookstride_cli-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hookstride = hookstride_cli.main:main