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.
- notifer_cli/__init__.py +3 -0
- notifer_cli/cli.py +47 -0
- notifer_cli/client.py +198 -0
- notifer_cli/commands/__init__.py +1 -0
- notifer_cli/commands/auth.py +91 -0
- notifer_cli/commands/config.py +142 -0
- notifer_cli/commands/keys.py +187 -0
- notifer_cli/commands/publish.py +75 -0
- notifer_cli/commands/subscribe.py +129 -0
- notifer_cli/commands/topics.py +197 -0
- notifer_cli/config.py +76 -0
- notifer_cli-1.0.0.dist-info/METADATA +325 -0
- notifer_cli-1.0.0.dist-info/RECORD +17 -0
- notifer_cli-1.0.0.dist-info/WHEEL +5 -0
- notifer_cli-1.0.0.dist-info/entry_points.txt +2 -0
- notifer_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- notifer_cli-1.0.0.dist-info/top_level.txt +1 -0
notifer_cli/__init__.py
ADDED
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()
|