hookstride-cli 0.2.0__py3-none-any.whl → 0.3.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.
@@ -1,3 +1,3 @@
1
1
  """Hookstride developer CLI."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
hookstride_cli/console.py CHANGED
@@ -1,8 +1,7 @@
1
- """Interactive, read-only Hookstride console.
1
+ """Interactive Hookstride console with allowlisted project and destination writes.
2
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.
3
+ The console never executes shell commands, retains tokens only in memory,
4
+ and stores no command history on disk.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -25,11 +24,11 @@ from typer.main import get_command
25
24
  from . import main as cli
26
25
 
27
26
  # 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]] = {
27
+ # Write actions are intentionally allowlisted, never automatically unlocked.
28
+ ALLOWED_COMMANDS: dict[str, set[str]] = {
30
29
  "auth": {"status"},
31
- "projects": {"list", "use"},
32
- "destinations": {"list"},
30
+ "projects": {"list", "use", "create", "update"},
31
+ "destinations": {"list", "create", "update"},
33
32
  "keys": {"list"},
34
33
  "events": {"list", "show"},
35
34
  "billing": {"status", "history"},
@@ -38,7 +37,7 @@ READ_COMMANDS: dict[str, set[str]] = {
38
37
  }
39
38
  ROOT_COMMANDS = {"status", "stats"}
40
39
 
41
- # Aliases that feel like a familiar operator console, but remain read-only.
40
+ # Aliases that feel like a familiar operator console.
42
41
  SHOW_TARGETS: dict[str, tuple[str, ...] | None] = {
43
42
  "options": None,
44
43
  "projects": ("projects", "list"),
@@ -72,6 +71,8 @@ COMMAND_COMPLETIONS = sorted(
72
71
  *SAFE_HISTORY_COMMANDS,
73
72
  "use ", "projects use ", "events show ", "events list --status ",
74
73
  "events list --origin ", "events list --page ", "set project ",
74
+ "projects create --name ", "projects update ",
75
+ "destinations create --name ", "destinations update ",
75
76
  "set env ", "json ", "show ", "auth ",
76
77
  }
77
78
  )
@@ -194,6 +195,8 @@ class HookstrideConsole:
194
195
  "[bold]help[/bold] Show console help\n"
195
196
  "[bold]show options[/bold] Display environment, project and authentication\n"
196
197
  "[bold]show projects[/bold] List accessible projects\n"
198
+ "[bold]projects create --name N --account UUID[/bold] Create a project\n"
199
+ "[bold]projects update UUID --name N[/bold] Rename a project\n"
197
200
  "[bold]use UUID[/bold] Select a project (also saves preference)\n"
198
201
  "[bold]back[/bold] Leave the active project context\n"
199
202
  "[bold]set env dev[/bold] Switch to development API\n"
@@ -202,6 +205,8 @@ class HookstrideConsole:
202
205
  "[bold]auth logout[/bold] Clear the current environment's session token\n"
203
206
  "[bold]auth status[/bold] Validate current token\n"
204
207
  "[bold]show destinations[/bold] Inspect outgoing webhooks\n"
208
+ "[bold]destinations create --name N --url HTTPS_URL[/bold] Add receiver\n"
209
+ "[bold]destinations update UUID --pause[/bold] Pause a receiver\n"
205
210
  "[bold]show events[/bold] View recent events\n"
206
211
  "[bold]events list --status failed[/bold] Filter delivery history\n"
207
212
  "[bold]events show UUID[/bold] Inspect a delivery and attempts\n"
@@ -223,7 +228,6 @@ class HookstrideConsole:
223
228
  ["API", self.session.origin],
224
229
  ["Project", project],
225
230
  ["Authenticated token", "configured" if self._current_token() else "missing"],
226
- ["Mode", "read-only"],
227
231
  ]
228
232
  cli._table(_FakeContext(self.session), "Session options", ["Setting", "Value"], rows)
229
233
 
@@ -294,7 +298,7 @@ class HookstrideConsole:
294
298
  self._write("[green]✓[/green] Signed out for this console environment.")
295
299
 
296
300
  def _execute_cli(self, args: list[str], *, json_output: bool = False) -> None:
297
- """Invoke existing read-only Click/Typer commands, never a shell."""
301
+ """Invoke explicitly allowed Click/Typer commands, never a shell."""
298
302
  if not _allowed_cli(args):
299
303
  raise cli.CLIError("Unknown or unavailable command. Type 'help'.")
300
304
  group = args[0]
@@ -382,7 +386,7 @@ class HookstrideConsole:
382
386
 
383
387
  def run(self) -> None:
384
388
  self._write(
385
- "\n[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CONSOLE v0.2[/dim]\n"
389
+ f"\n[bold cyan]HOOKSTRIDE[/bold cyan] [dim]CONSOLE v{cli.VERSION}[/dim]\n"
386
390
  "[dim]Type 'help' for commands, 'auth login' for a token, "
387
391
  "or 'exit' to quit.[/dim]\n"
388
392
  )
@@ -431,8 +435,8 @@ def _allowed_cli(parts: list[str]) -> bool:
431
435
  return len(parts) == 1
432
436
  return (
433
437
  len(parts) >= 2
434
- and parts[0] in READ_COMMANDS
435
- and parts[1] in READ_COMMANDS[parts[0]]
438
+ and parts[0] in ALLOWED_COMMANDS
439
+ and parts[1] in ALLOWED_COMMANDS[parts[0]]
436
440
  )
437
441
 
438
442
 
hookstride_cli/main.py CHANGED
@@ -1,6 +1,6 @@
1
- """Hookstride CLI: developer-friendly, project-scoped Control API inspection.
1
+ """Hookstride CLI: project-scoped Control API management.
2
2
 
3
- Read-only commands use control tokens. This module never persists a token.
3
+ Commands use Control API tokens. This module never persists a token.
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -24,20 +24,22 @@ from rich.markup import escape
24
24
  from rich.panel import Panel
25
25
  from rich.text import Text
26
26
 
27
- VERSION = "0.2.0"
27
+ from . import __version__
28
+
29
+ VERSION = __version__
28
30
  ORIGINS = {
29
- "dev": "https://api-dev.hookstride.voteeasy.net",
30
- "production": "https://api.hookstride.voteeasy.net",
31
+ "dev": "https://api-dev.hookstride.com",
32
+ "production": "https://api.hookstride.com",
31
33
  }
32
34
  app = typer.Typer(
33
- help="Hookstride · inspect your projects and webhook deliveries.",
35
+ help="Hookstride · manage projects and webhook destinations.",
34
36
  add_completion=False,
35
37
  no_args_is_help=False,
36
38
  pretty_exceptions_show_locals=False,
37
39
  )
38
40
  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
+ projects_app = typer.Typer(help="List, create, update and select workspace projects.")
42
+ destinations_app = typer.Typer(help="List, create and update webhook destinations.")
41
43
  events_app = typer.Typer(help="Inspect delivery history and attempts.")
42
44
  billing_app = typer.Typer(help="Inspect project-isolated billing.")
43
45
  incoming_app = typer.Typer(help="Inspect incoming webhook sources.")
@@ -59,8 +61,12 @@ class CLIError(Exception):
59
61
 
60
62
  class Session:
61
63
  def __init__(
62
- self, environment: str, api_url: str | None, project: str | None,
63
- json_output: bool, no_color: bool,
64
+ self,
65
+ environment: str,
66
+ api_url: str | None,
67
+ project: str | None,
68
+ json_output: bool,
69
+ no_color: bool,
64
70
  ) -> None:
65
71
  if environment not in ORIGINS:
66
72
  raise CLIError("Choose --env dev or --env production.")
@@ -69,7 +75,8 @@ class Session:
69
75
  self.project = _uuid(project) if project else None
70
76
  self.json_output = json_output
71
77
  self.console = Console(
72
- stderr=False, no_color=no_color or json_output,
78
+ stderr=False,
79
+ no_color=no_color or json_output,
73
80
  force_terminal=False if json_output else None,
74
81
  )
75
82
 
@@ -81,10 +88,17 @@ def _origin(value: str) -> str:
81
88
  port = parts.port
82
89
  hostname = parts.hostname
83
90
  except ValueError:
84
- raise CLIError("Invalid API origin. Specify a hostname and valid port.") from None
91
+ raise CLIError(
92
+ "Invalid API origin. Specify a hostname and valid port."
93
+ ) from None
85
94
  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 ("", "/")
95
+ not hostname
96
+ or port == 0
97
+ or parts.username
98
+ or parts.password
99
+ or parts.query
100
+ or parts.fragment
101
+ or parts.path not in ("", "/")
88
102
  ):
89
103
  raise CLIError("API URL must be an origin, without path, credentials or query.")
90
104
  if parts.scheme != "https" and not (
@@ -98,13 +112,15 @@ def _uuid(value: str) -> str:
98
112
  try:
99
113
  return str(uuid.UUID(value))
100
114
  except (ValueError, AttributeError) as exc:
101
- raise CLIError("Expected a valid project or event UUID.") from exc
115
+ raise CLIError("Expected a valid UUID.") from exc
102
116
 
103
117
 
104
118
  def _config_file() -> Path:
105
119
  configured = os.environ.get("HOOKSTRIDE_CONFIG_FILE")
106
- return Path(configured).expanduser() if configured else (
107
- Path.home() / ".config" / "hookstride" / "settings.json"
120
+ return (
121
+ Path(configured).expanduser()
122
+ if configured
123
+ else (Path.home() / ".config" / "hookstride" / "settings.json")
108
124
  )
109
125
 
110
126
 
@@ -156,7 +172,9 @@ def _project(ctx: typer.Context) -> str:
156
172
  saved = _read_config().get("projects", {})
157
173
  selected = saved.get(session.environment) if isinstance(saved, dict) else None
158
174
  if not selected:
159
- raise CLIError("Select a project with 'hookstride projects use UUID' or --project UUID.")
175
+ raise CLIError(
176
+ "Select a project with 'hookstride projects use UUID' or --project UUID."
177
+ )
160
178
  return _uuid(selected)
161
179
 
162
180
 
@@ -171,23 +189,39 @@ def _token() -> str:
171
189
 
172
190
 
173
191
  def _request(
174
- session: Session, path: str, *,
175
- authenticate: bool = True, params: dict[str, Any] | None = None,
192
+ session: Session,
193
+ path: str,
194
+ *,
195
+ authenticate: bool = True,
196
+ params: dict[str, Any] | None = None,
197
+ method: str = "GET",
198
+ json_body: dict[str, Any] | None = None,
176
199
  ) -> Any:
200
+ if method not in {"GET", "POST", "PATCH"}:
201
+ raise CLIError("Unsupported API request method.")
177
202
  headers = {"Accept": "application/json"}
178
203
  if authenticate:
179
204
  headers["Authorization"] = "Bearer " + _token()
180
205
  try:
181
206
  with httpx.Client(
182
- base_url=session.origin, headers=headers, timeout=15,
207
+ base_url=session.origin,
208
+ headers=headers,
209
+ timeout=15,
183
210
  follow_redirects=False,
184
211
  ) as client:
185
- response = client.get(path, params=params)
212
+ if method == "GET":
213
+ response = client.get(path, params=params)
214
+ else:
215
+ response = client.request(method, path, json=json_body)
186
216
  except (httpx.HTTPError, httpx.InvalidURL, ValueError):
187
217
  # 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
218
+ raise CLIError(
219
+ "Unable to reach the Hookstride API. Check the URL and connection."
220
+ ) from None
189
221
  if response.status_code == 401:
190
- raise CLIError("Authentication rejected. Check that your control token is valid.")
222
+ raise CLIError(
223
+ "Authentication rejected. Check that your control token is valid."
224
+ )
191
225
  if response.status_code == 403:
192
226
  raise CLIError("Access denied for this account or project (HTTP 403).")
193
227
  if response.status_code == 404:
@@ -244,16 +278,26 @@ def _escaped(value: Any) -> str:
244
278
  def _colored_status(value: Any) -> Text:
245
279
  label = _safe_terminal_text(value or "unknown")
246
280
  style = {
247
- "active": "green", "delivered": "green", "succeeded": "green",
248
- "success": "green", "failed": "red", "revoked": "red",
249
- "abandoned": "yellow", "pending": "yellow", "delivering": "cyan",
281
+ "active": "green",
282
+ "delivered": "green",
283
+ "succeeded": "green",
284
+ "success": "green",
285
+ "failed": "red",
286
+ "revoked": "red",
287
+ "abandoned": "yellow",
288
+ "pending": "yellow",
289
+ "delivering": "cyan",
250
290
  }.get(label.lower(), "white")
251
291
  return Text(label.upper(), style=style)
252
292
 
253
293
 
254
294
  def _table(
255
- ctx: typer.Context, title: str, columns: list[str],
256
- rows: list[list[Any]], *, status_column: int | None = None,
295
+ ctx: typer.Context,
296
+ title: str,
297
+ columns: list[str],
298
+ rows: list[list[Any]],
299
+ *,
300
+ status_column: int | None = None,
257
301
  ) -> None:
258
302
  """Render Metasploit-style columns without borders or wrapped identifiers."""
259
303
  session = _session(ctx)
@@ -278,19 +322,18 @@ def _table(
278
322
  # Keep names, dates, plans and statuses readable. On narrow terminals,
279
323
  # trim identifiers/references first instead of wrapping them across lines.
280
324
  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
325
+ (
326
+ 100
327
+ if ("id" in header.lower() or "reference" in header.lower())
328
+ else 50 if header.lower() in {"url", "error"} else 0
329
+ )
284
330
  for header in columns
285
331
  ]
286
332
  minimums = [
287
- min(width, 8 if priority[i] == 100 else 4)
288
- for i, width in enumerate(widths)
333
+ min(width, 8 if priority[i] == 100 else 4) for i, width in enumerate(widths)
289
334
  ]
290
335
  while sum(widths) + gap * (len(widths) - 1) > available:
291
- candidates = [
292
- i for i in range(len(widths)) if widths[i] > minimums[i]
293
- ]
336
+ candidates = [i for i in range(len(widths)) if widths[i] > minimums[i]]
294
337
  if not candidates:
295
338
  break
296
339
  index = max(candidates, key=lambda i: (priority[i], widths[i] - minimums[i]))
@@ -312,7 +355,9 @@ def _table(
312
355
  return result
313
356
 
314
357
  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)
358
+ session.console.print(
359
+ row_text([Text("-" * size) for size in widths]), highlight=False
360
+ )
316
361
  for values in styled_rows:
317
362
  session.console.print(row_text(values), highlight=False)
318
363
 
@@ -329,8 +374,13 @@ def _entries(data: Any, label: str) -> list[dict[str, Any]]:
329
374
 
330
375
 
331
376
  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,
377
+ ctx: typer.Context,
378
+ data: Any,
379
+ title: str,
380
+ columns: list[str],
381
+ rows: list[list[Any]],
382
+ *,
383
+ status_column: int | None = None,
334
384
  ) -> None:
335
385
  if _session(ctx).json_output:
336
386
  _print(ctx, data)
@@ -340,7 +390,7 @@ def _print_or_table(
340
390
 
341
391
  @app.command("console")
342
392
  def console_entry(ctx: typer.Context) -> None:
343
- """Start the interactive read-only Hookstride operator console."""
393
+ """Start the interactive Hookstride operator console."""
344
394
  from .console import run_console
345
395
 
346
396
  run_console(_session(ctx))
@@ -349,12 +399,20 @@ def console_entry(ctx: typer.Context) -> None:
349
399
  @app.callback(invoke_without_command=True)
350
400
  def root(
351
401
  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."),
402
+ env: str = typer.Option("production", "--env", help="production (default) or dev."),
403
+ api_url: str | None = typer.Option(
404
+ None, "--api-url", help="Override the API origin."
405
+ ),
406
+ project: str | None = typer.Option(
407
+ None, "--project", help="Project UUID override."
408
+ ),
409
+ json_output: bool = typer.Option(
410
+ False, "--json", help="Machine-readable JSON, no styling."
411
+ ),
356
412
  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."),
413
+ version: bool = typer.Option(
414
+ False, "--version", is_eager=True, help="Show CLI version."
415
+ ),
358
416
  ) -> None:
359
417
  if version:
360
418
  typer.echo(f"hookstride {VERSION}")
@@ -410,7 +468,9 @@ def status(ctx: typer.Context) -> None:
410
468
  color = "green" if online else "red"
411
469
  label = "Online" if online else "Offline"
412
470
  session.console.print(
413
- Panel.fit(f"[{color}]● {label}[/{color}]", title="Hookstride", border_style=color)
471
+ Panel.fit(
472
+ f"[{color}]● {label}[/{color}]", title="Hookstride", border_style=color
473
+ )
414
474
  )
415
475
  if not online:
416
476
  raise typer.Exit(code=1)
@@ -425,12 +485,15 @@ def auth_status(ctx: typer.Context) -> None:
425
485
  _print(ctx, {"authenticated": True, "email": data.get("user", {}).get("email")})
426
486
  return
427
487
  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
- ))
488
+ _session(ctx).console.print(
489
+ Panel.fit(
490
+ f"[green]✓ Authenticated[/green]\n"
491
+ f"Account: {_escaped(user.get('email'))}\n"
492
+ f"Workspace memberships: {len(data.get('memberships', []))}",
493
+ title="Control API",
494
+ border_style="green",
495
+ )
496
+ )
434
497
 
435
498
 
436
499
  @projects_app.command("list")
@@ -439,15 +502,27 @@ def projects_list(ctx: typer.Context) -> None:
439
502
  data = _request(_session(ctx), "/control/v1/projects/")
440
503
  items = _entries(data, "projects")
441
504
  _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],
505
+ ctx,
506
+ data,
507
+ "Projects",
508
+ ["Name", "Workspace", "Role", "Plan", "Project ID"],
509
+ [
510
+ [
511
+ p.get("name"),
512
+ p.get("account_name"),
513
+ p.get("role"),
514
+ (p.get("subscription") or {}).get("plan_code"),
515
+ p.get("id"),
516
+ ]
517
+ for p in items
518
+ ],
446
519
  )
447
520
 
448
521
 
449
522
  @projects_app.command("use")
450
- def projects_use(ctx: typer.Context, project_id: str = typer.Argument(..., help="Project UUID.")) -> None:
523
+ def projects_use(
524
+ ctx: typer.Context, project_id: str = typer.Argument(..., help="Project UUID.")
525
+ ) -> None:
451
526
  """Choose a default project for this environment (only saves its UUID)."""
452
527
  session = _session(ctx)
453
528
  project_id = _uuid(project_id)
@@ -466,13 +541,170 @@ def projects_use(ctx: typer.Context, project_id: str = typer.Argument(..., help=
466
541
  def destinations_list(ctx: typer.Context, page: int = typer.Option(1, min=1)) -> None:
467
542
  """List the selected project's outgoing destinations."""
