notifer-cli 1.0.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
+ """Notifer CLI - Command-line interface for Notifer notification service."""
2
+
3
+ __version__ = "1.0.0"
notifer_cli/cli.py ADDED
@@ -0,0 +1,47 @@
1
+ """Main CLI entry point."""
2
+ import click
3
+ from rich.console import Console
4
+
5
+ from .commands import publish, subscribe, keys, topics, config, auth
6
+
7
+ console = Console()
8
+
9
+
10
+ @click.group()
11
+ @click.version_option(version="1.0.0", prog_name="notifer")
12
+ @click.pass_context
13
+ def cli(ctx):
14
+ """
15
+ Notifer CLI - Simple pub-sub notifications from the command line.
16
+
17
+ Send and receive notifications, manage API keys, and configure topics.
18
+
19
+ \b
20
+ Examples:
21
+ # Publish a message
22
+ notifer publish my-topic "Hello World!"
23
+
24
+ # Subscribe to messages
25
+ notifer subscribe my-topic
26
+
27
+ # Manage API keys
28
+ notifer keys create "CI/CD" --scopes publish
29
+
30
+ For more help on a specific command:
31
+ notifer <command> --help
32
+ """
33
+ ctx.ensure_object(dict)
34
+
35
+
36
+ # Register command groups
37
+ cli.add_command(publish.publish)
38
+ cli.add_command(subscribe.subscribe)
39
+ cli.add_command(keys.keys)
40
+ cli.add_command(topics.topics)
41
+ cli.add_command(config.config)
42
+ cli.add_command(auth.login)
43
+ cli.add_command(auth.logout)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ cli()
notifer_cli/client.py ADDED
@@ -0,0 +1,198 @@
1
+ """HTTP client for Notifer API."""
2
+ import requests
3
+ from typing import Any, Optional
4
+ from .config import Config
5
+
6
+
7
+ class NotiferClient:
8
+ """HTTP client for Notifer API."""
9
+
10
+ def __init__(self, config: Optional[Config] = None):
11
+ """Initialize client with configuration."""
12
+ self.config = config or Config.load()
13
+ self.session = requests.Session()
14
+ self._setup_auth()
15
+
16
+ def _setup_auth(self):
17
+ """Setup authentication headers."""
18
+ if self.config.api_key:
19
+ self.session.headers["X-API-Key"] = self.config.api_key
20
+ elif self.config.access_token:
21
+ self.session.headers["Authorization"] = f"Bearer {self.config.access_token}"
22
+
23
+ def publish(
24
+ self,
25
+ topic: str,
26
+ message: str,
27
+ title: Optional[str] = None,
28
+ priority: int = 3,
29
+ tags: Optional[list[str]] = None,
30
+ ) -> dict[str, Any]:
31
+ """
32
+ Publish a message to a topic.
33
+
34
+ Args:
35
+ topic: Topic name
36
+ message: Message content
37
+ title: Optional message title
38
+ priority: Priority (1-5, default: 3)
39
+ tags: Optional list of tags
40
+
41
+ Returns:
42
+ Published message data
43
+
44
+ Raises:
45
+ requests.HTTPError: On API error
46
+ """
47
+ url = f"{self.config.server}/{topic}"
48
+ payload = {
49
+ "message": message,
50
+ "priority": priority,
51
+ }
52
+ if title:
53
+ payload["title"] = title
54
+ if tags:
55
+ payload["tags"] = tags
56
+
57
+ response = self.session.post(url, json=payload)
58
+ response.raise_for_status()
59
+ return response.json()
60
+
61
+ def subscribe(self, topic: str, since: Optional[str] = None):
62
+ """
63
+ Subscribe to a topic via SSE.
64
+
65
+ Args:
66
+ topic: Topic name
67
+ since: Optional timestamp to get messages since
68
+
69
+ Yields:
70
+ Message events as they arrive
71
+ """
72
+ url = f"{self.config.server}/{topic}/sse"
73
+ params = {}
74
+ if since:
75
+ params["since"] = since
76
+
77
+ response = self.session.get(url, params=params, stream=True)
78
+ response.raise_for_status()
79
+
80
+ # Simple SSE parsing
81
+ for line in response.iter_lines(decode_unicode=True):
82
+ if line.startswith("data: "):
83
+ import json
84
+ data = line[6:] # Remove "data: " prefix
85
+ try:
86
+ yield json.loads(data)
87
+ except json.JSONDecodeError:
88
+ continue
89
+
90
+ # API Keys
91
+ def list_api_keys(self) -> list[dict[str, Any]]:
92
+ """List all API keys."""
93
+ url = f"{self.config.server}/api/keys"
94
+ response = self.session.get(url)
95
+ response.raise_for_status()
96
+ return response.json()["keys"]
97
+
98
+ def create_api_key(
99
+ self,
100
+ name: str,
101
+ description: Optional[str] = None,
102
+ scopes: Optional[list[str]] = None,
103
+ expires_at: Optional[str] = None,
104
+ ) -> dict[str, Any]:
105
+ """Create a new API key."""
106
+ url = f"{self.config.server}/api/keys"
107
+ payload = {"name": name}
108
+ if description:
109
+ payload["description"] = description
110
+ if scopes:
111
+ payload["scopes"] = scopes
112
+ if expires_at:
113
+ payload["expires_at"] = expires_at
114
+
115
+ response = self.session.post(url, json=payload)
116
+ response.raise_for_status()
117
+ return response.json()
118
+
119
+ def revoke_api_key(self, key_id: str) -> dict[str, Any]:
120
+ """Revoke an API key."""
121
+ url = f"{self.config.server}/api/keys/{key_id}/revoke"
122
+ response = self.session.post(url)
123
+ response.raise_for_status()
124
+ return response.json()
125
+
126
+ def delete_api_key(self, key_id: str):
127
+ """Delete an API key."""
128
+ url = f"{self.config.server}/api/keys/{key_id}"
129
+ response = self.session.delete(url)
130
+ response.raise_for_status()
131
+
132
+ # Topics
133
+ def list_topics(self, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
134
+ """List all topics."""
135
+ url = f"{self.config.server}/api/topics"
136
+ params = {"limit": limit, "offset": offset}
137
+ response = self.session.get(url, params=params)
138
+ response.raise_for_status()
139
+ return response.json()
140
+
141
+ def my_topics(self, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
142
+ """List user's topics."""
143
+ url = f"{self.config.server}/api/topics/my"
144
+ params = {"limit": limit, "offset": offset}
145
+ response = self.session.get(url, params=params)
146
+ response.raise_for_status()
147
+ return response.json()
148
+
149
+ def get_topic(self, name: str) -> dict[str, Any]:
150
+ """Get topic details."""
151
+ url = f"{self.config.server}/api/topics/{name}"
152
+ response = self.session.get(url)
153
+ response.raise_for_status()
154
+ return response.json()
155
+
156
+ def create_topic(
157
+ self,
158
+ name: str,
159
+ description: Optional[str] = None,
160
+ is_private: bool = False,
161
+ is_discoverable: bool = True,
162
+ ) -> dict[str, Any]:
163
+ """Create a new topic."""
164
+ url = f"{self.config.server}/api/topics"
165
+ payload = {
166
+ "name": name,
167
+ "access_level": "private" if is_private else "public",
168
+ "is_discoverable": is_discoverable,
169
+ }
170
+ if description:
171
+ payload["description"] = description
172
+
173
+ response = self.session.post(url, json=payload)
174
+ response.raise_for_status()
175
+ return response.json()
176
+
177
+ def delete_topic(self, topic_id: str):
178
+ """Delete a topic."""
179
+ url = f"{self.config.server}/api/topics/{topic_id}"
180
+ response = self.session.delete(url)
181
+ response.raise_for_status()
182
+
183
+ # Auth
184
+ def login(self, email: str, password: str) -> dict[str, Any]:
185
+ """Login with email/password."""
186
+ url = f"{self.config.server}/auth/login"
187
+ payload = {"email": email, "password": password}
188
+ response = self.session.post(url, json=payload)
189
+ response.raise_for_status()
190
+ data = response.json()
191
+
192
+ # Update config with tokens
193
+ self.config.access_token = data["access_token"]
194
+ self.config.refresh_token = data["refresh_token"]
195
+ self.config.email = email
196
+ self._setup_auth()
197
+
198
+ return data
@@ -0,0 +1 @@
1
+ """CLI commands."""
@@ -0,0 +1,91 @@
1
+ """Authentication commands."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from getpass import getpass
6
+
7
+ from ..client import NotiferClient
8
+ from ..config import Config
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command()
14
+ @click.argument("email", required=False)
15
+ @click.option("--server", help="Override server URL")
16
+ def login(email, server):
17
+ """
18
+ Login with email and password.
19
+
20
+ Stores JWT tokens in ~/.notifer.yaml for future requests.
21
+
22
+ \b
23
+ Examples:
24
+ notifer login user@example.com
25
+ notifer login # Will prompt for email
26
+ """
27
+ try:
28
+ # Load config
29
+ config = Config.load()
30
+ if server:
31
+ config.server = server
32
+
33
+ # Get email if not provided
34
+ if not email:
35
+ email = click.prompt("Email")
36
+
37
+ # Get password (hidden input)
38
+ password = getpass("Password: ")
39
+
40
+ # Login
41
+ client = NotiferClient(config)
42
+ result = client.login(email, password)
43
+
44
+ # Save tokens to config
45
+ config.save()
46
+
47
+ console.print(
48
+ Panel(
49
+ f"[green]✓[/green] Logged in as [cyan]{result['user']['email']}[/cyan]\n\n"
50
+ f"Username: {result['user']['username']}\n"
51
+ f"Tier: {result['user']['subscription_tier']}\n"
52
+ f"Tokens saved to: {Config.config_path()}",
53
+ title="Login Successful",
54
+ border_style="green",
55
+ )
56
+ )
57
+
58
+ except Exception as e:
59
+ console.print(f"[red]✗ Login failed:[/red] {str(e)}", style="red")
60
+ raise click.Abort()
61
+
62
+
63
+ @click.command()
64
+ def logout():
65
+ """
66
+ Logout and clear stored credentials.
67
+
68
+ Removes tokens from ~/.notifer.yaml
69
+ """
70
+ try:
71
+ config = Config.load()
72
+
73
+ # Clear auth data
74
+ config.email = None
75
+ config.access_token = None
76
+ config.refresh_token = None
77
+ config.api_key = None
78
+ config.save()
79
+
80
+ console.print(
81
+ Panel(
82
+ "[green]✓[/green] Logged out successfully\n\n"
83
+ "All credentials cleared from config file.",
84
+ title="Logout",
85
+ border_style="green",
86
+ )
87
+ )
88
+
89
+ except Exception as e:
90
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
91
+ raise click.Abort()
@@ -0,0 +1,142 @@
1
+ """Configuration management commands."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.syntax import Syntax
6
+ import yaml
7
+
8
+ from ..config import Config
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.group()
14
+ def config():
15
+ """Manage CLI configuration."""
16
+ pass
17
+
18
+
19
+ @config.command("init")
20
+ def init_config():
21
+ """Initialize configuration file."""
22
+ config_file = Config.config_path()
23
+
24
+ if config_file.exists():
25
+ console.print(f"[yellow]Config file already exists:[/yellow] {config_file}")
26
+ if not click.confirm("Overwrite?", default=False):
27
+ return
28
+
29
+ # Create default config
30
+ cfg = Config()
31
+ cfg.save()
32
+
33
+ console.print(
34
+ Panel(
35
+ f"[green]✓[/green] Configuration file created\n\n"
36
+ f"Location: {config_file}\n\n"
37
+ f"Edit this file to set your default server and credentials.",
38
+ title="Config Initialized",
39
+ border_style="green",
40
+ )
41
+ )
42
+
43
+
44
+ @config.command("show")
45
+ def show_config():
46
+ """Show current configuration."""
47
+ try:
48
+ cfg = Config.load()
49
+ config_data = cfg.to_dict()
50
+
51
+ # Format as YAML for display
52
+ yaml_output = yaml.dump(config_data, default_flow_style=False)
53
+ syntax = Syntax(yaml_output, "yaml", theme="monokai", line_numbers=False)
54
+
55
+ console.print(
56
+ Panel(
57
+ syntax,
58
+ title=f"Configuration ({Config.config_path()})",
59
+ border_style="cyan",
60
+ )
61
+ )
62
+
63
+ except FileNotFoundError:
64
+ console.print(
65
+ "[yellow]No configuration file found.[/yellow]\n"
66
+ "Run: notifer config init"
67
+ )
68
+ except Exception as e:
69
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
70
+ raise click.Abort()
71
+
72
+
73
+ @config.command("set")
74
+ @click.argument("key")
75
+ @click.argument("value")
76
+ def set_config(key, value):
77
+ """
78
+ Set a configuration value.
79
+
80
+ \b
81
+ Examples:
82
+ notifer config set server http://localhost:8080
83
+ notifer config set api-key noti_abc123...
84
+ """
85
+ try:
86
+ cfg = Config.load()
87
+
88
+ # Map CLI keys to config attributes
89
+ key_mapping = {
90
+ "server": "server",
91
+ "api-key": "api_key",
92
+ "api_key": "api_key",
93
+ "email": "email",
94
+ }
95
+
96
+ config_key = key_mapping.get(key)
97
+ if not config_key:
98
+ console.print(
99
+ f"[red]✗ Unknown config key:[/red] {key}\n"
100
+ f"Valid keys: {', '.join(key_mapping.keys())}"
101
+ )
102
+ raise click.Abort()
103
+
104
+ # Set value
105
+ setattr(cfg, config_key, value)
106
+ cfg.save()
107
+
108
+ console.print(f"[green]✓[/green] Set {key} = {value}")
109
+
110
+ except Exception as e:
111
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
112
+ raise click.Abort()
113
+
114
+
115
+ @config.command("get")
116
+ @click.argument("key")
117
+ def get_config(key):
118
+ """Get a configuration value."""
119
+ try:
120
+ cfg = Config.load()
121
+
122
+ key_mapping = {
123
+ "server": "server",
124
+ "api-key": "api_key",
125
+ "api_key": "api_key",
126
+ "email": "email",
127
+ }
128
+
129
+ config_key = key_mapping.get(key)
130
+ if not config_key:
131
+ console.print(f"[red]✗ Unknown config key:[/red] {key}")
132
+ raise click.Abort()
133
+
134
+ value = getattr(cfg, config_key, None)
135
+ if value:
136
+ console.print(value)
137
+ else:
138
+ console.print(f"[dim]{key} not set[/dim]")
139
+
140
+ except Exception as e:
141
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
142
+ raise click.Abort()
@@ -0,0 +1,187 @@
1
+ """API keys management commands."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from rich.panel import Panel
6
+ from rich.prompt import Confirm
7
+
8
+ from ..client import NotiferClient
9
+ from ..config import Config
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group()
15
+ def keys():
16
+ """Manage API keys for programmatic access."""
17
+ pass
18
+
19
+
20
+ @keys.command("list")
21
+ @click.option("--api-key", help="API key for authentication")
22
+ @click.option("--server", help="Override server URL")
23
+ def list_keys(api_key, server):
24
+ """List all API keys."""
25
+ try:
26
+ config = Config.load()
27
+ if server:
28
+ config.server = server
29
+ if api_key:
30
+ config.api_key = api_key
31
+
32
+ client = NotiferClient(config)
33
+ keys_data = client.list_api_keys()
34
+
35
+ if not keys_data:
36
+ console.print("[yellow]No API keys found[/yellow]")
37
+ return
38
+
39
+ # Create table
40
+ table = Table(title="API Keys", show_header=True, header_style="bold cyan")
41
+ table.add_column("Name", style="cyan")
42
+ table.add_column("Prefix", style="dim")
43
+ table.add_column("Scopes", style="green")
44
+ table.add_column("Requests", justify="right")
45
+ table.add_column("Status", justify="center")
46
+ table.add_column("Created")
47
+ table.add_column("Last Used")
48
+
49
+ for key in keys_data:
50
+ scopes_str = ", ".join(key["scopes"][:3])
51
+ if len(key["scopes"]) > 3:
52
+ scopes_str += f" +{len(key['scopes']) - 3} more"
53
+
54
+ status = "[green]✓ Active[/green]" if key["is_active"] else "[red]✗ Revoked[/red]"
55
+ last_used = key["last_used"] or "[dim]Never[/dim]"
56
+
57
+ table.add_row(
58
+ key["name"],
59
+ key["key_prefix"],
60
+ scopes_str,
61
+ str(key["request_count"]),
62
+ status,
63
+ key["created_at"][:10],
64
+ last_used[:10] if key["last_used"] else last_used,
65
+ )
66
+
67
+ console.print(table)
68
+
69
+ except Exception as e:
70
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
71
+ raise click.Abort()
72
+
73
+
74
+ @keys.command("create")
75
+ @click.argument("name")
76
+ @click.option("--description", "-d", help="Key description")
77
+ @click.option("--scopes", "-s", default="*", help="Comma-separated scopes (default: *)")
78
+ @click.option("--expires", help="Expiration date (ISO format)")
79
+ @click.option("--api-key", help="API key for authentication")
80
+ @click.option("--server", help="Override server URL")
81
+ def create_key(name, description, scopes, expires, api_key, server):
82
+ """
83
+ Create a new API key.
84
+
85
+ \b
86
+ Examples:
87
+ notifer keys create "CI/CD Pipeline"
88
+ notifer keys create "Monitoring" --scopes publish,subscribe
89
+ notifer keys create "Read Only" --scopes topics:read,subscribe
90
+ """
91
+ try:
92
+ config = Config.load()
93
+ if server:
94
+ config.server = server
95
+ if api_key:
96
+ config.api_key = api_key
97
+
98
+ # Parse scopes
99
+ scope_list = [s.strip() for s in scopes.split(",")] if scopes != "*" else ["*"]
100
+
101
+ client = NotiferClient(config)
102
+ result = client.create_api_key(
103
+ name=name,
104
+ description=description,
105
+ scopes=scope_list,
106
+ expires_at=expires,
107
+ )
108
+
109
+ # Show the key (only shown once!)
110
+ console.print(
111
+ Panel(
112
+ f"[yellow]⚠ IMPORTANT: Save this key now - it won't be shown again![/yellow]\n\n"
113
+ f"[bold green]{result['key']}[/bold green]\n\n"
114
+ f"Name: {result['name']}\n"
115
+ f"Scopes: {', '.join(result['scopes'])}\n"
116
+ f"Created: {result['created_at'][:19]}",
117
+ title="API Key Created",
118
+ border_style="green",
119
+ )
120
+ )
121
+
122
+ # Offer to save to config
123
+ if Confirm.ask("\nSave this key to config file?", default=False):
124
+ config.api_key = result["key"]
125
+ config.save()
126
+ console.print("[green]✓[/green] Key saved to ~/.notifer.yaml")
127
+
128
+ except Exception as e:
129
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
130
+ raise click.Abort()
131
+
132
+
133
+ @keys.command("revoke")
134
+ @click.argument("key_id")
135
+ @click.option("--api-key", help="API key for authentication")
136
+ @click.option("--server", help="Override server URL")
137
+ def revoke_key(key_id, api_key, server):
138
+ """Revoke an API key (keeps for audit)."""
139
+ try:
140
+ config = Config.load()
141
+ if server:
142
+ config.server = server
143
+ if api_key:
144
+ config.api_key = api_key
145
+
146
+ if not Confirm.ask(f"Revoke API key {key_id}?", default=False):
147
+ console.print("Cancelled")
148
+ return
149
+
150
+ client = NotiferClient(config)
151
+ client.revoke_api_key(key_id)
152
+
153
+ console.print(f"[green]✓[/green] API key revoked: {key_id}")
154
+
155
+ except Exception as e:
156
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
157
+ raise click.Abort()
158
+
159
+
160
+ @keys.command("delete")
161
+ @click.argument("key_id")
162
+ @click.option("--api-key", help="API key for authentication")
163
+ @click.option("--server", help="Override server URL")
164
+ def delete_key(key_id, api_key, server):
165
+ """Permanently delete an API key."""
166
+ try:
167
+ config = Config.load()
168
+ if server:
169
+ config.server = server
170
+ if api_key:
171
+ config.api_key = api_key
172
+
173
+ if not Confirm.ask(
174
+ f"[red]Permanently delete[/red] API key {key_id}? This cannot be undone!",
175
+ default=False,
176
+ ):
177
+ console.print("Cancelled")
178
+ return
179
+
180
+ client = NotiferClient(config)
181
+ client.delete_api_key(key_id)
182
+
183
+ console.print(f"[green]✓[/green] API key deleted: {key_id}")
184
+
185
+ except Exception as e:
186
+ console.print(f"[red]✗ Error:[/red] {str(e)}", style="red")
187
+ raise click.Abort()