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.
Files changed (43) hide show
  1. smorg-1.2.0/PKG-INFO +61 -0
  2. smorg-1.2.0/README.md +47 -0
  3. smorg-1.2.0/pyproject.toml +58 -0
  4. smorg-1.2.0/pyproject.toml.orig +52 -0
  5. smorg-1.2.0/src/smorg/__init__.py +5 -0
  6. smorg-1.2.0/src/smorg/auth/__init__.py +0 -0
  7. smorg-1.2.0/src/smorg/auth/login.py +139 -0
  8. smorg-1.2.0/src/smorg/auth/oauth.py +339 -0
  9. smorg-1.2.0/src/smorg/auth/refresh.py +85 -0
  10. smorg-1.2.0/src/smorg/auth/store.py +260 -0
  11. smorg-1.2.0/src/smorg/auth/token.py +59 -0
  12. smorg-1.2.0/src/smorg/cli.py +293 -0
  13. smorg-1.2.0/src/smorg/core/__init__.py +1 -0
  14. smorg-1.2.0/src/smorg/core/config.py +179 -0
  15. smorg-1.2.0/src/smorg/core/contract.py +154 -0
  16. smorg-1.2.0/src/smorg/core/keys.py +46 -0
  17. smorg-1.2.0/src/smorg/core/mcp.py +221 -0
  18. smorg-1.2.0/src/smorg/core/path_setup.py +75 -0
  19. smorg-1.2.0/src/smorg/core/registry.py +50 -0
  20. smorg-1.2.0/src/smorg/core/removal.py +92 -0
  21. smorg-1.2.0/src/smorg/core/shape.py +37 -0
  22. smorg-1.2.0/src/smorg/core/state.py +83 -0
  23. smorg-1.2.0/src/smorg/core/text.py +37 -0
  24. smorg-1.2.0/src/smorg/core/update.py +48 -0
  25. smorg-1.2.0/src/smorg/integrations/__init__.py +13 -0
  26. smorg-1.2.0/src/smorg/integrations/github/__init__.py +3 -0
  27. smorg-1.2.0/src/smorg/integrations/github/manifest.py +54 -0
  28. smorg-1.2.0/src/smorg/integrations/github/panel.py +277 -0
  29. smorg-1.2.0/src/smorg/integrations/github/source.py +319 -0
  30. smorg-1.2.0/src/smorg/integrations/linear/__init__.py +3 -0
  31. smorg-1.2.0/src/smorg/integrations/linear/manifest.py +43 -0
  32. smorg-1.2.0/src/smorg/integrations/linear/panel.py +245 -0
  33. smorg-1.2.0/src/smorg/integrations/linear/source.py +198 -0
  34. smorg-1.2.0/src/smorg/shell/__init__.py +0 -0
  35. smorg-1.2.0/src/smorg/shell/app.py +572 -0
  36. smorg-1.2.0/src/smorg/shell/format.py +21 -0
  37. smorg-1.2.0/src/smorg/shell/help.py +84 -0
  38. smorg-1.2.0/src/smorg/shell/markdown.py +64 -0
  39. smorg-1.2.0/src/smorg/shell/menu.py +593 -0
  40. smorg-1.2.0/src/smorg/shell/modal.py +44 -0
  41. smorg-1.2.0/src/smorg/shell/panel.py +298 -0
  42. smorg-1.2.0/src/smorg/shell/refresh_indicator.py +84 -0
  43. 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"]
@@ -0,0 +1,5 @@
1
+ from importlib.metadata import version
2
+
3
+ # smorg only ever runs installed — uv installs the project before running
4
+ # anything — so an uninstalled import has nothing sensible to report.
5
+ __version__ = version("smorg")
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()