468
543
  project = _project(ctx)
469
- data = _request(_session(ctx), f"/control/v1/projects/{project}/destinations/", params={"page": page})
544
+ data = _request(
545
+ _session(ctx),
546
+ f"/control/v1/projects/{project}/destinations/",
547
+ params={"page": page},
548
+ )
470
549
  items = _entries(data, "destinations")
471
550
  _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],
551
+ ctx,
552
+ data,
553
+ "Destinations",
554
+ ["Name", "URL", "Active", "Destination ID"],
555
+ [
556
+ [
557
+ x.get("name"),
558
+ x.get("url"),
559
+ "yes" if x.get("is_active") else "no",
560
+ x.get("id"),
561
+ ]
562
+ for x in items
563
+ ],
564
+ )
565
+
566
+
567
+ def _name(value: str, label: str) -> str:
568
+ value = value.strip()
569
+ if not value or len(value) > 120:
570
+ raise CLIError(f"{label} must be between 1 and 120 characters.")
571
+ return value
572
+
573
+
574
+ def _webhook_url(value: str) -> str:
575
+ """Basic local validation; the server also enforces public-URL SSRF policy."""
576
+ if len(value) > 500:
577
+ raise CLIError("Destination URL must not exceed 500 characters.")
578
+ try:
579
+ url = urlsplit(value)
580
+ port = url.port
581
+ hostname = url.hostname
582
+ except ValueError:
583
+ raise CLIError("Invalid destination HTTPS URL.") from None
584
+ if (
585
+ url.scheme != "https"
586
+ or not hostname
587
+ or port == 0
588
+ or url.username
589
+ or url.password
590
+ or url.fragment
591
+ or any(ch.isspace() for ch in value)
592
+ ):
593
+ raise CLIError("Destination must have a valid public HTTPS URL without credentials or fragment.")
594
+ return value
595
+
596
+
597
+ def _result(ctx: typer.Context, title: str, data: Any, *, secret: str | None = None) -> None:
598
+ if not isinstance(data, dict):
599
+ raise CLIError("Unexpected API response.")
600
+ if _session(ctx).json_output:
601
+ _print(ctx, data)
602
+ return
603
+ session = _session(ctx)
604
+ session.console.print(f"[green]✓[/green] {title}")
605
+ for key in ("id", "name", "slug", "url", "is_active", "account_name"):
606
+ if key in data:
607
+ session.console.print(f" {key.replace('_', ' ').title()}: {_escaped(data[key])}")
608
+ if secret is not None:
609
+ # Shown once and never saved to the CLI config or command history.
610
+ session.console.print("[yellow]Signing secret (save this now; it cannot be retrieved later):[/yellow]")
611
+ session.console.print(_plain(secret), highlight=False)
612
+
613
+
614
+ @projects_app.command("create")
615
+ def projects_create(
616
+ ctx: typer.Context,
617
+ name: str = typer.Option(..., "--name", help="New project name."),
618
+ account: str = typer.Option(..., "--account", help="Workspace account UUID from projects list."),
619
+ slug: str | None = typer.Option(None, "--slug", help="Optional project slug."),
620
+ ) -> None:
621
+ """Create a project in a workspace where you are an admin or owner."""
622
+ payload = {"account_id": _uuid(account), "name": _name(name, "Project name")}
623
+ if slug is not None:
624
+ if not slug.strip():
625
+ raise CLIError("Project slug cannot be blank.")
626
+ payload["slug"] = slug.strip()
627
+ data = _request(_session(ctx), "/control/v1/projects/", method="POST", json_body=payload)
628
+ _result(ctx, "Project created", data)
629
+
630
+
631
+ @projects_app.command("update")
632
+ def projects_update(
633
+ ctx: typer.Context,
634
+ project_id: str = typer.Argument(..., help="Project UUID."),
635
+ name: str | None = typer.Option(None, "--name", help="New project name."),
636
+ slug: str | None = typer.Option(None, "--slug", help="New project slug."),
637
+ ) -> None:
638
+ """Rename a project or update its slug (admin/owner only)."""
639
+ changes: dict[str, Any] = {}
640
+ if name is not None:
641
+ changes["name"] = _name(name, "Project name")
642
+ if slug is not None:
643
+ if not slug.strip():
644
+ raise CLIError("Project slug cannot be blank.")
645
+ changes["slug"] = slug.strip()
646
+ if not changes:
647
+ raise CLIError("Specify --name and/or --slug.")
648
+ project_id = _uuid(project_id)
649
+ data = _request(
650
+ _session(ctx), f"/control/v1/projects/{project_id}/",
651
+ method="PATCH", json_body=changes,
652
+ )
653
+ _result(ctx, "Project updated", data)
654
+
655
+
656
+ @destinations_app.command("create")
657
+ def destinations_create(
658
+ ctx: typer.Context,
659
+ name: str = typer.Option(..., "--name", help="Destination name."),
660
+ url: str = typer.Option(..., "--url", help="Public HTTPS webhook receiver."),
661
+ ) -> None:
662
+ """Create a destination and display its signing secret exactly once."""
663
+ project = _project(ctx)
664
+ data = _request(
665
+ _session(ctx), f"/control/v1/projects/{project}/destinations/",
666
+ method="POST", json_body={"name": _name(name, "Destination name"), "url": _webhook_url(url)},
667
+ )
668
+ if not isinstance(data, dict) or not isinstance(data.get("destination"), dict):
669
+ raise CLIError("Unexpected destination creation response.")
670
+ secret = data.get("signing_secret")
671
+ if not isinstance(secret, str) or not secret:
672
+ raise CLIError("Destination created but the signing secret was not returned. Rotate it in the dashboard.")
673
+ if _session(ctx).json_output:
674
+ _print(ctx, data)
675
+ else:
676
+ _result(ctx, "Destination created", data["destination"], secret=secret)
677
+
678
+
679
+ @destinations_app.command("update")
680
+ def destinations_update(
681
+ ctx: typer.Context,
682
+ destination_id: str = typer.Argument(..., help="Destination UUID."),
683
+ name: str | None = typer.Option(None, "--name", help="New destination name."),
684
+ url: str | None = typer.Option(None, "--url", help="New public HTTPS receiver."),
685
+ pause: bool = typer.Option(False, "--pause", help="Pause delivery to this destination."),
686
+ resume: bool = typer.Option(False, "--resume", help="Resume delivery to this destination."),
687
+ ) -> None:
688
+ """Update a destination in the selected project (developer role or above)."""
689
+ if pause and resume:
690
+ raise CLIError("Choose --pause or --resume, not both.")
691
+ changes: dict[str, Any] = {}
692
+ if name is not None:
693
+ changes["name"] = _name(name, "Destination name")
694
+ if url is not None:
695
+ changes["url"] = _webhook_url(url)
696
+ if pause or resume:
697
+ changes["is_active"] = bool(resume)
698
+ if not changes:
699
+ raise CLIError("Specify --name, --url, --pause, or --resume.")
700
+ destination_id = _uuid(destination_id)
701
+ project = _project(ctx)
702
+ data = _request(
703
+ _session(ctx),
704
+ f"/control/v1/projects/{project}/destinations/{destination_id}/",
705
+ method="PATCH", json_body=changes,
475
706
  )
