pragmatiks-cli 0.5.1__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.
pragma_cli/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """CLI global client management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pragma_sdk import PragmaClient
6
+
7
+
8
+ _client: PragmaClient | None = None
9
+
10
+
11
+ def get_client() -> PragmaClient:
12
+ """Get the initialized client.
13
+
14
+ Returns:
15
+ Initialized PragmaClient instance.
16
+
17
+ Raises:
18
+ RuntimeError: If client has not been initialized via main().
19
+ """
20
+ if _client is None:
21
+ raise RuntimeError("Client not initialized. This should not happen.")
22
+ return _client
23
+
24
+
25
+ def set_client(client: PragmaClient):
26
+ """Set the global client instance.
27
+
28
+ Args:
29
+ client: The PragmaClient instance to use globally
30
+ """
31
+ global _client
32
+ _client = client
@@ -0,0 +1 @@
1
+ """Commands package."""
@@ -0,0 +1,247 @@
1
+ """Authentication commands for browser-based Clerk login."""
2
+
3
+ import os
4
+ import webbrowser
5
+ from http.server import BaseHTTPRequestHandler, HTTPServer
6
+ from urllib.parse import parse_qs, urlparse
7
+
8
+ import typer
9
+ from pragma_sdk.config import load_credentials
10
+ from rich import print
11
+
12
+ from pragma_cli.config import CREDENTIALS_FILE, get_current_context, load_config
13
+
14
+
15
+ app = typer.Typer()
16
+
17
+ CALLBACK_PORT = int(os.getenv("PRAGMA_AUTH_CALLBACK_PORT", "8765"))
18
+ CALLBACK_PATH = os.getenv("PRAGMA_AUTH_CALLBACK_PATH", "/auth/callback")
19
+ CLERK_FRONTEND_URL = os.getenv("PRAGMA_CLERK_FRONTEND_URL", "https://app.pragmatiks.io")
20
+ CALLBACK_URL = f"http://localhost:{CALLBACK_PORT}{CALLBACK_PATH}"
21
+ LOGIN_URL = f"{CLERK_FRONTEND_URL}/auth/callback?callback={CALLBACK_URL}"
22
+
23
+
24
+ class CallbackHandler(BaseHTTPRequestHandler):
25
+ """HTTP handler for OAuth callback from Clerk."""
26
+
27
+ token = None
28
+
29
+ def do_GET(self):
30
+ """Handle GET request from Clerk redirect."""
31
+ parsed = urlparse(self.path)
32
+
33
+ if parsed.path == CALLBACK_PATH:
34
+ params = parse_qs(parsed.query)
35
+ token = params.get("token", [None])[0]
36
+
37
+ if token:
38
+ CallbackHandler.token = token
39
+
40
+ self.send_response(200)
41
+ self.send_header("Content-type", "text/html")
42
+ self.end_headers()
43
+ self.wfile.write(
44
+ b"""
45
+ <html>
46
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
47
+ <h1 style="color: green;">&#10003; Authentication Successful</h1>
48
+ <p>You can close this window and return to the terminal.</p>
49
+ </body>
50
+ </html>
51
+ """
52
+ )
53
+ else:
54
+ self.send_response(400)
55
+ self.send_header("Content-type", "text/html")
56
+ self.end_headers()
57
+ self.wfile.write(
58
+ b"""
59
+ <html>
60
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
61
+ <h1 style="color: red;">&#10007; Authentication Failed</h1>
62
+ <p>No token received. Please try again.</p>
63
+ </body>
64
+ </html>
65
+ """
66
+ )
67
+ else:
68
+ self.send_response(404)
69
+ self.end_headers()
70
+
71
+ def log_message(self, format, *args):
72
+ """Suppress server logs."""
73
+
74
+
75
+ def save_credentials(token: str, context_name: str = "default"):
76
+ """Save authentication token to local credentials file.
77
+
78
+ Args:
79
+ token: JWT token from Clerk
80
+ context_name: Context to associate with this token
81
+ """
82
+ CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
83
+
84
+ credentials = {}
85
+ if CREDENTIALS_FILE.exists():
86
+ with open(CREDENTIALS_FILE) as f:
87
+ for line in f:
88
+ line = line.strip()
89
+ if not line or line.startswith("#"):
90
+ continue
91
+ if "=" in line:
92
+ key, value = line.split("=", 1)
93
+ credentials[key.strip()] = value.strip()
94
+
95
+ credentials[context_name] = token
96
+
97
+ with open(CREDENTIALS_FILE, "w") as f:
98
+ for key, value in credentials.items():
99
+ f.write(f"{key}={value}\n")
100
+
101
+ CREDENTIALS_FILE.chmod(0o600)
102
+
103
+
104
+ def clear_credentials(context_name: str | None = None):
105
+ """Clear stored credentials.
106
+
107
+ Args:
108
+ context_name: Specific context to clear, or None for all
109
+ """
110
+ if not CREDENTIALS_FILE.exists():
111
+ return
112
+
113
+ if context_name is None:
114
+ CREDENTIALS_FILE.unlink()
115
+ return
116
+
117
+ credentials = {}
118
+ with open(CREDENTIALS_FILE) as f:
119
+ for line in f:
120
+ line = line.strip()
121
+ if not line or line.startswith("#"):
122
+ continue
123
+ if "=" in line:
124
+ key, value = line.split("=", 1)
125
+ credentials[key.strip()] = value.strip()
126
+
127
+ credentials.pop(context_name, None)
128
+
129
+ if credentials:
130
+ with open(CREDENTIALS_FILE, "w") as f:
131
+ for key, value in credentials.items():
132
+ f.write(f"{key}={value}\n")
133
+ CREDENTIALS_FILE.chmod(0o600)
134
+ else:
135
+ CREDENTIALS_FILE.unlink()
136
+
137
+
138
+ @app.command()
139
+ def login(context: str = typer.Option("default", help="Context to authenticate for")):
140
+ """Authenticate with Pragma using browser-based Clerk login.
141
+
142
+ Opens your default browser to Clerk authentication page. After successful
143
+ login, your credentials are stored locally in ~/.config/pragma/credentials.
144
+
145
+ Example:
146
+ pragma login
147
+ pragma login --context production
148
+
149
+ Raises:
150
+ typer.Exit: If context not found or authentication fails/times out.
151
+ """
152
+ config = load_config()
153
+ if context not in config.contexts:
154
+ print(f"[red]\u2717[/red] Context '{context}' not found")
155
+ print(f"Available contexts: {', '.join(config.contexts.keys())}")
156
+ raise typer.Exit(1)
157
+
158
+ api_url = config.contexts[context].api_url
159
+
160
+ print(f"[cyan]Authenticating for context:[/cyan] {context}")
161
+ print(f"[cyan]API URL:[/cyan] {api_url}")
162
+ print()
163
+
164
+ server = HTTPServer(("localhost", CALLBACK_PORT), CallbackHandler)
165
+
166
+ print(f"[yellow]Opening browser to:[/yellow] {CLERK_FRONTEND_URL}")
167
+ print()
168
+ print("[dim]If browser doesn't open automatically, visit:[/dim]")
169
+ print(f"[dim]{LOGIN_URL}[/dim]")
170
+ print()
171
+ print("[yellow]Waiting for authentication...[/yellow]")
172
+
173
+ webbrowser.open(LOGIN_URL)
174
+
175
+ server.timeout = 300
176
+ server.handle_request()
177
+
178
+ if CallbackHandler.token:
179
+ save_credentials(CallbackHandler.token, context)
180
+ print()
181
+ print("[green]\u2713 Successfully authenticated![/green]")
182
+ print(f"[dim]Credentials saved to {CREDENTIALS_FILE}[/dim]")
183
+ print()
184
+ print("[bold]You can now use pragma commands:[/bold]")
185
+ print(" pragma resources list-groups")
186
+ print(" pragma resources get <resource-id>")
187
+ print(" pragma resources apply <file.yaml>")
188
+ else:
189
+ print()
190
+ print("[red]\u2717 Authentication failed or timed out[/red]")
191
+ print("[dim]Please try again[/dim]")
192
+ raise typer.Exit(1)
193
+
194
+
195
+ @app.command()
196
+ def logout(
197
+ context: str | None = typer.Option(None, help="Context to logout from (all if not specified)"),
198
+ all: bool = typer.Option(False, "--all", help="Logout from all contexts"),
199
+ ):
200
+ """Clear stored authentication credentials.
201
+
202
+ Example:
203
+ pragma logout # Clear current context
204
+ pragma logout --all # Clear all contexts
205
+ pragma logout --context staging # Clear specific context
206
+ """
207
+ if all:
208
+ clear_credentials(None)
209
+ print("[green]\u2713[/green] Cleared all credentials")
210
+ elif context:
211
+ clear_credentials(context)
212
+ print(f"[green]\u2713[/green] Cleared credentials for context '{context}'")
213
+ else:
214
+ context_name, _ = get_current_context()
215
+ clear_credentials(context_name)
216
+ print(f"[green]\u2713[/green] Cleared credentials for current context '{context_name}'")
217
+
218
+
219
+ @app.command()
220
+ def whoami():
221
+ """Show current authentication status.
222
+
223
+ Displays which contexts have stored credentials and current authentication state.
224
+ """
225
+ config = load_config()
226
+ current_context_name, _ = get_current_context()
227
+
228
+ print()
229
+ print("[bold]Authentication Status[/bold]")
230
+ print()
231
+
232
+ has_any_creds = False
233
+ for context_name in config.contexts.keys():
234
+ token = load_credentials(context_name)
235
+ marker = "[green]*[/green]" if context_name == current_context_name else " "
236
+
237
+ if token:
238
+ has_any_creds = True
239
+ print(f"{marker} [cyan]{context_name}[/cyan]: [green]\u2713 Authenticated[/green]")
240
+ else:
241
+ print(f"{marker} [cyan]{context_name}[/cyan]: [dim]Not authenticated[/dim]")
242
+
243
+ print()
244
+
245
+ if not has_any_creds:
246
+ print("[yellow]No stored credentials found[/yellow]")
247
+ print("[dim]Run 'pragma login' to authenticate[/dim]")
@@ -0,0 +1,54 @@
1
+ """CLI auto-completion functions for resource operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from pragma_cli import get_client
8
+
9
+
10
+ def completion_resource_ids(incomplete: str):
11
+ """Complete resource identifiers in provider/resource format based on existing resources.
12
+
13
+ Args:
14
+ incomplete: Partial input to complete against available resource types.
15
+
16
+ Yields:
17
+ Resource identifiers matching the incomplete input.
18
+ """
19
+ client = get_client()
20
+ try:
21
+ resources = client.list_resources()
22
+ except Exception:
23
+ return
24
+
25
+ seen = set()
26
+ for res in resources:
27
+ resource_id = f"{res['provider']}/{res['resource']}"
28
+ if resource_id not in seen and resource_id.lower().startswith(incomplete.lower()):
29
+ seen.add(resource_id)
30
+ yield resource_id
31
+
32
+
33
+ def completion_resource_names(ctx: typer.Context, incomplete: str):
34
+ """Complete resource instance names.
35
+
36
+ Args:
37
+ ctx: Typer context containing parsed parameters including resource_id.
38
+ incomplete: Partial input to complete.
39
+
40
+ Yields:
41
+ Resource names matching the incomplete input for the selected resource type.
42
+ """
43
+ client = get_client()
44
+ resource_id = ctx.params.get("resource_id")
45
+ if not resource_id or "/" not in resource_id:
46
+ return
47
+ provider, resource = resource_id.split("/", 1)
48
+ try:
49
+ resources = client.list_resources(provider=provider, resource=resource)
50
+ except Exception:
51
+ return
52
+ for res in resources:
53
+ if res["name"].startswith(incomplete):
54
+ yield res["name"]
@@ -0,0 +1,79 @@
1
+ """Context and configuration management commands."""
2
+
3
+ import typer
4
+ from rich import print
5
+
6
+ from pragma_cli.config import ContextConfig, get_current_context, load_config, save_config
7
+
8
+
9
+ app = typer.Typer()
10
+
11
+
12
+ @app.command()
13
+ def use_context(context_name: str):
14
+ """Switch to a different context.
15
+
16
+ Raises:
17
+ typer.Exit: If context not found.
18
+ """
19
+ config = load_config()
20
+ if context_name not in config.contexts:
21
+ print(f"[red]\u2717[/red] Context '{context_name}' not found")
22
+ print(f"Available contexts: {', '.join(config.contexts.keys())}")
23
+ raise typer.Exit(1)
24
+
25
+ config.current_context = context_name
26
+ save_config(config)
27
+ print(f"[green]\u2713[/green] Switched to context '{context_name}'")
28
+
29
+
30
+ @app.command()
31
+ def get_contexts():
32
+ """List available contexts."""
33
+ config = load_config()
34
+ print("\n[bold]Available contexts:[/bold]")
35
+ for name, ctx in config.contexts.items():
36
+ marker = "[green]*[/green]" if name == config.current_context else " "
37
+ print(f"{marker} [cyan]{name}[/cyan]: {ctx.api_url}")
38
+ print()
39
+
40
+
41
+ @app.command()
42
+ def current_context():
43
+ """Show current context."""
44
+ context_name, context_config = get_current_context()
45
+ print(f"[bold]Current context:[/bold] [cyan]{context_name}[/cyan]")
46
+ print(f"[bold]API URL:[/bold] {context_config.api_url}")
47
+
48
+
49
+ @app.command()
50
+ def set_context(
51
+ name: str = typer.Argument(..., help="Context name"),
52
+ api_url: str = typer.Option(..., help="API endpoint URL"),
53
+ ):
54
+ """Create or update a context."""
55
+ config = load_config()
56
+ config.contexts[name] = ContextConfig(api_url=api_url)
57
+ save_config(config)
58
+ print(f"[green]\u2713[/green] Context '{name}' configured")
59
+
60
+
61
+ @app.command()
62
+ def delete_context(name: str):
63
+ """Delete a context.
64
+
65
+ Raises:
66
+ typer.Exit: If context not found or is current context.
67
+ """
68
+ config = load_config()
69
+ if name not in config.contexts:
70
+ print(f"[red]\u2717[/red] Context '{name}' not found")
71
+ raise typer.Exit(1)
72
+
73
+ if name == config.current_context:
74
+ print("[red]\u2717[/red] Cannot delete current context")
75
+ raise typer.Exit(1)
76
+
77
+ del config.contexts[name]
78
+ save_config(config)
79
+ print(f"[green]\u2713[/green] Context '{name}' deleted")
@@ -0,0 +1,233 @@
1
+ """Dead letter event management commands.
2
+
3
+ Commands for listing, inspecting, retrying, and deleting dead letter events
4
+ from the Pragmatiks platform. Dead letter events are failed resource
5
+ operations that exceeded retry attempts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Annotated
12
+
13
+ import httpx
14
+ import typer
15
+ from rich import print
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from pragma_cli import get_client
20
+
21
+
22
+ app = typer.Typer(help="Dead letter event management commands")
23
+
24
+ console = Console()
25
+
26
+
27
+ def truncate(text: str, max_length: int = 50) -> str:
28
+ """Truncate text to max_length, adding ellipsis if needed.
29
+
30
+ Args:
31
+ text: Text to truncate.
32
+ max_length: Maximum length including ellipsis.
33
+
34
+ Returns:
35
+ Truncated text with ellipsis if exceeded max_length.
36
+ """
37
+ if len(text) <= max_length:
38
+ return text
39
+ return text[: max_length - 3] + "..."
40
+
41
+
42
+ @app.command("list")
43
+ def list_events(
44
+ provider: Annotated[
45
+ str | None,
46
+ typer.Option("--provider", "-p", help="Filter by provider name"),
47
+ ] = None,
48
+ ):
49
+ """List dead letter events.
50
+
51
+ Displays a table of all dead letter events, optionally filtered by provider.
52
+ The error_message column is truncated to 50 characters for readability.
53
+
54
+ Example:
55
+ pragma ops dead-letter list
56
+ pragma ops dead-letter list --provider postgres
57
+ """
58
+ client = get_client()
59
+ events = client.list_dead_letter_events(provider=provider)
60
+
61
+ if not events:
62
+ print("[dim]No dead letter events found.[/dim]")
63
+ return
64
+
65
+ table = Table()
66
+ table.add_column("Event ID", style="cyan")
67
+ table.add_column("Provider", style="green")
68
+ table.add_column("Resource Type")
69
+ table.add_column("Resource Name")
70
+ table.add_column("Error Message", style="red")
71
+ table.add_column("Failed At")
72
+
73
+ for event in events:
74
+ table.add_row(
75
+ event.get("id", ""),
76
+ event.get("provider", ""),
77
+ event.get("resource_type", ""),
78
+ event.get("resource_name", ""),
79
+ truncate(event.get("error_message", ""), 50),
80
+ event.get("failed_at", ""),
81
+ )
82
+
83
+ console.print(table)
84
+
85
+
86
+ @app.command()
87
+ def show(
88
+ event_id: Annotated[str, typer.Argument(help="Dead letter event ID")],
89
+ ):
90
+ """Show detailed information about a dead letter event.
91
+
92
+ Displays the full event data as formatted JSON.
93
+
94
+ Example:
95
+ pragma ops dead-letter show evt_123abc
96
+
97
+ Raises:
98
+ typer.Exit: If event not found (code 1).
99
+ """ # noqa: DOC501
100
+ client = get_client()
101
+ try:
102
+ event = client.get_dead_letter_event(event_id)
103
+ except httpx.HTTPStatusError as e:
104
+ if e.response.status_code == 404:
105
+ print(f"[red]Event not found:[/red] {event_id}")
106
+ raise typer.Exit(1)
107
+ raise
108
+
109
+ print(json.dumps(event, indent=2, default=str))
110
+
111
+
112
+ @app.command()
113
+ def retry(
114
+ event_id: Annotated[
115
+ str | None,
116
+ typer.Argument(help="Dead letter event ID to retry"),
117
+ ] = None,
118
+ all_events: Annotated[
119
+ bool,
120
+ typer.Option("--all", help="Retry all dead letter events"),
121
+ ] = False,
122
+ ):
123
+ """Retry dead letter event(s).
124
+
125
+ Retries a single event by ID, or all events with --all flag.
126
+ The --all flag requires confirmation.
127
+
128
+ Example:
129
+ pragma ops dead-letter retry evt_123abc
130
+ pragma ops dead-letter retry --all
131
+
132
+ Raises:
133
+ typer.Exit: If event not found (code 1) or user cancels (code 0).
134
+ """ # noqa: DOC501
135
+ client = get_client()
136
+
137
+ if all_events:
138
+ events = client.list_dead_letter_events()
139
+ count = len(events)
140
+
141
+ if count == 0:
142
+ print("[dim]No dead letter events to retry.[/dim]")
143
+ return
144
+
145
+ if not typer.confirm(f"Retry {count} event(s)?"):
146
+ raise typer.Exit(0)
147
+
148
+ retried = client.retry_all_dead_letter_events()
149
+ print(f"[green]Retried {retried} event(s)[/green]")
150
+ elif event_id:
151
+ try:
152
+ client.retry_dead_letter_event(event_id)
153
+ except httpx.HTTPStatusError as e:
154
+ if e.response.status_code == 404:
155
+ print(f"[red]Event not found:[/red] {event_id}")
156
+ raise typer.Exit(1)
157
+ raise
158
+
159
+ print(f"[green]Retried event:[/green] {event_id}")
160
+ else:
161
+ print("[red]Error:[/red] Provide an event_id or use --all")
162
+ raise typer.Exit(1)
163
+
164
+
165
+ @app.command()
166
+ def delete(
167
+ event_id: Annotated[
168
+ str | None,
169
+ typer.Argument(help="Dead letter event ID to delete"),
170
+ ] = None,
171
+ all_events: Annotated[
172
+ bool,
173
+ typer.Option("--all", help="Delete all dead letter events"),
174
+ ] = False,
175
+ provider: Annotated[
176
+ str | None,
177
+ typer.Option("--provider", "-p", help="Delete all events for this provider"),
178
+ ] = None,
179
+ ):
180
+ """Delete dead letter event(s).
181
+
182
+ Deletes a single event by ID, all events for a provider, or all events.
183
+ The --all and --provider flags require confirmation.
184
+
185
+ Example:
186
+ pragma ops dead-letter delete evt_123abc
187
+ pragma ops dead-letter delete --all
188
+ pragma ops dead-letter delete --provider postgres
189
+
190
+ Raises:
191
+ typer.Exit: If event not found (code 1) or user cancels (code 0).
192
+ """ # noqa: DOC501
193
+ client = get_client()
194
+
195
+ if all_events:
196
+ events = client.list_dead_letter_events()
197
+ count = len(events)
198
+
199
+ if count == 0:
200
+ print("[dim]No dead letter events to delete.[/dim]")
201
+ return
202
+
203
+ if not typer.confirm(f"Delete {count} event(s)?"):
204
+ raise typer.Exit(0)
205
+
206
+ deleted = client.delete_dead_letter_events(all=True)
207
+ print(f"[green]Deleted {deleted} event(s)[/green]")
208
+ elif provider:
209
+ events = client.list_dead_letter_events(provider=provider)
210
+ count = len(events)
211
+
212
+ if count == 0:
213
+ print(f"[dim]No dead letter events found for provider '{provider}'.[/dim]")
214
+ return
215
+
216
+ if not typer.confirm(f"Delete {count} event(s) for provider '{provider}'?"):
217
+ raise typer.Exit(0)
218
+
219
+ deleted = client.delete_dead_letter_events(provider=provider)
220
+ print(f"[green]Deleted {deleted} event(s) for provider '{provider}'[/green]")
221
+ elif event_id:
222
+ try:
223
+ client.delete_dead_letter_event(event_id)
224
+ except httpx.HTTPStatusError as e:
225
+ if e.response.status_code == 404:
226
+ print(f"[red]Event not found:[/red] {event_id}")
227
+ raise typer.Exit(1)
228
+ raise
229
+
230
+ print(f"[green]Deleted event:[/green] {event_id}")
231
+ else:
232
+ print("[red]Error:[/red] Provide an event_id, --provider, or --all")
233
+ raise typer.Exit(1)
@@ -0,0 +1,14 @@
1
+ """Operational commands for platform administration.
2
+
3
+ Commands for managing operational concerns like dead letter events,
4
+ system health, and other administrative tasks.
5
+ """
6
+
7
+ import typer
8
+
9
+ from pragma_cli.commands import dead_letter
10
+
11
+
12
+ app = typer.Typer(help="Operational commands for platform administration")
13
+
14
+ app.add_typer(dead_letter.app, name="dead-letter")