rexdeck 0.1.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.
- rexdeck/__init__.py +1 -0
- rexdeck/cli.py +292 -0
- rexdeck/dependencies.py +92 -0
- rexdeck/lifecycle.py +1238 -0
- rexdeck/plugins/files/launch.py +218 -0
- rexdeck/plugins/files/main.lua +92 -0
- rexdeck/settings.py +188 -0
- rexdeck-0.1.0.dist-info/METADATA +127 -0
- rexdeck-0.1.0.dist-info/RECORD +12 -0
- rexdeck-0.1.0.dist-info/WHEEL +4 -0
- rexdeck-0.1.0.dist-info/entry_points.txt +2 -0
- rexdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Start unmodified Yazi in a native Rex terminal and restore its exact origin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import unquote, urlsplit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def directory(raw: str) -> str:
|
|
17
|
+
"""Decode only an explicit absolute local directory, never a fallback."""
|
|
18
|
+
if not raw or "\x00" in raw:
|
|
19
|
+
raise ValueError(
|
|
20
|
+
"An explicit local directory without NUL characters is required"
|
|
21
|
+
)
|
|
22
|
+
raw.encode("utf-8", errors="strict")
|
|
23
|
+
if raw.lower().startswith("file:"):
|
|
24
|
+
if any(ord(char) < 32 or ord(char) == 127 for char in raw):
|
|
25
|
+
raise ValueError("File URI contains unescaped control characters")
|
|
26
|
+
uri = urlsplit(raw)
|
|
27
|
+
authority = uri.netloc.casefold()
|
|
28
|
+
if (
|
|
29
|
+
authority not in ("", "localhost")
|
|
30
|
+
and authority != socket.gethostname().casefold()
|
|
31
|
+
and authority != socket.getfqdn().casefold()
|
|
32
|
+
):
|
|
33
|
+
raise ValueError("Remote or credentialed file URI is not supported")
|
|
34
|
+
if "?" in raw or "#" in raw:
|
|
35
|
+
raise ValueError("File URI must not contain a query or fragment")
|
|
36
|
+
if re.search(r"%(?![0-9a-fA-F]{2})", uri.path):
|
|
37
|
+
raise ValueError("File URI contains invalid percent encoding")
|
|
38
|
+
raw = unquote(uri.path, encoding="utf-8", errors="strict")
|
|
39
|
+
if "\x00" in raw or not raw.startswith("/") or raw.startswith("//"):
|
|
40
|
+
raise ValueError("An absolute local directory is required")
|
|
41
|
+
try:
|
|
42
|
+
path = Path(raw).resolve(strict=True)
|
|
43
|
+
except RuntimeError as error:
|
|
44
|
+
# Python 3.12 reports symlink loops as RuntimeError rather than OSError.
|
|
45
|
+
raise ValueError(str(error)) from error
|
|
46
|
+
if not path.is_dir():
|
|
47
|
+
raise ValueError("The invoking terminal's cwd is not a directory")
|
|
48
|
+
return str(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def git_directory(raw: str) -> str:
|
|
52
|
+
"""Keep cwd-based Git discovery; reject inherited repository redirection."""
|
|
53
|
+
overrides = {
|
|
54
|
+
"GIT_DIR",
|
|
55
|
+
"GIT_WORK_TREE",
|
|
56
|
+
"GIT_COMMON_DIR",
|
|
57
|
+
"GIT_INDEX_FILE",
|
|
58
|
+
"GIT_OBJECT_DIRECTORY",
|
|
59
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
60
|
+
"GIT_NAMESPACE",
|
|
61
|
+
"GIT_CONFIG",
|
|
62
|
+
"GIT_CONFIG_COUNT",
|
|
63
|
+
"GIT_CONFIG_PARAMETERS",
|
|
64
|
+
"GIT_CEILING_DIRECTORIES",
|
|
65
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
66
|
+
}
|
|
67
|
+
selected = sorted(
|
|
68
|
+
key
|
|
69
|
+
for key in os.environ
|
|
70
|
+
if key in overrides or key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"))
|
|
71
|
+
)
|
|
72
|
+
if selected:
|
|
73
|
+
raise ValueError("Ambiguous inherited Git override: " + ", ".join(selected))
|
|
74
|
+
cwd = directory(raw)
|
|
75
|
+
search_path = os.pathsep.join(
|
|
76
|
+
entry
|
|
77
|
+
for entry in os.environ.get("PATH", os.defpath).split(os.pathsep)
|
|
78
|
+
if Path(entry).is_absolute()
|
|
79
|
+
)
|
|
80
|
+
git = shutil.which("git", path=search_path) if search_path else None
|
|
81
|
+
if git is None:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"Required executable not found in absolute PATH directories: git"
|
|
84
|
+
)
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
[git, "rev-parse", "--is-inside-work-tree"],
|
|
87
|
+
cwd=cwd,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=10,
|
|
91
|
+
check=False,
|
|
92
|
+
)
|
|
93
|
+
if result.returncode:
|
|
94
|
+
raise ValueError(result.stderr.strip() or f"Git exited {result.returncode}")
|
|
95
|
+
if result.stdout.strip() != "true":
|
|
96
|
+
raise ValueError("Invoking directory is not a non-bare Git worktree")
|
|
97
|
+
return cwd
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def acknowledge() -> None:
|
|
101
|
+
if sys.stdin.isatty():
|
|
102
|
+
try:
|
|
103
|
+
input("Press Enter to close RexDeck: ")
|
|
104
|
+
except (EOFError, KeyboardInterrupt):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def restore_focus(rex: str, server: str, session: str, origin: str) -> None:
|
|
109
|
+
"""Use API identities, not CLI label/suffix/directional target resolution."""
|
|
110
|
+
environment = dict(os.environ)
|
|
111
|
+
environment.pop("REX_SESSION", None)
|
|
112
|
+
environment.pop("REX_BLOCK", None)
|
|
113
|
+
environment.pop("REX_CLIENT", None)
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
[
|
|
116
|
+
rex,
|
|
117
|
+
"-S",
|
|
118
|
+
server,
|
|
119
|
+
"--autostart=false",
|
|
120
|
+
"--timeout=10s",
|
|
121
|
+
"api",
|
|
122
|
+
"call",
|
|
123
|
+
"session.focus_block",
|
|
124
|
+
json.dumps({"session_id": session, "block_id": origin}),
|
|
125
|
+
],
|
|
126
|
+
env=environment,
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
timeout=15,
|
|
130
|
+
check=False,
|
|
131
|
+
)
|
|
132
|
+
if result.returncode:
|
|
133
|
+
raise RuntimeError(f"Rex exited {result.returncode}: {result.stderr.strip()}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run(
|
|
137
|
+
yazi: str,
|
|
138
|
+
rex: str,
|
|
139
|
+
server: str,
|
|
140
|
+
session: str,
|
|
141
|
+
origin: str,
|
|
142
|
+
cwd: str,
|
|
143
|
+
*,
|
|
144
|
+
plugin: str = "files",
|
|
145
|
+
) -> int:
|
|
146
|
+
"""Launch the selected native tool at validated cwd, then restore origin focus."""
|
|
147
|
+
title = "Git" if plugin == "git" else "Files"
|
|
148
|
+
tool = "lazygit" if plugin == "git" else "Yazi"
|
|
149
|
+
exit_code = 1
|
|
150
|
+
try:
|
|
151
|
+
if not all((server, session, origin)):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Explicit Rex server, session and origin identities are required"
|
|
154
|
+
)
|
|
155
|
+
if not Path(yazi).is_absolute() or not Path(rex).is_absolute():
|
|
156
|
+
raise ValueError("Absolute tool and Rex executable paths are required")
|
|
157
|
+
checked_cwd = git_directory(cwd) if plugin == "git" else directory(cwd)
|
|
158
|
+
# Inherit the actual pane TTY and the tool's normal configuration.
|
|
159
|
+
exit_code = subprocess.run([yazi], cwd=checked_cwd, check=False).returncode
|
|
160
|
+
if exit_code < 0:
|
|
161
|
+
exit_code = 128 - exit_code
|
|
162
|
+
if exit_code:
|
|
163
|
+
print(f"RexDeck {title}: {tool} exited {exit_code}", file=sys.stderr)
|
|
164
|
+
acknowledge()
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
exit_code = 130
|
|
167
|
+
print(f"RexDeck {title}: {tool} interrupted", file=sys.stderr)
|
|
168
|
+
acknowledge()
|
|
169
|
+
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
|
170
|
+
print(f"RexDeck {title}: cannot launch {tool}: {error}", file=sys.stderr)
|
|
171
|
+
acknowledge()
|
|
172
|
+
try:
|
|
173
|
+
if not all((server, session, origin)) or not Path(rex).is_absolute():
|
|
174
|
+
raise ValueError(
|
|
175
|
+
"Cannot restore focus without explicit Rex origin and executable"
|
|
176
|
+
)
|
|
177
|
+
restore_focus(rex, server, session, origin)
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
print(
|
|
180
|
+
f"RexDeck {title}: return focus to original pane {origin!r} interrupted; "
|
|
181
|
+
"focus outcome is unknown. No replacement pane was selected.",
|
|
182
|
+
file=sys.stderr,
|
|
183
|
+
)
|
|
184
|
+
acknowledge()
|
|
185
|
+
return exit_code or 1
|
|
186
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
|
|
187
|
+
print(
|
|
188
|
+
f"RexDeck {title}: cannot return focus to original pane {origin!r}: {error}. "
|
|
189
|
+
"No replacement pane was selected.",
|
|
190
|
+
file=sys.stderr,
|
|
191
|
+
)
|
|
192
|
+
acknowledge()
|
|
193
|
+
return exit_code or 1
|
|
194
|
+
return exit_code
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def main() -> int:
|
|
198
|
+
"""Dispatch standalone cwd validation and native pane launch commands."""
|
|
199
|
+
args = sys.argv[1:]
|
|
200
|
+
if len(args) == 2 and args[0] in ("cwd", "git-cwd"):
|
|
201
|
+
try:
|
|
202
|
+
cwd = git_directory(args[1]) if args[0] == "git-cwd" else directory(args[1])
|
|
203
|
+
print(json.dumps({"cwd": cwd}))
|
|
204
|
+
return 0
|
|
205
|
+
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
|
206
|
+
print(f"RexDeck: invalid invoking cwd: {error}", file=sys.stderr)
|
|
207
|
+
return 1
|
|
208
|
+
if len(args) == 7 and args[0] in ("run", "git-run"):
|
|
209
|
+
return run(*args[1:], plugin="git" if args[0] == "git-run" else "files")
|
|
210
|
+
print(
|
|
211
|
+
"Use [git-]cwd RAW_PWD or [git-]run TOOL REX SERVER SESSION ORIGIN CWD",
|
|
212
|
+
file=sys.stderr,
|
|
213
|
+
)
|
|
214
|
+
return 2
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
-- Native Rex configuration module; also embedded verbatim by RexDeck's installer.
|
|
2
|
+
return function(opts)
|
|
3
|
+
if type(opts) ~= 'table' then error('RexDeck Files requires installation options', 0) end
|
|
4
|
+
local plugin = opts.plugin or 'files'
|
|
5
|
+
if plugin ~= 'files' and plugin ~= 'git' then error('Unknown RexDeck integration', 0) end
|
|
6
|
+
local is_git = plugin == 'git'
|
|
7
|
+
local title = is_git and 'RexDeck Git' or 'RexDeck Files'
|
|
8
|
+
local tool = is_git and 'lazygit' or 'yazi'
|
|
9
|
+
local paths = {}
|
|
10
|
+
for _, key in ipairs({'python', 'rex', tool}) do
|
|
11
|
+
local value = opts[key]
|
|
12
|
+
if type(value) ~= 'string' or value:sub(1, 1) ~= '/' or value:find('%z') then
|
|
13
|
+
error(title .. ' requires an absolute ' .. key .. ' path', 0)
|
|
14
|
+
end
|
|
15
|
+
paths[key] = value
|
|
16
|
+
end
|
|
17
|
+
local launcher = opts.launcher_code
|
|
18
|
+
if type(launcher) ~= 'string' or launcher == '' or launcher:find('%z') then
|
|
19
|
+
error(title .. ' requires its applied Python launcher', 0)
|
|
20
|
+
end
|
|
21
|
+
local server = opts.server
|
|
22
|
+
if type(server) ~= 'string' or server == '' or server:find('%z') then
|
|
23
|
+
error(title .. ' requires an explicit Rex server', 0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
rex.action{
|
|
27
|
+
name = 'rexdeck_' .. plugin,
|
|
28
|
+
title = title,
|
|
29
|
+
category = 'RexDeck',
|
|
30
|
+
description = 'Open ' .. tool .. ' in the invoking terminal directory',
|
|
31
|
+
keywords = is_git and 'git lazygit' or 'files yazi explorer',
|
|
32
|
+
run = function(ctx, args)
|
|
33
|
+
-- Freeze identity before any call can change focus or mutate the context.
|
|
34
|
+
local session = type(ctx) == 'table' and ctx.session_id
|
|
35
|
+
local origin = type(ctx) == 'table' and ctx.block_id
|
|
36
|
+
if type(session) ~= 'string' or session == '' or type(origin) ~= 'string' or origin == '' then
|
|
37
|
+
error(title .. ' requires the invoking session and terminal pane', 0)
|
|
38
|
+
end
|
|
39
|
+
local attached, attach_error = rex.call('session.attach', {session_id = session})
|
|
40
|
+
if not attached then
|
|
41
|
+
error(title .. ' cannot attach to the invoking session: ' .. tostring(attach_error), 0)
|
|
42
|
+
end
|
|
43
|
+
local reported, pwd_error = rex.call('com.superlogical.terminal.pwd', {
|
|
44
|
+
session_id = session, block_id = origin, args = {},
|
|
45
|
+
})
|
|
46
|
+
if not reported then
|
|
47
|
+
error(title .. ' cannot read the invoking terminal cwd: ' .. tostring(pwd_error), 0)
|
|
48
|
+
end
|
|
49
|
+
if type(reported.pwd) ~= 'string' or reported.pwd == '' then
|
|
50
|
+
error(title .. ': the invoking terminal has not reported a cwd', 0)
|
|
51
|
+
end
|
|
52
|
+
local checked, exec_error = rex.exec({paths.python, '-I', '-c', launcher,
|
|
53
|
+
is_git and 'git-cwd' or 'cwd', reported.pwd}, {timeout = 15})
|
|
54
|
+
if not checked then
|
|
55
|
+
error(title .. ' cannot validate the invoking cwd: ' .. tostring(exec_error), 0)
|
|
56
|
+
end
|
|
57
|
+
if not checked.ok then
|
|
58
|
+
error(title .. ' refused the invoking cwd: ' .. tostring(checked.stderr), 0)
|
|
59
|
+
end
|
|
60
|
+
local decoded, json_error = rex.json.decode(checked.stdout)
|
|
61
|
+
if json_error or type(decoded) ~= 'table' or type(decoded.cwd) ~= 'string'
|
|
62
|
+
or decoded.cwd:sub(1, 1) ~= '/' or decoded.cwd:find('%z') then
|
|
63
|
+
error(title .. ' received an invalid cwd validation result: ' .. tostring(json_error), 0)
|
|
64
|
+
end
|
|
65
|
+
local cwd = decoded.cwd
|
|
66
|
+
local split, split_error = rex.session.new_split{
|
|
67
|
+
session_id = session,
|
|
68
|
+
anchor_id = origin,
|
|
69
|
+
direction = 'horizontal',
|
|
70
|
+
side = 'after',
|
|
71
|
+
ratio = 0.5,
|
|
72
|
+
focus = true,
|
|
73
|
+
layout = {block = {
|
|
74
|
+
flavor = 'com.superlogical.terminal.shell',
|
|
75
|
+
label = title,
|
|
76
|
+
options = {
|
|
77
|
+
cwd = cwd,
|
|
78
|
+
shell = 'none',
|
|
79
|
+
login = false,
|
|
80
|
+
exit = {on_completion = true},
|
|
81
|
+
command = {paths.python, '-I', '-c', launcher, is_git and 'git-run' or 'run',
|
|
82
|
+
paths[tool], paths.rex, server, session, origin, cwd},
|
|
83
|
+
},
|
|
84
|
+
}},
|
|
85
|
+
}
|
|
86
|
+
if not split then
|
|
87
|
+
error(title .. ' cannot open the tool terminal: ' .. tostring(split_error), 0)
|
|
88
|
+
end
|
|
89
|
+
return split
|
|
90
|
+
end,
|
|
91
|
+
}
|
|
92
|
+
end
|
rexdeck/settings.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Bounded, atomic storage of the explicitly approved native Files target."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
from rexdeck import lifecycle
|
|
14
|
+
|
|
15
|
+
_MAX_SETTINGS = 16 * 1024
|
|
16
|
+
_FIELDS = {
|
|
17
|
+
"version",
|
|
18
|
+
"kind",
|
|
19
|
+
"workspace",
|
|
20
|
+
"data_root",
|
|
21
|
+
"config",
|
|
22
|
+
"config_target",
|
|
23
|
+
"server",
|
|
24
|
+
"real_server",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def xdg_path(name: str, default: Path) -> Path:
|
|
29
|
+
value = os.environ.get(name)
|
|
30
|
+
if value is None:
|
|
31
|
+
return default
|
|
32
|
+
if not value or not Path(value).is_absolute() or "\x00" in value:
|
|
33
|
+
raise RuntimeError(f"{name} must be a nonempty absolute path")
|
|
34
|
+
return Path(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def settings_path() -> Path:
|
|
38
|
+
return xdg_path("XDG_CONFIG_HOME", Path.home() / ".config") / "rexdeck/config.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_workspace() -> Path:
|
|
42
|
+
return (
|
|
43
|
+
xdg_path("XDG_STATE_HOME", Path.home() / ".local/state") / "rexdeck/files"
|
|
44
|
+
).resolve()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_data_root() -> Path:
|
|
48
|
+
root = xdg_path("XDG_DATA_HOME", Path.home() / ".local/share") / "rexdeck"
|
|
49
|
+
if root.is_symlink():
|
|
50
|
+
raise RuntimeError(f"Files deployment directory is redirected: {root}")
|
|
51
|
+
return root.resolve()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _absolute(value: object, name: str) -> Path:
|
|
55
|
+
if not isinstance(value, str) or not value or "\x00" in value:
|
|
56
|
+
raise RuntimeError(f"Saved {name} must be a nonempty absolute path")
|
|
57
|
+
path = Path(value)
|
|
58
|
+
if not path.is_absolute() or str(path) != value or ".." in path.parts:
|
|
59
|
+
raise RuntimeError(f"Saved {name} must be a normalized absolute path")
|
|
60
|
+
return path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _object(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
64
|
+
result: dict[str, object] = {}
|
|
65
|
+
for key, value in pairs:
|
|
66
|
+
if key in result:
|
|
67
|
+
raise RuntimeError(f"Duplicate settings field: {key}")
|
|
68
|
+
result[key] = value
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class SavedTarget:
|
|
74
|
+
workspace: Path
|
|
75
|
+
data_root: Path
|
|
76
|
+
config: Path
|
|
77
|
+
config_target: Path
|
|
78
|
+
server: str
|
|
79
|
+
real_server: bool
|
|
80
|
+
|
|
81
|
+
def _validate(self, plugin: str | None = None) -> None:
|
|
82
|
+
"""Validate shared frozen identity and only an explicitly selected plugin."""
|
|
83
|
+
if self.config.resolve(strict=True) != self.config_target:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"Saved Rex config target changed; restore the selected symlink before "
|
|
86
|
+
"using rexdeck setup or changing Files"
|
|
87
|
+
)
|
|
88
|
+
if self.workspace.resolve() != self.workspace:
|
|
89
|
+
raise RuntimeError("Saved Files workspace target changed; restore its path")
|
|
90
|
+
lifecycle._validate_data_root(self.data_root, plugin)
|
|
91
|
+
if lifecycle._canonical_server(self.server) != self.server:
|
|
92
|
+
raise RuntimeError("Saved Rex socket target changed; restore its path")
|
|
93
|
+
|
|
94
|
+
def setup(self, plugin: str = "files") -> lifecycle.Setup:
|
|
95
|
+
"""Validate frozen target paths and select one integration's ownership."""
|
|
96
|
+
self._validate(plugin)
|
|
97
|
+
return lifecycle.Setup(
|
|
98
|
+
project=Path(__file__).resolve().parent,
|
|
99
|
+
workspace=self.workspace,
|
|
100
|
+
data_root=self.data_root,
|
|
101
|
+
config=self.config,
|
|
102
|
+
server=self.server,
|
|
103
|
+
real_server=self.real_server,
|
|
104
|
+
plugin=plugin,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def document(self) -> dict[str, object]:
|
|
108
|
+
return {
|
|
109
|
+
"version": 2,
|
|
110
|
+
"kind": "native-files",
|
|
111
|
+
"workspace": str(self.workspace),
|
|
112
|
+
"data_root": str(self.data_root),
|
|
113
|
+
"config": str(self.config),
|
|
114
|
+
"config_target": str(self.config_target),
|
|
115
|
+
"server": self.server,
|
|
116
|
+
"real_server": self.real_server,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load(path: Path) -> SavedTarget | None:
|
|
121
|
+
"""Load bounded settings and validate shared identity before plugin selection."""
|
|
122
|
+
try:
|
|
123
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
124
|
+
except FileNotFoundError:
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
info = os.fstat(descriptor)
|
|
128
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > _MAX_SETTINGS:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
"RexDeck settings must be a bounded regular non-symlink file"
|
|
131
|
+
)
|
|
132
|
+
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
|
133
|
+
data = stream.read(_MAX_SETTINGS + 1)
|
|
134
|
+
if len(data) > _MAX_SETTINGS:
|
|
135
|
+
raise RuntimeError("RexDeck settings exceed the size limit")
|
|
136
|
+
finally:
|
|
137
|
+
os.close(descriptor)
|
|
138
|
+
try:
|
|
139
|
+
value: object = json.loads(data, object_pairs_hook=_object)
|
|
140
|
+
except (ValueError, UnicodeError) as exc:
|
|
141
|
+
raise RuntimeError(f"Invalid RexDeck settings JSON: {exc}") from exc
|
|
142
|
+
if not isinstance(value, dict):
|
|
143
|
+
raise RuntimeError("RexDeck settings must be a JSON object")
|
|
144
|
+
fields = cast(dict[str, object], value)
|
|
145
|
+
if (
|
|
146
|
+
set(fields) != _FIELDS
|
|
147
|
+
or type(fields["version"]) is not int
|
|
148
|
+
or fields["version"] != 2
|
|
149
|
+
or fields["kind"] != "native-files"
|
|
150
|
+
or type(fields["real_server"]) is not bool
|
|
151
|
+
):
|
|
152
|
+
raise RuntimeError("Unsupported or malformed RexDeck settings schema")
|
|
153
|
+
server = fields["server"]
|
|
154
|
+
if not isinstance(server, str) or not server or server != server.strip():
|
|
155
|
+
raise RuntimeError("Saved server must be a nonempty Unix endpoint")
|
|
156
|
+
lifecycle._socket_path(server)
|
|
157
|
+
target = SavedTarget(
|
|
158
|
+
workspace=_absolute(fields["workspace"], "workspace"),
|
|
159
|
+
data_root=_absolute(fields["data_root"], "data_root"),
|
|
160
|
+
config=_absolute(fields["config"], "config"),
|
|
161
|
+
config_target=_absolute(fields["config_target"], "config_target"),
|
|
162
|
+
server=server,
|
|
163
|
+
real_server=fields["real_server"],
|
|
164
|
+
)
|
|
165
|
+
if target.config_target.resolve() != target.config_target:
|
|
166
|
+
raise RuntimeError("Saved config_target must be a resolved absolute path")
|
|
167
|
+
target._validate()
|
|
168
|
+
return target
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def save(path: Path, target: SavedTarget) -> None:
|
|
172
|
+
data = (json.dumps(target.document(), ensure_ascii=True, indent=2) + "\n").encode()
|
|
173
|
+
if len(data) > _MAX_SETTINGS:
|
|
174
|
+
raise RuntimeError("Approved RexDeck settings exceed the size limit")
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".config-", dir=path.parent)
|
|
177
|
+
try:
|
|
178
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
179
|
+
stream.write(data)
|
|
180
|
+
stream.flush()
|
|
181
|
+
os.fsync(stream.fileno())
|
|
182
|
+
# Publish a complete file without ever replacing a concurrently saved target.
|
|
183
|
+
os.link(temporary, path)
|
|
184
|
+
finally:
|
|
185
|
+
try:
|
|
186
|
+
os.unlink(temporary)
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
pass
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rexdeck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Native Rex integrations for independently selectable existing tools
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mathews-Tom/RexDeck
|
|
6
|
+
Project-URL: Repository, https://github.com/Mathews-Tom/RexDeck
|
|
7
|
+
Project-URL: Issues, https://github.com/Mathews-Tom/RexDeck/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Mathews-Tom/RexDeck/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Tom Mathews
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: lazygit,rex,terminal,yazi
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# RexDeck
|
|
23
|
+
|
|
24
|
+
A collection of independently selectable plugins and extensions that bring existing tools into your own Rex application.
|
|
25
|
+
|
|
26
|
+
Files and local Git bring unmodified Yazi and lazygit into native Rex terminals. GitHub workflows, rich viewers, agent/subagent views, and task-provider integrations remain planned. Select the tools you need without installing an unrelated default suite.
|
|
27
|
+
|
|
28
|
+
## Plugins and extensions
|
|
29
|
+
|
|
30
|
+
| Integration | Purpose | Status |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| [Files](plugins/files/README.md) | Browse, preview, and manage files using unmodified Yazi in a native Rex terminal | Available |
|
|
33
|
+
| [Git](plugins/git/README.md) | Inspect, stage, and commit in the invoking repository/worktree using unmodified lazygit | Available |
|
|
34
|
+
| GitHub | Pull-request/issue workflows with explicit repository/host/account | Planned |
|
|
35
|
+
| Rich viewers | Rendered Markdown, PDF, and image viewing | Planned |
|
|
36
|
+
| Agents and subagents | Attention, observed state, and navigation to agent work | Planned |
|
|
37
|
+
| Tasks | Independently selectable Linear and ClickUp workflows | Planned |
|
|
38
|
+
|
|
39
|
+
Planned integrations are not implemented or installable yet. RexDeck is not published on PyPI; install the current source package as described below.
|
|
40
|
+
|
|
41
|
+
## Getting started
|
|
42
|
+
|
|
43
|
+
The qualified platform is **macOS arm64, Rex API revision 10 baseline, and Python 3.12+**. Revision 10 is not an exact-version compatibility gate. Rex must already be installed and running; RexDeck does not bootstrap Rex, Homebrew, Python, or uv. Other platforms, remote hosts, and multi-device operation are not qualified.
|
|
44
|
+
|
|
45
|
+
Until the approved PyPI release is published, install the pinned public source baseline (Files and Git; before S1 dependency onboarding):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
uv tool install 'git+https://github.com/Mathews-Tom/RexDeck.git@d7f934e31d2becf53bc435ac931fa6e1b984bbd0'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For the S1 candidate, use its reviewed checkout and record the exact commit:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
uv tool install .
|
|
55
|
+
rexdeck setup
|
|
56
|
+
rexdeck enable files
|
|
57
|
+
rexdeck status
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The installed command works from any directory; the package includes the shared native action and Python launcher for Files and Git. No shell wrapper, `RD_*` variables, or developer checkout is needed at runtime. Ensure the tool executable directory reported by `uv` is on `PATH`.
|
|
61
|
+
|
|
62
|
+
`setup` remembers the selected Rex configuration and running server without changing Rex configuration. Interactive setup asks for approval before saving a personal-server target. Use `enable files` or `enable git` independently, `status [files|git]` to inspect ownership/registration, and `disable <id>` to remove only that integration. Unqualified `status` reports both integrations, retaining healthy results alongside an integration-specific fault if its sibling cannot be inspected. See the plugin READMEs for prerequisites, capabilities, updates, and removal.
|
|
63
|
+
|
|
64
|
+
The uv-managed package remains the runtime source. Setup freezes the app data root from `$XDG_DATA_HOME/rexdeck` (default `~/.local/share/rexdeck`); each integration deploys `plugins/<id>/rexdeck_<id>.lua` there with an independently owned tracked loader in Rex's `init.lua`. Approved preferences remain in `$XDG_CONFIG_HOME/rexdeck/config.json`. Files receipt/lock paths remain in the saved `$XDG_STATE_HOME/rexdeck/files` workspace; Git uses its `git/` subdirectory. Later `XDG_DATA_HOME` changes do not retarget the saved installation. Run `disable` for every applied integration before uninstalling RexDeck or deliberately resetting a saved target, including integrations reported as disabled after a failed enable.
|
|
65
|
+
|
|
66
|
+
### Selected dependencies
|
|
67
|
+
|
|
68
|
+
`setup` and `status` never install packages. `enable files` validates preinstalled Yazi; `enable git` validates preinstalled Git/lazygit. Qualified versions are **Yazi 26.9.1, lazygit 0.66.2 or 0.66.3**. Missing or unqualified selected tools produce actionable nonzero manual-install/version-check/rerun guidance without registration or installation prompts. RexDeck never installs, upgrades, downloads, cancels package transactions, or removes upstream tools, and never probes or executes Homebrew.
|
|
69
|
+
|
|
70
|
+
Executable discovery ignores empty and relative `PATH` entries. Absolute entries, including checkout directories and symlinks to them, remain eligible for version probes and lifecycle lookup. Put only trusted tools in absolute `PATH` directories. RexDeck captures followed-target device/inode/mode/size/nanosecond timestamps/owner before the version probe and rechecks that identity afterward and under its operation locks immediately before applying or enabling an integration. Replacement or modification requires rerunning enable. This is metadata-based detection, not pinned execution or protection against an executable changed after the final check.
|
|
71
|
+
|
|
72
|
+
Install dependencies yourself using qualified upstream releases: [Yazi 26.9.1](https://github.com/sxyazi/yazi/releases/tag/v26.9.1), [lazygit 0.66.2](https://github.com/jesseduffield/lazygit/releases/tag/v0.66.2) or [0.66.3](https://github.com/jesseduffield/lazygit/releases/tag/v0.66.3). Follow [Yazi installation](https://yazi-rs.github.io/docs/installation/) or [lazygit installation](https://github.com/jesseduffield/lazygit#installation); Git must also already be installed for Git integration.
|
|
73
|
+
|
|
74
|
+
If you choose Homebrew, first run `brew info yazi` or `brew info lazygit` yourself and inspect the selected version and dependency/source-build/upgrade effects. **Skip Homebrew if its selected version is not qualified**; use the qualified upstream release instead. Only if the version qualifies and you accept the package effects, run the selected `brew install yazi` or `brew install lazygit` yourself. RexDeck does not qualify a Homebrew version, prefix or transaction; all package effects remain owner-controlled.
|
|
75
|
+
|
|
76
|
+
Check `yazi --version` or `lazygit --version` (and `git --version` for Git), then rerun `rexdeck enable files` or `rexdeck enable git`. The pinned pre-S1 source baseline above requires manual dependency provisioning too, but does not contain S1's selected-tool qualification/guidance behavior.
|
|
77
|
+
|
|
78
|
+
Rex, Yazi, lazygit, Git, Homebrew, Python and uv remain externally managed prerequisites. No install-all, sudo, silent upgrade, tool configuration change, or upstream package removal is performed. `disable` and Python package uninstall leave upstream tools installed.
|
|
79
|
+
|
|
80
|
+
## Design principles
|
|
81
|
+
|
|
82
|
+
- **Reuse tools, do not rebuild them.** Preserve their normal interfaces, configuration, authentication, and capabilities.
|
|
83
|
+
- **Prefer native Rex integration.** Use native actions and panes where sufficient. LazyRex is optional only where a particular integration needs an existing capability; it is not a collection-wide dependency.
|
|
84
|
+
- **Keep features independently selectable.** Install only the dependencies needed by the chosen integration; no implicit default suite, hooks, or autostart.
|
|
85
|
+
- **Preserve user context and ownership.** Pass explicit launch context and structured arguments, keep unrelated work intact, and make removal reversible.
|
|
86
|
+
- **Disclose capabilities accurately.** External tools run as your OS account and can be write-capable; they are not sandboxed or automatically read-only.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
Install LuaJIT before running the suite: native Lua behavior tests require `lua` or `luajit` and otherwise skip. On macOS:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
brew install luajit
|
|
94
|
+
uv sync --locked
|
|
95
|
+
uv run ruff check .
|
|
96
|
+
uv run ruff format --check .
|
|
97
|
+
uv run mypy --strict
|
|
98
|
+
uv run pytest
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Plugin-specific syntax checks and real interaction verification belong in each plugin's README. Keep plugin documentation under `plugins/<name>/README.md` and packaged runtime assets under `src/rexdeck/plugins/<name>/`. Link available integrations from the table above. Do not create empty plugin folders for planned features.
|
|
102
|
+
|
|
103
|
+
## Release preparation and owner gate
|
|
104
|
+
|
|
105
|
+
Version **0.1.0 is prepared, not evidence of publication**. Do not use a PyPI install command until the release's public registry files and hashes have been verified. A PyPI name lookup returning 404 does not establish ownership, name eligibility, or publishing access.
|
|
106
|
+
|
|
107
|
+
The manual [`publish.yml`](.github/workflows/publish.yml) workflow supports [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) using [uv](https://docs.astral.sh/uv/guides/package/#publishing-your-package). No ordinary push, PR, or tag publishes anything; no release-time rebuild occurs.
|
|
108
|
+
|
|
109
|
+
Before dispatch, the owner must configure a PyPI project or pending publisher for `rexdeck`: GitHub owner `Mathews-Tom`, repository `RexDeck`, workflow `publish.yml`, environment `pypi`. Configure that GitHub environment with `Mathews-Tom` as its **sole required reviewer** and restrict deployment to `main`; GitHub permits any listed reviewer to approve, so additional reviewers do not enforce owner approval. Leave self-review prevention disabled for owner-dispatched runs so the sole owner can approve an assistant-dispatched run under the owner's GitHub identity. These account/environment changes require owner participation and are not performed by setup or this repository. Missing protection or publisher access holds publication.
|
|
110
|
+
|
|
111
|
+
The S1 publication boundary assumes this remains a personal-account repository with Mathews-Tom as its sole administrator. Owner-controlled bypass/configuration is trusted; transfer to an organization requires re-evaluating that gate.
|
|
112
|
+
|
|
113
|
+
1. Merge only the reviewed candidate. A successful **push on main** `checks.yml` run builds wheel/sdist once and retains the exact pair as `rexdeck-0.1.0`; download and inspect them, record both SHA-256 hashes, immutable artifact ID, full commit SHA, run URL, and runtime/security/review evidence. Artifacts expire; a rebuild requires fresh hashes and approval.
|
|
114
|
+
2. Obtain explicit owner approval naming that exact commit, artifact ID, both filenames/hashes, and reviewed evidence. Any changed input requires renewed approval. After this approval only, create/verify the annotated `v0.1.0` tag and GitHub Release at that commit, attaching the same files; never rebuild for a tag.
|
|
115
|
+
3. Dispatch `publish.yml` **from main at that approved commit**, entering the exact SHA, artifact ID, hashes and approval-evidence URL. The protected-environment reviewer must compare the dispatch inputs/run summary against the approval before approving the deployment. The workflow verifies repository/run provenance, tag target and hashes, then publishes only `approved/rexdeck-0.1.0.tar.gz` and `approved/rexdeck-0.1.0-py3-none-any.whl` with `uv publish --trusted-publishing always` (no token secret or broad `dist/*` upload).
|
|
116
|
+
4. Verify [PyPI JSON](https://pypi.org/pypi/rexdeck/0.1.0/json) exposes both approved hashes. Then run an isolated no-cache public-index install, allowing index propagation, and repeat actual private Rex use/quit/refresh/disable. Only then document `uv tool install 'rexdeck==0.1.0'` as available:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
uv --no-cache run --isolated --no-project --index https://pypi.org/simple \
|
|
120
|
+
--with rexdeck==0.1.0 rexdeck --help
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
CI and private endpoint/TUI captures do not prove owner-app palette, glyph, or desktop visual approval. Successful package installation does not prove native interaction; successful local builds do not prove registry publication. Keep those evidence claims separate.
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
RexDeck is [MIT licensed](LICENSE), included in wheel/sdist metadata. External executables are **not bundled**: [Yazi is MIT](https://github.com/sxyazi/yazi/blob/main/LICENSE) and [lazygit is MIT](https://github.com/jesseduffield/lazygit/blob/master/LICENSE), retaining upstream notices and terms. Git retains its own upstream license. Rex is an externally installed, separately licensed application prerequisite; RexDeck's MIT license does not grant rights to Rex or redistribute it.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
rexdeck/__init__.py,sha256=dta1LrrcdEEoTEdYFAwyXieXsx8AWioPIK2xBwkwSfE,55
|
|
2
|
+
rexdeck/cli.py,sha256=N47pPZF-hb4vzIwKK4Grai5IkbYohMabcx4LfZ66Rcg,11452
|
|
3
|
+
rexdeck/dependencies.py,sha256=SX9kCjm7to6KJj9B-WmfePpGCAa9QRg3YYKmsUw0gQ0,3625
|
|
4
|
+
rexdeck/lifecycle.py,sha256=PJWHaSjJHdgBKpA6NAREGniQ1_c8Pdl60kQoOBJx1tI,47725
|
|
5
|
+
rexdeck/settings.py,sha256=EPuMiteAxxMzyiZm2_f6b1s2DUcE6ORuwbKL1ykKgCU,6687
|
|
6
|
+
rexdeck/plugins/files/launch.py,sha256=GDlL2_3rr_rDOU3OqV6IUcvO6X0nXbmt91V7DMPlGqg,7757
|
|
7
|
+
rexdeck/plugins/files/main.lua,sha256=itAOYwyMlGcsPGlumj47ktdCMZFICj8mqKQYfTSjxME,3981
|
|
8
|
+
rexdeck-0.1.0.dist-info/METADATA,sha256=lKr_vnZXnY72uuUnMKSwyJg6OetyJ4v1B3XaBbZHwic,12599
|
|
9
|
+
rexdeck-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
rexdeck-0.1.0.dist-info/entry_points.txt,sha256=GuflU63f8PxKffblz6oBANdiD36nau_AqJ90s82fCMc,45
|
|
11
|
+
rexdeck-0.1.0.dist-info/licenses/LICENSE,sha256=g6qKmvZAxoXVjOFPeSmV4IaCkMZMuRektS4YVmfl8-k,1068
|
|
12
|
+
rexdeck-0.1.0.dist-info/RECORD,,
|