707
+ _result(ctx, "Destination updated", data)
476
708
 
477
709
 
478
710
  @keys_app.command("list")
@@ -482,12 +714,20 @@ def keys_list(ctx: typer.Context) -> None:
482
714
  data = _request(_session(ctx), f"/control/v1/projects/{project}/api-keys/")
483
715
  items = _entries(data, "API keys")
484
716
  _print_or_table(
485
- ctx, data, "API keys",
717
+ ctx,
718
+ data,
719
+ "API keys",
486
720
  ["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],
721
+ [
722
+ [
723
+ key.get("name"),
724
+ key.get("masked_key"),
725
+ "yes" if key.get("is_active") else "no",
726
+ _date(key.get("expires_at")),
727
+ key.get("id"),
728
+ ]
729
+ for key in items
730
+ ],
491
731
  )
492
732
 
493
733
 
@@ -495,9 +735,15 @@ def keys_list(ctx: typer.Context) -> None:
495
735
  def events_list(
496
736
  ctx: typer.Context,
497
737
  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."),
738
+ status_filter: str | None = typer.Option(
739
+ None, "--status", help="delivered, failed, pending..."
740
+ ),
741
+ origin: str | None = typer.Option(
742
+ None, "--origin", help="api, incoming or replay."
743
+ ),
744
+ search: str | None = typer.Option(
745
+ None, "--search", help="Event ID, type or destination."
746
+ ),
501
747
  ) -> None:
502
748
  """List recent delivery activity with optional filters."""
503
749
  project = _project(ctx)
@@ -508,12 +754,26 @@ def events_list(
508
754
  params["origin"] = origin
509
755
  if search:
510
756
  params["q"] = search
511
- data = _request(_session(ctx), f"/control/v1/projects/{project}/events/", params=params)
757
+ data = _request(
758
+ _session(ctx), f"/control/v1/projects/{project}/events/", params=params
759
+ )
512
760
  items = _entries(data, "events")
513
761
  _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],
762
+ ctx,
763
+ data,
764
+ "Activity",
765
+ ["Created", "Event", "Destination", "Status", "Attempts", "Event ID"],
766
+ [
767
+ [
768
+ _date(x.get("created_at")),
769
+ x.get("event_type"),
770
+ x.get("destination_name"),
771
+ x.get("status"),
772
+ x.get("attempt_count"),
773
+ x.get("id"),
774
+ ]
775
+ for x in items
776
+ ],
517
777
  status_column=3,
518
778
  )
