richmack-workspace 0.1.3__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.
@@ -0,0 +1,56 @@
1
+ name: Publish Richmack Workspace to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ name: Build package
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - name: Checkout repository
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install build tools
25
+ run: python -m pip install --upgrade build
26
+
27
+ - name: Build distribution
28
+ run: python -m build
29
+
30
+ - name: Upload distributions
31
+ uses: actions/upload-artifact@v4
32
+ with:
33
+ name: python-package-distributions
34
+ path: dist/
35
+
36
+ publish:
37
+ name: Publish to PyPI
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+
41
+ environment:
42
+ name: pypi
43
+ url: https://pypi.org/project/richmack-workspace/
44
+
45
+ permissions:
46
+ id-token: write
47
+
48
+ steps:
49
+ - name: Download distributions
50
+ uses: actions/download-artifact@v4
51
+ with:
52
+ name: python-package-distributions
53
+ path: dist/
54
+
55
+ - name: Publish package to PyPI
56
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .DS_Store
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: richmack-workspace
3
+ Version: 0.1.3
4
+ Summary: Terminal workspace and launcher for the Richmack PyPI application ecosystem.
5
+ Author: Jeremy Franklin
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: platformdirs>=4.0
9
+ Requires-Dist: textual>=0.70
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ⚡ Richmack Workspace
13
+
14
+ **Richmack Workspace** is a keyboard-first Textual launcher and package manager for the Richmack terminal application ecosystem.
15
+
16
+ ## 🚀 Current application catalog
17
+
18
+ - 🧙 **Wize Wizard** — `wize-wizard`
19
+ - 🥷 **Language Ninja** — `language-ninja`
20
+ - 🍯 **Pot of Mannah** — `pot-of-mannah`
21
+ - 💪 **ExerxEye** — `ExerxEye`
22
+ - 📜 **Hebrew Fuzzy Study** — `hebrew-fuzzy-study`
23
+ - 🧞 **JinnLab** — `jinnlab`
24
+
25
+ Each managed application is installed in its **own isolated virtual environment**, so dependencies from one Richmack application do not break another.
26
+
27
+ ## 🧠 Architecture
28
+
29
+ ```text
30
+ Richmack Workspace
31
+
32
+ ├── App Catalog
33
+
34
+ ├── SQLite Activity Registry
35
+
36
+ └── Managed Application Environments
37
+
38
+ ├── wize-wizard/.venv
39
+ ├── language-ninja/.venv
40
+ ├── pot-of-mannah/.venv
41
+ ├── exerxeye/.venv
42
+ ├── hebrew-fuzzy-study/.venv
43
+ └── jinnlab/.venv
44
+ ```
45
+
46
+ Managed data is stored under the platform-specific user data directory. On macOS this is typically under:
47
+
48
+ ```text
49
+ ~/Library/Application Support/richmack-workspace/
50
+ ```
51
+
52
+ ## 🛠️ Development install
53
+
54
+ ```bash
55
+ cd richmack-workspace
56
+ chmod +x install.sh
57
+ ./install.sh
58
+ source .venv/bin/activate
59
+ richmack
60
+ ```
61
+
62
+ Or:
63
+
64
+ ```bash
65
+ python3 -m venv .venv
66
+ source .venv/bin/activate
67
+ python -m pip install --upgrade pip
68
+ python -m pip install -e .
69
+ richmack
70
+ ```
71
+
72
+ ## ⌨️ Keyboard controls
73
+
74
+ | Key | Action |
75
+ |---|---|
76
+ | `↑` / `↓` | Select an application |
77
+ | `Enter` | Launch selected application |
78
+ | `G` | Get/install |
79
+ | `U` | Update |
80
+ | `X` | Remove |
81
+ | `R` | Refresh |
82
+ | `Q` | Quit |
83
+
84
+ ## 📦 How installation works
85
+
86
+ When you select an application and press `G`, Workspace:
87
+
88
+ 1. Creates an isolated virtual environment.
89
+ 2. Upgrades pip inside that environment.
90
+ 3. Installs the selected package from PyPI.
91
+ 4. Reads the installed package metadata.
92
+ 5. Detects the package's `console_scripts` entry point.
93
+ 6. Records the operation in SQLite.
94
+
95
+ When you press `Enter`, Workspace suspends its own Textual interface and gives the terminal to the selected child TUI. When the child program exits, you return to Richmack Workspace.
96
+
97
+ ## 🧪 Test
98
+
99
+ ```bash
100
+ python -m pip install pytest
101
+ pytest -q
102
+ ```
103
+
104
+ ## 📦 Build
105
+
106
+ ```bash
107
+ python -m pip install build
108
+ python -m build
109
+ ```
110
+
111
+ Expected output:
112
+
113
+ ```text
114
+ dist/
115
+ ├── richmack_workspace-0.1.0-py3-none-any.whl
116
+ └── richmack_workspace-0.1.0.tar.gz
117
+ ```
118
+
119
+ ## 🌐 Future roadmap
120
+
121
+ ### v0.2
122
+ - PyPI latest-version lookup
123
+ - Update-available indicator
124
+ - Update All
125
+ - Installed-only view
126
+ - Launch counters
127
+
128
+ ### v0.3
129
+ - Application search
130
+ - Favorites
131
+ - Shared Richmack theme/config
132
+ - Health checks
133
+ - Broken-environment repair
134
+
135
+ ### v1.0
136
+ - Richmack account/workspace profile
137
+ - Shared project registry
138
+ - Cross-application file handoff
139
+ - Richmack Workspace plugin manifest
140
+ - Optional remote package catalog
141
+
142
+ ---
143
+
144
+ **Richmack Workspace** is intended to become the terminal front door for the wider Richmack software ecosystem.
145
+
146
+
147
+ ## ✨ v0.1.1 interface improvements
148
+
149
+ - Added large **RICHMACK WORKSPACE** ASCII branding.
150
+ - Added an always-visible keyboard instruction strip.
151
+ - Increased application-menu row height and spacing.
152
+ - Replaced human-character emojis with more neutral symbols.
153
+ - Package installation and update output is now redirected to per-app log files instead of being printed over the Textual display.
154
+ - Package-manager failures show the log location in the TUI.
155
+
156
+ Logs are stored in the Workspace data directory under:
157
+
158
+ ```text
159
+ logs/<application>.log
160
+ ```
@@ -0,0 +1,149 @@
1
+ # ⚡ Richmack Workspace
2
+
3
+ **Richmack Workspace** is a keyboard-first Textual launcher and package manager for the Richmack terminal application ecosystem.
4
+
5
+ ## 🚀 Current application catalog
6
+
7
+ - 🧙 **Wize Wizard** — `wize-wizard`
8
+ - 🥷 **Language Ninja** — `language-ninja`
9
+ - 🍯 **Pot of Mannah** — `pot-of-mannah`
10
+ - 💪 **ExerxEye** — `ExerxEye`
11
+ - 📜 **Hebrew Fuzzy Study** — `hebrew-fuzzy-study`
12
+ - 🧞 **JinnLab** — `jinnlab`
13
+
14
+ Each managed application is installed in its **own isolated virtual environment**, so dependencies from one Richmack application do not break another.
15
+
16
+ ## 🧠 Architecture
17
+
18
+ ```text
19
+ Richmack Workspace
20
+
21
+ ├── App Catalog
22
+
23
+ ├── SQLite Activity Registry
24
+
25
+ └── Managed Application Environments
26
+
27
+ ├── wize-wizard/.venv
28
+ ├── language-ninja/.venv
29
+ ├── pot-of-mannah/.venv
30
+ ├── exerxeye/.venv
31
+ ├── hebrew-fuzzy-study/.venv
32
+ └── jinnlab/.venv
33
+ ```
34
+
35
+ Managed data is stored under the platform-specific user data directory. On macOS this is typically under:
36
+
37
+ ```text
38
+ ~/Library/Application Support/richmack-workspace/
39
+ ```
40
+
41
+ ## 🛠️ Development install
42
+
43
+ ```bash
44
+ cd richmack-workspace
45
+ chmod +x install.sh
46
+ ./install.sh
47
+ source .venv/bin/activate
48
+ richmack
49
+ ```
50
+
51
+ Or:
52
+
53
+ ```bash
54
+ python3 -m venv .venv
55
+ source .venv/bin/activate
56
+ python -m pip install --upgrade pip
57
+ python -m pip install -e .
58
+ richmack
59
+ ```
60
+
61
+ ## ⌨️ Keyboard controls
62
+
63
+ | Key | Action |
64
+ |---|---|
65
+ | `↑` / `↓` | Select an application |
66
+ | `Enter` | Launch selected application |
67
+ | `G` | Get/install |
68
+ | `U` | Update |
69
+ | `X` | Remove |
70
+ | `R` | Refresh |
71
+ | `Q` | Quit |
72
+
73
+ ## 📦 How installation works
74
+
75
+ When you select an application and press `G`, Workspace:
76
+
77
+ 1. Creates an isolated virtual environment.
78
+ 2. Upgrades pip inside that environment.
79
+ 3. Installs the selected package from PyPI.
80
+ 4. Reads the installed package metadata.
81
+ 5. Detects the package's `console_scripts` entry point.
82
+ 6. Records the operation in SQLite.
83
+
84
+ When you press `Enter`, Workspace suspends its own Textual interface and gives the terminal to the selected child TUI. When the child program exits, you return to Richmack Workspace.
85
+
86
+ ## 🧪 Test
87
+
88
+ ```bash
89
+ python -m pip install pytest
90
+ pytest -q
91
+ ```
92
+
93
+ ## 📦 Build
94
+
95
+ ```bash
96
+ python -m pip install build
97
+ python -m build
98
+ ```
99
+
100
+ Expected output:
101
+
102
+ ```text
103
+ dist/
104
+ ├── richmack_workspace-0.1.0-py3-none-any.whl
105
+ └── richmack_workspace-0.1.0.tar.gz
106
+ ```
107
+
108
+ ## 🌐 Future roadmap
109
+
110
+ ### v0.2
111
+ - PyPI latest-version lookup
112
+ - Update-available indicator
113
+ - Update All
114
+ - Installed-only view
115
+ - Launch counters
116
+
117
+ ### v0.3
118
+ - Application search
119
+ - Favorites
120
+ - Shared Richmack theme/config
121
+ - Health checks
122
+ - Broken-environment repair
123
+
124
+ ### v1.0
125
+ - Richmack account/workspace profile
126
+ - Shared project registry
127
+ - Cross-application file handoff
128
+ - Richmack Workspace plugin manifest
129
+ - Optional remote package catalog
130
+
131
+ ---
132
+
133
+ **Richmack Workspace** is intended to become the terminal front door for the wider Richmack software ecosystem.
134
+
135
+
136
+ ## ✨ v0.1.1 interface improvements
137
+
138
+ - Added large **RICHMACK WORKSPACE** ASCII branding.
139
+ - Added an always-visible keyboard instruction strip.
140
+ - Increased application-menu row height and spacing.
141
+ - Replaced human-character emojis with more neutral symbols.
142
+ - Package installation and update output is now redirected to per-app log files instead of being printed over the Textual display.
143
+ - Package-manager failures show the log location in the TUI.
144
+
145
+ Logs are stored in the Workspace data directory under:
146
+
147
+ ```text
148
+ logs/<application>.log
149
+ ```
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ python3 -m venv .venv
5
+ source .venv/bin/activate
6
+ python -m pip install --upgrade pip
7
+ python -m pip install -e .
8
+
9
+ echo
10
+ echo "✅ Richmack Workspace installed in .venv"
11
+ echo "Run:"
12
+ echo " source .venv/bin/activate"
13
+ echo " richmack"
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "richmack-workspace"
7
+ version = "0.1.3"
8
+ description = "Terminal workspace and launcher for the Richmack PyPI application ecosystem."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Jeremy Franklin" }
14
+ ]
15
+ dependencies = [
16
+ "textual>=0.70",
17
+ "platformdirs>=4.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ richmack = "richmack_workspace.app:main"
22
+ richmack-workspace = "richmack_workspace.app:main"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["richmack_workspace"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -0,0 +1,354 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ from textual import work
5
+ from textual.app import App, ComposeResult
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import Button, Footer, Header, Label, ListItem, ListView, Static
9
+
10
+ from .catalog import APPS, WorkspaceApp
11
+ from .db import recent_activity
12
+ from .manager import WorkspaceManager
13
+
14
+
15
+ WORKSPACE_ART = r"""
16
+ ____ ___ ____ _ _ __ __ _ ____ _ __
17
+ | _ \\|_ _/ ___| | | | \\/ | / \\ / ___| |/ /
18
+ | |_) || | | | |_| | |\\/| | / _ \\| | | ' /
19
+ | _ < | | |___| _ | | | |/ ___ \\ |___| . \\
20
+ |_| \\_\\___\\____|_| |_|_| |_/_/ \\_\\____|_|\\_\\
21
+
22
+ W O R K S P A C E
23
+ """
24
+
25
+
26
+ class ConfirmUninstall(ModalScreen[bool]):
27
+ CSS = """
28
+ ConfirmUninstall {
29
+ align: center middle;
30
+ }
31
+
32
+ #confirm-box {
33
+ width: 64;
34
+ height: auto;
35
+ border: round $warning;
36
+ padding: 1 2;
37
+ background: $surface;
38
+ }
39
+
40
+ #confirm-buttons {
41
+ height: auto;
42
+ align-horizontal: center;
43
+ margin-top: 1;
44
+ }
45
+ """
46
+
47
+ def __init__(self, app_name: str) -> None:
48
+ super().__init__()
49
+ self.app_name = app_name
50
+
51
+ def compose(self) -> ComposeResult:
52
+ with Vertical(id="confirm-box"):
53
+ yield Label(f"Remove {self.app_name} from Richmack Workspace?")
54
+ with Horizontal(id="confirm-buttons"):
55
+ yield Button("Cancel", id="cancel")
56
+ yield Button("Remove", id="remove", variant="error")
57
+
58
+ def on_button_pressed(self, event: Button.Pressed) -> None:
59
+ self.dismiss(event.button.id == "remove")
60
+
61
+
62
+ class RichmackWorkspace(App):
63
+ TITLE = "Richmack Workspace"
64
+ SUB_TITLE = "Terminal Intelligence Environment"
65
+
66
+ CSS = """
67
+ Screen {
68
+ layout: vertical;
69
+ }
70
+
71
+ #brand {
72
+ height: 9;
73
+ content-align: center middle;
74
+ text-align: center;
75
+ text-style: bold;
76
+ border-bottom: heavy $primary;
77
+ padding: 0 1;
78
+ }
79
+
80
+ #instructions {
81
+ height: 3;
82
+ content-align: center middle;
83
+ text-align: center;
84
+ padding: 0 1;
85
+ border-bottom: solid $boost;
86
+ }
87
+
88
+ #main {
89
+ height: 1fr;
90
+ }
91
+
92
+ #apps {
93
+ width: 42%;
94
+ min-width: 38;
95
+ border: round $primary;
96
+ }
97
+
98
+ #detail {
99
+ width: 58%;
100
+ padding: 1 2;
101
+ border: round $secondary;
102
+ }
103
+
104
+ #big-icon {
105
+ height: 5;
106
+ content-align: center middle;
107
+ text-align: center;
108
+ text-style: bold;
109
+ border-bottom: solid $boost;
110
+ margin-bottom: 1;
111
+ }
112
+
113
+ #app-title {
114
+ text-style: bold;
115
+ text-align: center;
116
+ margin-bottom: 1;
117
+ }
118
+
119
+ #description {
120
+ margin-bottom: 1;
121
+ }
122
+
123
+ #status {
124
+ margin: 1 0;
125
+ }
126
+
127
+ #actions {
128
+ height: auto;
129
+ margin-top: 1;
130
+ }
131
+
132
+ #actions Button {
133
+ margin-right: 1;
134
+ }
135
+
136
+ #activity {
137
+ margin-top: 2;
138
+ height: 1fr;
139
+ border-top: solid $boost;
140
+ padding-top: 1;
141
+ }
142
+
143
+ ListItem {
144
+ height: 3;
145
+ padding: 1 2;
146
+ }
147
+
148
+ ListItem.--highlight {
149
+ text-style: bold;
150
+ }
151
+ """
152
+
153
+ BINDINGS = [
154
+ ("enter", "launch", "Launch"),
155
+ ("g", "install", "Get"),
156
+ ("u", "update", "Update"),
157
+ ("x", "uninstall", "Remove"),
158
+ ("r", "refresh", "Refresh"),
159
+ ("q", "quit", "Quit"),
160
+ ]
161
+
162
+ def __init__(self) -> None:
163
+ super().__init__()
164
+ self.manager = WorkspaceManager()
165
+ self.selected_index = 0
166
+
167
+ def compose(self) -> ComposeResult:
168
+ yield Header(show_clock=True)
169
+ yield Static(WORKSPACE_ART, id="brand")
170
+ yield Static(
171
+ "UP/DOWN Select ENTER Launch G Install U Update "
172
+ "X Remove R Refresh Q Quit",
173
+ id="instructions",
174
+ )
175
+ with Horizontal(id="main"):
176
+ yield ListView(
177
+ *[
178
+ ListItem(
179
+ Label(f" {app.emoji} {app.name}"),
180
+ id=f"app-{i}",
181
+ )
182
+ for i, app in enumerate(APPS)
183
+ ],
184
+ id="apps",
185
+ )
186
+ with Vertical(id="detail"):
187
+ yield Static("", id="big-icon")
188
+ yield Static("", id="app-title")
189
+ yield Static("", id="description")
190
+ yield Static("", id="package")
191
+ yield Static("", id="status")
192
+ with Horizontal(id="actions"):
193
+ yield Button("Launch", id="launch", variant="primary")
194
+ yield Button("Get", id="install")
195
+ yield Button("Update", id="update")
196
+ yield Button("Remove", id="uninstall", variant="error")
197
+ yield Static("", id="activity")
198
+ yield Footer()
199
+
200
+ def on_mount(self) -> None:
201
+ apps = self.query_one("#apps", ListView)
202
+ apps.index = 0
203
+ self.refresh_detail()
204
+
205
+ def selected_app(self) -> WorkspaceApp:
206
+ return APPS[self.selected_index]
207
+
208
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
209
+ if event.item is None:
210
+ return
211
+ item_id = event.item.id or ""
212
+ if item_id.startswith("app-"):
213
+ self.selected_index = int(item_id.split("-", 1)[1])
214
+ self.refresh_detail()
215
+
216
+ def refresh_detail(self) -> None:
217
+ app = self.selected_app()
218
+ status = self.manager.status(app)
219
+
220
+ self.query_one("#big-icon", Static).update(
221
+ f"[bold]\n {app.emoji} {app.emoji} {app.emoji}\n[/bold]"
222
+ )
223
+ self.query_one("#app-title", Static).update(f"[bold]{app.name}[/bold]")
224
+ self.query_one("#description", Static).update(app.description)
225
+ self.query_one("#package", Static).update(f"PyPI package: {app.package}")
226
+
227
+ if status.installed:
228
+ entry = status.entrypoint or "No console entry point detected"
229
+ status_text = (
230
+ f"[bold green]Installed[/bold green]\n"
231
+ f"Version: {status.version}\n"
232
+ f"Command: {entry}"
233
+ )
234
+ else:
235
+ status_text = (
236
+ "[yellow]Not installed[/yellow]\n"
237
+ "Press G or choose Get to install this application."
238
+ )
239
+
240
+ self.query_one("#status", Static).update(status_text)
241
+
242
+ rows = recent_activity(8)
243
+ if not rows:
244
+ activity = "[bold]Recent Activity[/bold]\nNo activity yet."
245
+ else:
246
+ lines = ["[bold]Recent Activity[/bold]"]
247
+ for row in rows:
248
+ stamp = row["created_at"].replace("T", " ")[:16]
249
+ lines.append(f"{stamp} {row['app_slug']:<20} {row['action']}")
250
+ activity = "\n".join(lines)
251
+ self.query_one("#activity", Static).update(activity)
252
+
253
+ def notify_error(self, error: Exception) -> None:
254
+ self.notify(str(error), severity="error", timeout=8)
255
+
256
+ @work(thread=True, exclusive=True)
257
+ def install_selected(self) -> None:
258
+ app = self.selected_app()
259
+ try:
260
+ self.call_from_thread(
261
+ self.notify,
262
+ f"Installing {app.name} in the background…",
263
+ timeout=5,
264
+ )
265
+ self.manager.install(app)
266
+ self.call_from_thread(
267
+ self.notify,
268
+ f"{app.name} installed successfully.",
269
+ severity="information",
270
+ )
271
+ except Exception as exc:
272
+ self.call_from_thread(self.notify_error, exc)
273
+ finally:
274
+ self.call_from_thread(self.refresh_detail)
275
+
276
+ @work(thread=True, exclusive=True)
277
+ def update_selected(self) -> None:
278
+ app = self.selected_app()
279
+ try:
280
+ self.call_from_thread(
281
+ self.notify,
282
+ f"Updating {app.name} in the background…",
283
+ timeout=5,
284
+ )
285
+ self.manager.update(app)
286
+ self.call_from_thread(
287
+ self.notify,
288
+ f"{app.name} update complete.",
289
+ )
290
+ except Exception as exc:
291
+ self.call_from_thread(self.notify_error, exc)
292
+ finally:
293
+ self.call_from_thread(self.refresh_detail)
294
+
295
+ def action_install(self) -> None:
296
+ self.install_selected()
297
+
298
+ def action_update(self) -> None:
299
+ self.update_selected()
300
+
301
+ def action_refresh(self) -> None:
302
+ self.refresh_detail()
303
+ self.notify("Workspace refreshed.")
304
+
305
+ def action_launch(self) -> None:
306
+ app = self.selected_app()
307
+ try:
308
+ if not self.manager.status(app).installed:
309
+ self.notify(
310
+ f"{app.name} is not installed. Press G to install.",
311
+ severity="warning",
312
+ )
313
+ return
314
+
315
+ with self.suspend():
316
+ self.manager.launch(app)
317
+
318
+ self.refresh_detail()
319
+ except Exception as exc:
320
+ self.notify_error(exc)
321
+
322
+ def action_uninstall(self) -> None:
323
+ app = self.selected_app()
324
+
325
+ def after_confirm(remove: bool | None) -> None:
326
+ if not remove:
327
+ return
328
+ try:
329
+ self.manager.uninstall(app)
330
+ self.notify(f"{app.name} removed.")
331
+ self.refresh_detail()
332
+ except Exception as exc:
333
+ self.notify_error(exc)
334
+
335
+ self.push_screen(ConfirmUninstall(app.name), after_confirm)
336
+
337
+ def on_button_pressed(self, event: Button.Pressed) -> None:
338
+ match event.button.id:
339
+ case "launch":
340
+ self.action_launch()
341
+ case "install":
342
+ self.action_install()
343
+ case "update":
344
+ self.action_update()
345
+ case "uninstall":
346
+ self.action_uninstall()
347
+
348
+
349
+ def main() -> None:
350
+ RichmackWorkspace().run()
351
+
352
+
353
+ if __name__ == "__main__":
354
+ main()
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class WorkspaceApp:
8
+ name: str
9
+ package: str
10
+ emoji: str
11
+ description: str
12
+ slug: str
13
+
14
+
15
+ APPS: tuple[WorkspaceApp, ...] = (
16
+ WorkspaceApp(
17
+ name="Wize Wizard",
18
+ package="wize-wizard",
19
+ emoji="🪄",
20
+ description="Strategy, PERT, communications, tasks, and journal planner.",
21
+ slug="wize-wizard",
22
+ ),
23
+ WorkspaceApp(
24
+ name="Language Ninja",
25
+ package="language-ninja",
26
+ emoji="🧠",
27
+ description="NLP sentiment training, analysis, notes, and export workstation.",
28
+ slug="language-ninja",
29
+ ),
30
+ WorkspaceApp(
31
+ name="Pot of Mannah",
32
+ package="pot-of-mannah",
33
+ emoji="🍯",
34
+ description="Terminal nutrition and training intelligence.",
35
+ slug="pot-of-mannah",
36
+ ),
37
+ WorkspaceApp(
38
+ name="ExerxEye",
39
+ package="ExerxEye",
40
+ emoji="⚙️",
41
+ description="Exercise intelligence, workout tracking, and progress analytics.",
42
+ slug="exerxeye",
43
+ ),
44
+ WorkspaceApp(
45
+ name="Hebrew Fuzzy Study",
46
+ package="hebrew-fuzzy-study",
47
+ emoji="📜",
48
+ description="Hebrew lexical search, Tanakh reader, scholar reference, and annotation.",
49
+ slug="hebrew-fuzzy-study",
50
+ ),
51
+ WorkspaceApp(
52
+ name="JinnLab",
53
+ package="jinnlab",
54
+ emoji="♟️",
55
+ description="Game theory laboratory for Axelrod matches, Moran processes, and tournaments.",
56
+ slug="jinnlab",
57
+ ),
58
+ )
59
+
60
+
61
+ def by_slug(slug: str) -> WorkspaceApp:
62
+ for app in APPS:
63
+ if app.slug == slug:
64
+ return app
65
+ raise KeyError(slug)
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+
6
+ from .paths import DB_PATH, ensure_dirs
7
+
8
+
9
+ def connect() -> sqlite3.Connection:
10
+ ensure_dirs()
11
+ con = sqlite3.connect(DB_PATH)
12
+ con.row_factory = sqlite3.Row
13
+ con.execute(
14
+ """
15
+ CREATE TABLE IF NOT EXISTS activity (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ created_at TEXT NOT NULL,
18
+ app_slug TEXT NOT NULL,
19
+ action TEXT NOT NULL,
20
+ detail TEXT
21
+ )
22
+ """
23
+ )
24
+ con.commit()
25
+ return con
26
+
27
+
28
+ def log_activity(app_slug: str, action: str, detail: str = "") -> None:
29
+ with connect() as con:
30
+ con.execute(
31
+ "INSERT INTO activity(created_at, app_slug, action, detail) VALUES (?, ?, ?, ?)",
32
+ (
33
+ datetime.now(timezone.utc).isoformat(timespec="seconds"),
34
+ app_slug,
35
+ action,
36
+ detail,
37
+ ),
38
+ )
39
+ con.commit()
40
+
41
+
42
+ def recent_activity(limit: int = 20) -> list[sqlite3.Row]:
43
+ with connect() as con:
44
+ return con.execute(
45
+ """
46
+ SELECT created_at, app_slug, action, detail
47
+ FROM activity
48
+ ORDER BY id DESC
49
+ LIMIT ?
50
+ """,
51
+ (limit,),
52
+ ).fetchall()
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .catalog import WorkspaceApp
12
+ from .db import log_activity
13
+ from .paths import APPS_DIR, LOG_DIR, ensure_dirs
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class AppStatus:
18
+ installed: bool
19
+ version: str | None = None
20
+ entrypoint: str | None = None
21
+ venv_dir: Path | None = None
22
+
23
+
24
+ class WorkspaceManager:
25
+ def __init__(self) -> None:
26
+ ensure_dirs()
27
+
28
+ def _run_logged(self, app: WorkspaceApp, command: list[str]) -> None:
29
+ """Run package-management commands without corrupting the Textual screen."""
30
+ log_path = LOG_DIR / f"{app.slug}.log"
31
+ with log_path.open("a", encoding="utf-8") as log:
32
+ log.write("\n$ " + " ".join(command) + "\n")
33
+ log.flush()
34
+ result = subprocess.run(
35
+ command,
36
+ stdout=log,
37
+ stderr=subprocess.STDOUT,
38
+ text=True,
39
+ check=False,
40
+ )
41
+ if result.returncode != 0:
42
+ raise RuntimeError(
43
+ f"Command failed for {app.name}. See log: {log_path}"
44
+ )
45
+
46
+ def app_dir(self, app: WorkspaceApp) -> Path:
47
+ return APPS_DIR / app.slug
48
+
49
+ def venv_dir(self, app: WorkspaceApp) -> Path:
50
+ return self.app_dir(app) / ".venv"
51
+
52
+ def python_path(self, app: WorkspaceApp) -> Path:
53
+ if os.name == "nt":
54
+ return self.venv_dir(app) / "Scripts" / "python.exe"
55
+ return self.venv_dir(app) / "bin" / "python"
56
+
57
+ def bin_dir(self, app: WorkspaceApp) -> Path:
58
+ if os.name == "nt":
59
+ return self.venv_dir(app) / "Scripts"
60
+ return self.venv_dir(app) / "bin"
61
+
62
+ def _metadata(self, app: WorkspaceApp) -> tuple[str | None, str | None]:
63
+ python = self.python_path(app)
64
+ if not python.exists():
65
+ return None, None
66
+
67
+ code = r"""
68
+ import importlib.metadata as md, json
69
+ package = __import__("sys").argv[1]
70
+ try:
71
+ dist = md.distribution(package)
72
+ except md.PackageNotFoundError:
73
+ print(json.dumps({"version": None, "entrypoint": None}))
74
+ raise SystemExit(0)
75
+
76
+ eps = [
77
+ ep.name for ep in dist.entry_points
78
+ if ep.group == "console_scripts"
79
+ ]
80
+ print(json.dumps({
81
+ "version": dist.version,
82
+ "entrypoint": eps[0] if eps else None,
83
+ }))
84
+ """
85
+ result = subprocess.run(
86
+ [str(python), "-c", code, app.package],
87
+ text=True,
88
+ stdout=subprocess.PIPE,
89
+ stderr=subprocess.PIPE,
90
+ check=False,
91
+ )
92
+ if result.returncode != 0:
93
+ return None, None
94
+ try:
95
+ data = json.loads(result.stdout.strip() or "{}")
96
+ except json.JSONDecodeError:
97
+ return None, None
98
+ return data.get("version"), data.get("entrypoint")
99
+
100
+ def status(self, app: WorkspaceApp) -> AppStatus:
101
+ python = self.python_path(app)
102
+ if not python.exists():
103
+ return AppStatus(False, venv_dir=self.venv_dir(app))
104
+
105
+ version, entrypoint = self._metadata(app)
106
+ return AppStatus(
107
+ installed=bool(version),
108
+ version=version,
109
+ entrypoint=entrypoint,
110
+ venv_dir=self.venv_dir(app),
111
+ )
112
+
113
+ def install(self, app: WorkspaceApp) -> None:
114
+ app_dir = self.app_dir(app)
115
+ venv = self.venv_dir(app)
116
+ app_dir.mkdir(parents=True, exist_ok=True)
117
+
118
+ if not self.python_path(app).exists():
119
+ self._run_logged(app, [sys.executable, "-m", "venv", str(venv)])
120
+
121
+ python = self.python_path(app)
122
+ self._run_logged(
123
+ app, [str(python), "-m", "pip", "install", "--upgrade", "pip"]
124
+ )
125
+ self._run_logged(
126
+ app, [str(python), "-m", "pip", "install", "--upgrade", app.package]
127
+ )
128
+
129
+ status = self.status(app)
130
+ log_activity(
131
+ app.slug,
132
+ "install",
133
+ f"{app.package} {status.version or ''}".strip(),
134
+ )
135
+
136
+ def update(self, app: WorkspaceApp) -> None:
137
+ if not self.status(app).installed:
138
+ self.install(app)
139
+ return
140
+
141
+ python = self.python_path(app)
142
+ self._run_logged(
143
+ app, [str(python), "-m", "pip", "install", "--upgrade", app.package]
144
+ )
145
+ status = self.status(app)
146
+ log_activity(
147
+ app.slug,
148
+ "update",
149
+ f"{app.package} {status.version or ''}".strip(),
150
+ )
151
+
152
+ def uninstall(self, app: WorkspaceApp) -> None:
153
+ target = self.app_dir(app)
154
+ if target.exists():
155
+ shutil.rmtree(target)
156
+ log_activity(app.slug, "uninstall", app.package)
157
+
158
+ def launch_command(self, app: WorkspaceApp) -> list[str]:
159
+ status = self.status(app)
160
+ if not status.installed:
161
+ raise RuntimeError(f"{app.name} is not installed.")
162
+ if not status.entrypoint:
163
+ raise RuntimeError(
164
+ f"{app.package} is installed but exposes no console_scripts entry point."
165
+ )
166
+
167
+ exe = self.bin_dir(app) / status.entrypoint
168
+ if os.name == "nt":
169
+ candidates = [exe, exe.with_suffix(".exe")]
170
+ for candidate in candidates:
171
+ if candidate.exists():
172
+ return [str(candidate)]
173
+ elif exe.exists():
174
+ return [str(exe)]
175
+
176
+ # Fallback through Python's script lookup environment.
177
+ return [status.entrypoint]
178
+
179
+ def launch(self, app: WorkspaceApp) -> int:
180
+ command = self.launch_command(app)
181
+ env = os.environ.copy()
182
+ env["PATH"] = str(self.bin_dir(app)) + os.pathsep + env.get("PATH", "")
183
+ log_activity(app.slug, "launch", " ".join(command))
184
+ return subprocess.run(command, env=env, check=False).returncode
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from platformdirs import user_data_dir
6
+
7
+ DATA_DIR = Path(user_data_dir("richmack-workspace", "Richmack"))
8
+ APPS_DIR = DATA_DIR / "apps"
9
+ DB_PATH = DATA_DIR / "workspace.db"
10
+ LOG_DIR = DATA_DIR / "logs"
11
+
12
+
13
+ def ensure_dirs() -> None:
14
+ APPS_DIR.mkdir(parents=True, exist_ok=True)
15
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,10 @@
1
+ from richmack_workspace.catalog import APPS
2
+
3
+
4
+ def test_catalog_has_unique_slugs() -> None:
5
+ slugs = [app.slug for app in APPS]
6
+ assert len(slugs) == len(set(slugs))
7
+
8
+
9
+ def test_catalog_has_packages() -> None:
10
+ assert all(app.package for app in APPS)