smorg 1.2.0__tar.gz
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.
- smorg-1.2.0/PKG-INFO +61 -0
- smorg-1.2.0/README.md +47 -0
- smorg-1.2.0/pyproject.toml +58 -0
- smorg-1.2.0/pyproject.toml.orig +52 -0
- smorg-1.2.0/src/smorg/__init__.py +5 -0
- smorg-1.2.0/src/smorg/auth/__init__.py +0 -0
- smorg-1.2.0/src/smorg/auth/login.py +139 -0
- smorg-1.2.0/src/smorg/auth/oauth.py +339 -0
- smorg-1.2.0/src/smorg/auth/refresh.py +85 -0
- smorg-1.2.0/src/smorg/auth/store.py +260 -0
- smorg-1.2.0/src/smorg/auth/token.py +59 -0
- smorg-1.2.0/src/smorg/cli.py +293 -0
- smorg-1.2.0/src/smorg/core/__init__.py +1 -0
- smorg-1.2.0/src/smorg/core/config.py +179 -0
- smorg-1.2.0/src/smorg/core/contract.py +154 -0
- smorg-1.2.0/src/smorg/core/keys.py +46 -0
- smorg-1.2.0/src/smorg/core/mcp.py +221 -0
- smorg-1.2.0/src/smorg/core/path_setup.py +75 -0
- smorg-1.2.0/src/smorg/core/registry.py +50 -0
- smorg-1.2.0/src/smorg/core/removal.py +92 -0
- smorg-1.2.0/src/smorg/core/shape.py +37 -0
- smorg-1.2.0/src/smorg/core/state.py +83 -0
- smorg-1.2.0/src/smorg/core/text.py +37 -0
- smorg-1.2.0/src/smorg/core/update.py +48 -0
- smorg-1.2.0/src/smorg/integrations/__init__.py +13 -0
- smorg-1.2.0/src/smorg/integrations/github/__init__.py +3 -0
- smorg-1.2.0/src/smorg/integrations/github/manifest.py +54 -0
- smorg-1.2.0/src/smorg/integrations/github/panel.py +277 -0
- smorg-1.2.0/src/smorg/integrations/github/source.py +319 -0
- smorg-1.2.0/src/smorg/integrations/linear/__init__.py +3 -0
- smorg-1.2.0/src/smorg/integrations/linear/manifest.py +43 -0
- smorg-1.2.0/src/smorg/integrations/linear/panel.py +245 -0
- smorg-1.2.0/src/smorg/integrations/linear/source.py +198 -0
- smorg-1.2.0/src/smorg/shell/__init__.py +0 -0
- smorg-1.2.0/src/smorg/shell/app.py +572 -0
- smorg-1.2.0/src/smorg/shell/format.py +21 -0
- smorg-1.2.0/src/smorg/shell/help.py +84 -0
- smorg-1.2.0/src/smorg/shell/markdown.py +64 -0
- smorg-1.2.0/src/smorg/shell/menu.py +593 -0
- smorg-1.2.0/src/smorg/shell/modal.py +44 -0
- smorg-1.2.0/src/smorg/shell/panel.py +298 -0
- smorg-1.2.0/src/smorg/shell/refresh_indicator.py +84 -0
- smorg-1.2.0/src/smorg/shell/terminal_palette.py +213 -0
smorg-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: smorg
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Keyboard-driven terminal dashboard, one tab per connected integration
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Dist: httpx>=0.28
|
|
7
|
+
Requires-Dist: keyring>=25.0
|
|
8
|
+
Requires-Dist: pygithub>=2.5
|
|
9
|
+
Requires-Dist: requests>=2.32
|
|
10
|
+
Requires-Dist: textual>=1.0
|
|
11
|
+
Requires-Dist: tomli-w>=1.0
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# smorg
|
|
16
|
+
|
|
17
|
+
Short for [Smorgasbord](https://en.wikipedia.org/wiki/Smorgasbord): a table
|
|
18
|
+
laid out with many dishes, everyone taking what they want.
|
|
19
|
+
|
|
20
|
+
smorg is that table for your work — a keyboard-driven terminal dashboard with
|
|
21
|
+
one tab per connected integration, showing what's on your plate and what
|
|
22
|
+
changed since you last looked. It reads and opens things, never writes; it
|
|
23
|
+
refreshes when you look at it, not on a timer. Nothing is enabled by default,
|
|
24
|
+
and anyone can bring a dish: an integration is one directory and one registry
|
|
25
|
+
line.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
One-off run, no install:
|
|
30
|
+
|
|
31
|
+
uvx smorg
|
|
32
|
+
|
|
33
|
+
That first run installs the package before the screen paints, so expect a
|
|
34
|
+
brief blank terminal.
|
|
35
|
+
|
|
36
|
+
To install it once and reuse it:
|
|
37
|
+
|
|
38
|
+
uv tool install smorg
|
|
39
|
+
smorg
|
|
40
|
+
|
|
41
|
+
Press `^ + p` and pick "Add integration" to connect one, then select the
|
|
42
|
+
desired connection method.
|
|
43
|
+
|
|
44
|
+
`smorg connect <integration>` does the same from the CLI, and is also how you
|
|
45
|
+
re-authenticate a tab whose token has expired or been revoked
|
|
46
|
+
|
|
47
|
+
`smorg logout <integration>` (or "Remove integration" from `^ + p`) removes a tab,
|
|
48
|
+
its stored token, and its seen marks.
|
|
49
|
+
|
|
50
|
+
Inside the dashboard: `h`/`l` switch tabs, `up`/`down` select an item, `o`
|
|
51
|
+
opens it in your browser, `r` refreshes, `m` marks the tab's changes seen,
|
|
52
|
+
`u` marks the selected item unseen, `?` shows the current tab's keys, `^ + p`
|
|
53
|
+
opens the menu (add/remove integrations, screenshots), `q` quits.
|
|
54
|
+
|
|
55
|
+
## Status
|
|
56
|
+
|
|
57
|
+
Two dishes on the table:
|
|
58
|
+
* **Linear**, showing the issues assigned to you
|
|
59
|
+
* **GitHub**, showing what needs your attention
|
|
60
|
+
|
|
61
|
+
See [docs/ROADMAP.md](docs/ROADMAP.md) for what's ahead.
|
smorg-1.2.0/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# smorg
|
|
2
|
+
|
|
3
|
+
Short for [Smorgasbord](https://en.wikipedia.org/wiki/Smorgasbord): a table
|
|
4
|
+
laid out with many dishes, everyone taking what they want.
|
|
5
|
+
|
|
6
|
+
smorg is that table for your work — a keyboard-driven terminal dashboard with
|
|
7
|
+
one tab per connected integration, showing what's on your plate and what
|
|
8
|
+
changed since you last looked. It reads and opens things, never writes; it
|
|
9
|
+
refreshes when you look at it, not on a timer. Nothing is enabled by default,
|
|
10
|
+
and anyone can bring a dish: an integration is one directory and one registry
|
|
11
|
+
line.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
One-off run, no install:
|
|
16
|
+
|
|
17
|
+
uvx smorg
|
|
18
|
+
|
|
19
|
+
That first run installs the package before the screen paints, so expect a
|
|
20
|
+
brief blank terminal.
|
|
21
|
+
|
|
22
|
+
To install it once and reuse it:
|
|
23
|
+
|
|
24
|
+
uv tool install smorg
|
|
25
|
+
smorg
|
|
26
|
+
|
|
27
|
+
Press `^ + p` and pick "Add integration" to connect one, then select the
|
|
28
|
+
desired connection method.
|
|
29
|
+
|
|
30
|
+
`smorg connect <integration>` does the same from the CLI, and is also how you
|
|
31
|
+
re-authenticate a tab whose token has expired or been revoked
|
|
32
|
+
|
|
33
|
+
`smorg logout <integration>` (or "Remove integration" from `^ + p`) removes a tab,
|
|
34
|
+
its stored token, and its seen marks.
|
|
35
|
+
|
|
36
|
+
Inside the dashboard: `h`/`l` switch tabs, `up`/`down` select an item, `o`
|
|
37
|
+
opens it in your browser, `r` refreshes, `m` marks the tab's changes seen,
|
|
38
|
+
`u` marks the selected item unseen, `?` shows the current tab's keys, `^ + p`
|
|
39
|
+
opens the menu (add/remove integrations, screenshots), `q` quits.
|
|
40
|
+
|
|
41
|
+
## Status
|
|
42
|
+
|
|
43
|
+
Two dishes on the table:
|
|
44
|
+
* **Linear**, showing the issues assigned to you
|
|
45
|
+
* **GitHub**, showing what needs your attention
|
|
46
|
+
|
|
47
|
+
See [docs/ROADMAP.md](docs/ROADMAP.md) for what's ahead.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smorg"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "Keyboard-driven terminal dashboard, one tab per connected integration"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.28",
|
|
9
|
+
"keyring>=25.0",
|
|
10
|
+
"pygithub>=2.5",
|
|
11
|
+
"requests>=2.32",
|
|
12
|
+
"textual>=1.0",
|
|
13
|
+
"tomli-w>=1.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.license]
|
|
17
|
+
text = "MIT"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
smorg = "smorg.cli:main"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.0",
|
|
25
|
+
"pytest-asyncio>=0.24",
|
|
26
|
+
"ruff>=0.9",
|
|
27
|
+
"pyright>=1.1",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.11,<0.13"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[[tool.uv.index]]
|
|
35
|
+
name = "pypi"
|
|
36
|
+
url = "https://pypi.org/simple"
|
|
37
|
+
default = true
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
asyncio_mode = "auto"
|
|
42
|
+
|
|
43
|
+
[tool.pyright]
|
|
44
|
+
venvPath = "."
|
|
45
|
+
venv = ".venv"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 100
|
|
49
|
+
extend-exclude = ["docs"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = [
|
|
53
|
+
"E",
|
|
54
|
+
"F",
|
|
55
|
+
"I",
|
|
56
|
+
"UP",
|
|
57
|
+
"B",
|
|
58
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smorg"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "Keyboard-driven terminal dashboard, one tab per connected integration"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.28",
|
|
10
|
+
"keyring>=25.0",
|
|
11
|
+
"pygithub>=2.5",
|
|
12
|
+
"requests>=2.32",
|
|
13
|
+
"textual>=1.0",
|
|
14
|
+
"tomli-w>=1.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
smorg = "smorg.cli:main"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "ruff>=0.9", "pyright>=1.1"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.11,<0.13"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
# Pinned so the lockfile resolves against public PyPI regardless of any index
|
|
28
|
+
# mirror configured in the local environment. Without this a mirror's URLs leak
|
|
29
|
+
# into uv.lock and CI cannot resolve them.
|
|
30
|
+
[[tool.uv.index]]
|
|
31
|
+
name = "pypi"
|
|
32
|
+
url = "https://pypi.org/simple"
|
|
33
|
+
default = true
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
|
|
39
|
+
# Pyright reads this directly, so import resolution does not depend on the
|
|
40
|
+
# editor's Python extension discovering an interpreter.
|
|
41
|
+
[tool.pyright]
|
|
42
|
+
venvPath = "."
|
|
43
|
+
venv = ".venv"
|
|
44
|
+
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
line-length = 100
|
|
47
|
+
# Ruff formats Python code blocks inside markdown; plan and spec snippets are
|
|
48
|
+
# illustrative and must not be rewritten by the formatter.
|
|
49
|
+
extend-exclude = ["docs"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The browser-driven half of OAuth: run a loopback callback server, send the
|
|
2
|
+
user to authorize, wait for the redirect, and exchange the code.
|
|
3
|
+
|
|
4
|
+
Print-free: the caller decides how to surface the authorize URL
|
|
5
|
+
(`on_authorize_url`) and whether the wait can be cancelled (`cancelled`).
|
|
6
|
+
`cli.run_login` is the printing frontend; the in-app connect modal is the other.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import http.server
|
|
12
|
+
import secrets
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import webbrowser
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from smorg.auth import oauth
|
|
22
|
+
from smorg.auth.oauth import ProviderConfig
|
|
23
|
+
from smorg.auth.store import Credentials
|
|
24
|
+
from smorg.core.text import sanitize_line
|
|
25
|
+
|
|
26
|
+
LOGIN_TIMEOUT_SECONDS = 300
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LoginCancelled(oauth.OAuthError):
|
|
30
|
+
"""The caller's `cancelled` event was set before the callback arrived."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _callback_handler(
|
|
34
|
+
expected_state: str, received: dict[str, str]
|
|
35
|
+
) -> type[http.server.BaseHTTPRequestHandler]:
|
|
36
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
37
|
+
def do_GET(self) -> None:
|
|
38
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
39
|
+
parsed_query = urllib.parse.parse_qs(parsed.query)
|
|
40
|
+
query = {key: value[0] for key, value in parsed_query.items()}
|
|
41
|
+
# Only a request whose `state` matches this login's is accepted —
|
|
42
|
+
# a stray probe or a forged ?error= is refused without ending the
|
|
43
|
+
# wait. Compared as bytes, since compare_digest's str form raises
|
|
44
|
+
# on non-ASCII input.
|
|
45
|
+
if (
|
|
46
|
+
parsed.path != "/callback"
|
|
47
|
+
or not secrets.compare_digest(
|
|
48
|
+
query.get("state", "").encode(), expected_state.encode()
|
|
49
|
+
)
|
|
50
|
+
or not query.keys() & {"code", "error"}
|
|
51
|
+
):
|
|
52
|
+
self.send_response(404)
|
|
53
|
+
self.end_headers()
|
|
54
|
+
return
|
|
55
|
+
received.update(query)
|
|
56
|
+
self.send_response(200)
|
|
57
|
+
self.send_header("content-type", "text/plain; charset=utf-8")
|
|
58
|
+
self.end_headers()
|
|
59
|
+
self.wfile.write(b"smorg: authentication complete. You can close this tab.")
|
|
60
|
+
|
|
61
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
62
|
+
"""Silence the default access log, which would print over our output."""
|
|
63
|
+
|
|
64
|
+
return Handler
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _cancellation_requested(cancelled: threading.Event | None) -> bool:
|
|
68
|
+
if cancelled is None:
|
|
69
|
+
return False
|
|
70
|
+
return cancelled.is_set()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def perform_login(
|
|
74
|
+
client: httpx.Client,
|
|
75
|
+
provider: ProviderConfig,
|
|
76
|
+
client_id: str | None,
|
|
77
|
+
*,
|
|
78
|
+
on_authorize_url: Callable[[str], None],
|
|
79
|
+
cancelled: threading.Event | None = None,
|
|
80
|
+
port: int = 0,
|
|
81
|
+
timeout: float = LOGIN_TIMEOUT_SECONDS,
|
|
82
|
+
) -> tuple[str, Credentials]:
|
|
83
|
+
"""Register if needed, take the user through the browser, return the tokens.
|
|
84
|
+
|
|
85
|
+
Returns the client id alongside the credentials so a first-time
|
|
86
|
+
registration can be persisted for later logins to reuse. The callback
|
|
87
|
+
listens on an ephemeral port nothing else can predict or squat in
|
|
88
|
+
advance; see REGISTRATION_PORT for the fixed port named at registration.
|
|
89
|
+
"""
|
|
90
|
+
verifier, challenge = oauth.make_pkce_pair()
|
|
91
|
+
state = secrets.token_urlsafe(16)
|
|
92
|
+
received: dict[str, str] = {}
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
server = http.server.HTTPServer(("127.0.0.1", port), _callback_handler(state, received))
|
|
96
|
+
except OSError as error:
|
|
97
|
+
raise oauth.OAuthError(f"could not open a port for the callback: {error}") from error
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Bound before the redirect is built so an ephemeral port resolves to
|
|
101
|
+
# the one actually listening.
|
|
102
|
+
redirect_uri = oauth.redirect_uri_for(server.server_port)
|
|
103
|
+
metadata = oauth.discover(client, provider)
|
|
104
|
+
if client_id is None:
|
|
105
|
+
client_id = oauth.register_client(
|
|
106
|
+
client, metadata, provider, oauth.REGISTERED_REDIRECT_URI
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
url = oauth.build_authorize_url(
|
|
110
|
+
metadata, client_id, redirect_uri, challenge, provider.scopes, state
|
|
111
|
+
)
|
|
112
|
+
on_authorize_url(url)
|
|
113
|
+
webbrowser.open(url)
|
|
114
|
+
|
|
115
|
+
# One deadline for the whole wait rather than per request, so a drip of
|
|
116
|
+
# stray requests cannot extend it indefinitely. cancelled is polled at
|
|
117
|
+
# the same 1-second granularity as the deadline.
|
|
118
|
+
server.timeout = 1.0 # seconds
|
|
119
|
+
deadline = time.monotonic() + timeout
|
|
120
|
+
while (
|
|
121
|
+
not received and time.monotonic() < deadline and not _cancellation_requested(cancelled)
|
|
122
|
+
):
|
|
123
|
+
server.handle_request()
|
|
124
|
+
|
|
125
|
+
# A code that already arrived wins over a cancellation requested in
|
|
126
|
+
# the same instant — only an empty result can still be cancelled.
|
|
127
|
+
if not received:
|
|
128
|
+
if _cancellation_requested(cancelled):
|
|
129
|
+
raise LoginCancelled("login cancelled before the browser callback arrived")
|
|
130
|
+
raise oauth.OAuthError("timed out waiting for the browser callback")
|
|
131
|
+
if "error" in received:
|
|
132
|
+
raise oauth.OAuthError(f"authorization was refused: {sanitize_line(received['error'])}")
|
|
133
|
+
|
|
134
|
+
credentials = oauth.exchange_code(
|
|
135
|
+
client, metadata, client_id, received["code"], verifier, redirect_uri
|
|
136
|
+
)
|
|
137
|
+
return client_id, credentials
|
|
138
|
+
finally:
|
|
139
|
+
server.server_close()
|