519
779
 
@@ -522,22 +782,37 @@ def events_list(
522
782
  def events_show(ctx: typer.Context, event_id: str = typer.Argument(...)) -> None:
523
783
  """Inspect an event and its delivery attempts (redacted by the API)."""
524
784
  project = _project(ctx)
525
- data = _request(_session(ctx), f"/control/v1/projects/{project}/events/{_uuid(event_id)}/")
785
+ data = _request(
786
+ _session(ctx), f"/control/v1/projects/{project}/events/{_uuid(event_id)}/"
787
+ )
526
788
  if _session(ctx).json_output:
527
789
  _print(ctx, data)
528
790
  return
529
791
  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
- ))
792
+ session.console.print(
793
+ Panel.fit(
794
+ f"[bold]{_escaped(data.get('event_type'))}[/bold]\n"
795
+ f"Status: {_escaped(str(_colored_status(data.get('status'))))}\n"
796
+ f"Destination: {_escaped(data.get('destination_name'))}\n"
797
+ f"Event ID: {_escaped(data.get('id'))}",
798
+ title="Delivery",
799
+ border_style="cyan",
800
+ )
801
+ )
537
802
  _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", [])],
803
+ ctx,
804
+ "Delivery attempts",
805
+ ["Attempt", "HTTP", "Duration (ms)", "Time", "Error"],
806
+ [
807
+ [
808
+ x.get("attempt_number"),
809
+ x.get("status_code"),
810
+ x.get("duration_ms"),
811
+ _date(x.get("created_at")),
812
+ x.get("error"),
813
+ ]
814
+ for x in data.get("attempts", [])
815
+ ],
541
816
  )
542
817
 
543
818
 
@@ -550,14 +825,17 @@ def billing_status(ctx: typer.Context) -> None:
550
825
  _print(ctx, data)
551
826
  return
552
827
  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
- ))
828
+ _session(ctx).console.print(
829
+ Panel.fit(
830
+ f"Plan: [bold]{_escaped(plan.get('plan_code'))}[/bold]\n"
831
+ f"Status: {_escaped(str(_colored_status(plan.get('status'))))}\n"
832
+ f"Flex credits: {_escaped(plan.get('flex_credits'))}\n"
833
+ f"Current period ends: {_escaped(_date(plan.get('current_period_end')))}\n"
834
+ f"Payment attempts: {len(data.get('records', []))}",
835
+ title="Project Billing",
836
+ border_style="cyan",
837
+ )
838
+ )
561
839
 
562
840
 
563
841
  @billing_app.command("history")
@@ -567,11 +845,20 @@ def billing_history(ctx: typer.Context) -> None:
567
845
  data = _request(_session(ctx), f"/control/v1/projects/{project}/billing/")
568
846
  records = data.get("records", [])
569
847
  _print_or_table(
570
- ctx, records, "Payment history",
848
+ ctx,
849
+ records,
850
+ "Payment history",
571
851
  ["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],
852
+ [
853
+ [
854
+ _date(r.get("created_at")),
855
+ r.get("plan_code"),
856
+ f"{r.get('currency', '')} {int(r.get('amount_minor', 0)) / 100:.2f}",
857
+ r.get("status"),
858
+ r.get("reference"),
859
+ ]
860
+ for r in records
861
+ ],
575
862
  status_column=3,
576
863
  )
577
864
 
@@ -583,9 +870,20 @@ def incoming_sources(ctx: typer.Context) -> None:
583
870
  data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/sources/")
584
871
  items = data.get("sources", [])
585
872
  _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],
873
+ ctx,
874
+ data,
875
+ "Incoming sources",
876
+ ["Name", "Provider", "Routing", "Active", "Source ID"],
877
+ [
878
+ [
879
+ s.get("name"),
880
+ s.get("provider"),
881
+ s.get("routing"),
882
+ "yes" if s.get("is_active") else "no",
883
+ s.get("id"),
884
+ ]
885
+ for s in items
886
+ ],
589
887
  )
590
888
 
591
889
 
@@ -596,11 +894,20 @@ def incoming_routes(ctx: typer.Context) -> None:
596
894
  data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/routes/")
597
895
  routes = data.get("routes", [])
598
896
  _print_or_table(
599
- ctx, data, "Incoming routes",
897
+ ctx,
898
+ data,
899
+ "Incoming routes",
600
900
  ["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],
901
+ [
902
+ [
903
+ route.get("source"),
904
+ route.get("endpoint"),
905
+ route.get("event_pattern"),
906
+ "yes" if route.get("is_active") else "no",
907
+ route.get("id"),
908
+ ]
909
+ for route in routes
910
+ ],
604
911
  )
605
912
 
606
913
 
@@ -611,10 +918,19 @@ def incoming_bindings(ctx: typer.Context) -> None:
611
918
  data = _request(_session(ctx), f"/control/v1/projects/{project}/incoming/bindings/")
612
919
  bindings = data.get("bindings", [])
613
920
  _print_or_table(
614
- ctx, data, "Incoming bindings",
921
+ ctx,
922
+ data,
923
+ "Incoming bindings",
615
924
  ["Reference", "Source", "Destination", "Binding ID"],
616
- [[item.get("reference"), item.get("source"),
617
- item.get("endpoint"), item.get("id")] for item in bindings],
925
+ [
926
+ [
927
+ item.get("reference"),
928
+ item.get("source"),
929
+ item.get("endpoint"),
930
+ item.get("id"),
931
+ ]
932
+ for item in bindings
933
+ ],
618
934
  )
619
935
 
620
936
 
@@ -627,11 +943,20 @@ def incoming_registration_keys(ctx: typer.Context) -> None:
627
943
  )
628
944
  keys = data.get("keys", [])
629
945
  _print_or_table(
630
- ctx, data, "Registration keys",
946
+ ctx,
947
+ data,
948
+ "Registration keys",
631
949
  ["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],
950
+ [
951
+ [
952
+ item.get("name"),
953
+ item.get("masked_key"),
954
+ "yes" if item.get("is_active") else "no",
955
+ _date(item.get("expires_at")),
956
+ item.get("id"),
957
+ ]
958
+ for item in keys
959
+ ],
635
960
  )
636
961
 
637
962
 
@@ -645,9 +970,18 @@ def delivery_stats(ctx: typer.Context) -> None:
645
970
  return
646
971
  counts = data.get("events", {})
647
972
  _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)}%"]],
973
+ ctx,
974
+ "Delivery overview",
975
+ ["Total", "Delivered", "Failed", "Pending", "Success"],
976
+ [
977
+ [
978
+ counts.get("total"),
979
+ counts.get("delivered"),
980
+ counts.get("failed"),
981
+ counts.get("pending"),
982
+ f"{counts.get('success_rate', 0)}%",
983
+ ]
984
+ ],
651
985
  )
652
986
 
653
987
 
@@ -656,18 +990,31 @@ def config_show(ctx: typer.Context) -> None:
656
990
  """Print the active API/profile settings, never the token value."""
657
991
  session = _session(ctx)
658
992
  saved = _read_config().get("projects", {})
659
- selected = session.project or (saved.get(session.environment) if isinstance(saved, dict) else None)
993
+ selected = session.project or (
994
+ saved.get(session.environment) if isinstance(saved, dict) else None
995
+ )
660
996
  data = {
661
997
  "environment": session.environment,
662
998
  "api_url": session.origin,
663
999
  "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,
1000
+ "token_configured": bool(
1001
+ os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_")
1002
+ ),
1003
+ "token_source": (
1004
+ "environment"
1005
+ if os.environ.get("HOOKSTRIDE_CONTROL_TOKEN", "").startswith("whrctl_")
1006
+ else None
1007
+ ),
666
1008
  }
667
1009
  if session.json_output:
668
1010
  _print(ctx, data)
669
1011
  else:
670
- _table(ctx, "Configuration", ["Setting", "Value"], [[k, v] for k, v in data.items()])
1012
+ _table(
1013
+ ctx,
1014
+ "Configuration",
1015
+ ["Setting", "Value"],
1016
+ [[k, v] for k, v in data.items()],
1017
+ )
671
1018
 
672
1019
 
673
1020
  def main() -> None:
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: hookstride-cli
3
+ Version: 0.3.0
4
+ Summary: Manage Hookstride projects and destinations, inspect webhook activity and billing.
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
+ Hookstride CLI lets you manage projects and webhook destinations, inspect delivery activity, and view billing records. It includes an interactive console with the same selected-project commands.
17
+
18
+ Version 0.3.0 adds project creation and updates, plus destination creation, updates, and pausing/resuming. Billing and credential changes, delivery replay, and destructive operations remain unavailable.
19
+
20
+ ## Installation
21
+
22
+ Requires Python 3.11 or later.
23
+
24
+ ```bash
25
+ uv tool install hookstride-cli
26
+ ```
27
+
28
+ Alternatively, use `pipx install hookstride-cli`.
29
+
30
+ ```bash
31
+ hookstride --version
32
+ hookstride status
33
+ hookstride console
34
+ ```
35
+
36
+ Upgrade an existing installation with `uv tool upgrade hookstride-cli`.
37
+
38
+ ## Authentication
39
+
40
+ Authenticated commands require a **Control API token** (`whrctl_`), available from your Hookstride dashboard. Project delivery API keys (`whrk_`) do not grant CLI access.
41
+
42
+ In an interactive console, run `auth login` and enter the token at the hidden prompt:
43
+
44
+ ```bash
45
+ hookstride console
46
+ ```
47
+
48
+ For one-off commands, set `HOOKSTRIDE_CONTROL_TOKEN` in your shell without entering it on the command line:
49
+
50
+ ```bash
51
+ read -rs "HOOKSTRIDE_CONTROL_TOKEN?Control token: "
52
+ echo
53
+ export HOOKSTRIDE_CONTROL_TOKEN
54
+ hookstride auth status
55
+ ```
56
+
57
+ The `read` example above works in **zsh**. In Bash, use `read -rs -p "Control token: " HOOKSTRIDE_CONTROL_TOKEN` followed by `echo` and `export HOOKSTRIDE_CONTROL_TOKEN`.
58
+
59
+ Tokens entered through `auth login` are retained only for the interactive console session. The CLI does not write tokens to its settings file.
60
+
61
+ ## Commands
62
+
63
+ ```bash
64
+ hookstride status
65
+ hookstride auth status
66
+ hookstride projects list
67
+ hookstride projects use PROJECT_UUID
68
+ hookstride projects create --account ACCOUNT_UUID --name "Staging" --slug staging
69
+ hookstride projects update PROJECT_UUID --name "Production EU"
70
+
71
+ hookstride destinations list
72
+ hookstride destinations create --name "Webhook receiver" --url "https://app.example.com/webhooks/hookstride"
73
+ hookstride destinations update DESTINATION_UUID --url "https://api.example.com/webhooks/hookstride"
74
+ hookstride destinations update DESTINATION_UUID --pause
75
+ hookstride destinations update DESTINATION_UUID --resume
76
+ hookstride keys list
77
+ hookstride events list --status failed
78
+ hookstride events show EVENT_UUID
79
+ hookstride stats
80
+
81
+ hookstride incoming sources
82
+ hookstride incoming routes
83
+ hookstride incoming bindings
84
+ hookstride incoming registration-keys
85
+
86
+ hookstride billing status
87
+ hookstride billing history
88
+ hookstride config show
89
+ ```
90
+
91
+ Replace the example UUIDs with identifiers from your account. Find your workspace's `account_id` with `hookstride --json projects list`; that ID is required for project creation. Project creation and renaming require an admin/owner role. Destination changes require developer access or higher.
92
+
93
+ Commands that operate on a project use the selected project. You can override the selection for an individual command:
94
+
95
+ ```bash
96
+ hookstride --project PROJECT_UUID events list
97
+ ```
98
+
99
+ ### Interactive console
100
+
101
+ ```bash
102
+ hookstride console
103
+ ```
104
+
105
+ Use `help` to view commands, Tab for completion, and `exit` or Ctrl+D to leave. The console also supports `show projects`, `show events`, `show destinations`, `use PROJECT_UUID`, `back`, and `set env dev|production`. The same `projects create/update` and `destinations create/update` commands work inside the console. Commands with customer-provided names and URLs are not saved to console history.
106
+
107
+ ### JSON output
108
+
109
+ Use `--json` before a command to retrieve structured data for scripts:
110
+
111
+ ```bash
112
+ hookstride --json projects list
113
+ hookstride --project PROJECT_UUID --json events list --status failed
114
+ hookstride --env production --json billing history
115
+ ```
116
+
117
+ Errors go to standard error; unsuccessful requests return a nonzero exit code. When creating a destination, the server returns a signing secret **once**. Save it securely immediately. Both terminal output and `--json` deliberately include that one-time secret, so avoid piping creation output into logs or public CI artifacts.
118
+
119
+ ## Environments and local settings
120
+
121
+ The CLI connects to the production API by default: `https://api.hookstride.com`.
122
+ A new release must be published to PyPI before existing installations receive this change.
123
+
124
+ To inspect the development API explicitly, select it:
125
+
126
+ ```bash
127
+ hookstride --env dev console
128
+ ```
129
+
130
+ Development requests use `https://api-dev.hookstride.com`. Use a Control API token issued for the selected environment. A custom HTTPS API origin can be supplied with `--api-url`.
131
+
132
+ Project selections are stored separately by environment in `~/.config/hookstride/settings.json`. This file contains project identifiers, **not authentication tokens**. Console tokens and command history remain in memory and are discarded at exit.
133
+
134
+ ## Documentation
135
+
136
+ Documentation: https://hookstride.com
@@ -0,0 +1,7 @@
1
+ hookstride_cli/__init__.py,sha256=nu4rJBpNjzDjU4Gx5MN1BdUiMVx6HbsoDoYnSfWzkok,55
2
+ hookstride_cli/console.py,sha256=kUARFRd5XkuXsJWOJ-enAfw_dY4j6ANelZX5B1vQSpM,19126
3
+ hookstride_cli/main.py,sha256=q_L6e_GBD78vLx_JURuMkV9V2Ao07_OW9rKH016zA0E,34582
4
+ hookstride_cli-0.3.0.dist-info/METADATA,sha256=iPHreSMhrsMKt27zUgU9kjFipo2wQdXcHTFQ7TZ7Vu4,5201
5
+ hookstride_cli-0.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ hookstride_cli-0.3.0.dist-info/entry_points.txt,sha256=E9bqvnCFVO-lYULID8x-HTp0gYnfrYprB2R8n-DKy6Q,56
7
+ hookstride_cli-0.3.0.dist-info/RECORD,,
@@ -1,179 +0,0 @@
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.
@@ -1,7 +0,0 @@
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,,