lotus-shell 26.10.4__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.
- lotus_shell-26.10.4/PKG-INFO +10 -0
- lotus_shell-26.10.4/README.md +0 -0
- lotus_shell-26.10.4/pyproject.toml +18 -0
- lotus_shell-26.10.4/pyproject.toml.orig +19 -0
- lotus_shell-26.10.4/src/lotus_shell/__init__.py +4 -0
- lotus_shell-26.10.4/src/lotus_shell/builtins.py +158 -0
- lotus_shell-26.10.4/src/lotus_shell/config.py +103 -0
- lotus_shell-26.10.4/src/lotus_shell/init.py +157 -0
- lotus_shell-26.10.4/src/lotus_shell/shell.py +126 -0
- lotus_shell-26.10.4/src/lotus_shell/utils.py +253 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: lotus-shell
|
|
3
|
+
Version: 26.10.4
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Stormzady
|
|
6
|
+
Author-email: Stormzady <Stormzady@noreply.codeberg.org>
|
|
7
|
+
Requires-Dist: prompt-toolkit>=3.0.53
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lotus-shell"
|
|
3
|
+
version = "26.10.4"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = ["prompt-toolkit>=3.0.53"]
|
|
8
|
+
|
|
9
|
+
[[project.authors]]
|
|
10
|
+
name = "Stormzady"
|
|
11
|
+
email = "Stormzady@noreply.codeberg.org"
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
lotus = "lotus:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lotus-shell"
|
|
3
|
+
version = "26.10.4"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Stormzady", email = "Stormzady@noreply.codeberg.org" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"prompt-toolkit>=3.0.53",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
lotus = "lotus:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import inspect as ins
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import shutil
|
|
6
|
+
import ast
|
|
7
|
+
|
|
8
|
+
from .config import Context
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cd(args: list[str], ctx: Context) -> int:
|
|
13
|
+
place = args[0] if args else "~"
|
|
14
|
+
try:
|
|
15
|
+
os.chdir(os.path.expanduser(place))
|
|
16
|
+
except OSError as e:
|
|
17
|
+
print(f"cd: {place}: {e.strerror}")
|
|
18
|
+
return 127
|
|
19
|
+
path(args, ctx)
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
def test() -> None:
|
|
23
|
+
while True:
|
|
24
|
+
continue
|
|
25
|
+
|
|
26
|
+
def path(args, ctx: Context):
|
|
27
|
+
cwd = Path.cwd()
|
|
28
|
+
home = Path.home()
|
|
29
|
+
print(f"you're somewhere around: {cwd}")
|
|
30
|
+
if cwd == home:
|
|
31
|
+
print("and that is.. home, as far as i can tell.")
|
|
32
|
+
elif cwd.is_relative_to(home):
|
|
33
|
+
print(f"home is {len(cwd.relative_to(home).parts)} dirs away")
|
|
34
|
+
else:
|
|
35
|
+
print("and that is.. nowhere near home, as far as i can tell.")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
def help(args, ctx: Context):
|
|
39
|
+
print("help? you want help?\nhere's some help: type things in the box and i will make it do things for you")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
def editconfig(args, ctx: Context):
|
|
43
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
44
|
+
config = os.path.expanduser("~/.config/lotus/config.py")
|
|
45
|
+
subprocess.run([editor, config], check=False)
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
def command(args: list[str], ctx: Context):
|
|
49
|
+
try:
|
|
50
|
+
subprocess.run(args, check=False)
|
|
51
|
+
return 0
|
|
52
|
+
except IndexError:
|
|
53
|
+
return 1
|
|
54
|
+
def exit(args, ctx: Context):
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
def listdir(args: list[str], ctx: Context):
|
|
58
|
+
path = Path(args[0]).expanduser() if args else Path.cwd()
|
|
59
|
+
|
|
60
|
+
raw = os.listdir(path)
|
|
61
|
+
|
|
62
|
+
print(f"Directory listing of: {path.resolve()}")
|
|
63
|
+
print("-------------")
|
|
64
|
+
|
|
65
|
+
for item in raw:
|
|
66
|
+
print(item)
|
|
67
|
+
|
|
68
|
+
print("-------------")
|
|
69
|
+
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
def inspect(args: list[str], ctx: Context):
|
|
73
|
+
if len(args) < 1:
|
|
74
|
+
print("Please provide a command!")
|
|
75
|
+
return 2
|
|
76
|
+
print(f"Specified command name: {args[0]}")
|
|
77
|
+
for arg in args[1:]:
|
|
78
|
+
print(f"Argument: {arg}")
|
|
79
|
+
exec = shutil.which(args[0])
|
|
80
|
+
print(f"Does command exist: {exec or 'not found'}")
|
|
81
|
+
if args[0] in ctx.config.aliases:
|
|
82
|
+
print("Is alias: True")
|
|
83
|
+
print(f"Alias points to: {ctx.config.aliases.get(args[0])}")
|
|
84
|
+
else:
|
|
85
|
+
print("Is alias = False")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
def config(args: list[str], ctx: Context) -> int:
|
|
89
|
+
configuration = ctx.config
|
|
90
|
+
|
|
91
|
+
# config
|
|
92
|
+
if not args:
|
|
93
|
+
for name in dir(configuration):
|
|
94
|
+
if name.startswith("_"):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
value = getattr(configuration, name)
|
|
98
|
+
|
|
99
|
+
if not callable(value):
|
|
100
|
+
print(f"{name} = {value}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
name = args[0]
|
|
104
|
+
key = f"_{name}"
|
|
105
|
+
|
|
106
|
+
# config <name>
|
|
107
|
+
if not hasattr(configuration, key):
|
|
108
|
+
print(f"config: '{name}' doesn't exist")
|
|
109
|
+
return 127
|
|
110
|
+
|
|
111
|
+
if len(args) == 1:
|
|
112
|
+
print(f"{name} = {getattr(configuration, name)}")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
# config <name> <value>
|
|
116
|
+
raw_value = " ".join(args[1:])
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
value = ast.literal_eval(raw_value)
|
|
120
|
+
except (ValueError, SyntaxError):
|
|
121
|
+
if raw_value.startswith("[") and raw_value.endswith("]"):
|
|
122
|
+
value = [
|
|
123
|
+
item.strip().strip("\"'")
|
|
124
|
+
for item in raw_value[1:-1].split(",")
|
|
125
|
+
]
|
|
126
|
+
else:
|
|
127
|
+
value = raw_value
|
|
128
|
+
|
|
129
|
+
setattr(configuration, key, value)
|
|
130
|
+
|
|
131
|
+
print(f"{name} = {getattr(configuration, name)}")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
def type(args: list[str], ctx: Context):
|
|
135
|
+
if not args:
|
|
136
|
+
print("type: expected a command")
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
name = args[0]
|
|
140
|
+
|
|
141
|
+
if name in ctx.config.aliases:
|
|
142
|
+
print(f"{name} is an alias for {ctx.config.aliases[name]}")
|
|
143
|
+
elif name in ctx.info.BUILTINS:
|
|
144
|
+
print(f"{name} is a lotus builtin")
|
|
145
|
+
elif shutil.which(name):
|
|
146
|
+
print(f"{name} is {shutil.which(name)}")
|
|
147
|
+
else:
|
|
148
|
+
print(f"{name}: not found")
|
|
149
|
+
return 127
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
def _get():
|
|
153
|
+
return {
|
|
154
|
+
name: func
|
|
155
|
+
for name, func in globals().items()
|
|
156
|
+
if ins.isfunction(func)
|
|
157
|
+
and not name.startswith("_")
|
|
158
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
VERSION = "26.10.4"
|
|
7
|
+
|
|
8
|
+
class Context:
|
|
9
|
+
def __init__(self, config, info):
|
|
10
|
+
self.config = config
|
|
11
|
+
self.info = info
|
|
12
|
+
|
|
13
|
+
class Config:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.config_path: Path = Path("~/.config/lotus").expanduser().resolve()
|
|
16
|
+
self.default_config = """
|
|
17
|
+
# WARNING
|
|
18
|
+
# The config does NOT check if a value is missing.
|
|
19
|
+
# This means that if you are to remove a value, it will error out before the shell can load.
|
|
20
|
+
# This is being patched.
|
|
21
|
+
# Please keep all existing entries.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Create the config object
|
|
25
|
+
lotus = Config()
|
|
26
|
+
|
|
27
|
+
# Lotus version. Do not modify.
|
|
28
|
+
lotus.version = "26.10.4"
|
|
29
|
+
|
|
30
|
+
# Wanna know what's happening?
|
|
31
|
+
lotus.verbose = False
|
|
32
|
+
|
|
33
|
+
# Would you like the dynamic prompt?
|
|
34
|
+
# True: Dynamic, False: Classic
|
|
35
|
+
# Dynamic: -- Home -- 12:00 >
|
|
36
|
+
# Classic user@hostname:directory >
|
|
37
|
+
lotus.dynamic = True
|
|
38
|
+
|
|
39
|
+
# What should we show for root and user as the indicator?
|
|
40
|
+
# (root first, then user)
|
|
41
|
+
lotus.indicator = ["#", "$"]
|
|
42
|
+
|
|
43
|
+
# Can I show you an advert on startup?
|
|
44
|
+
lotus.advert = True
|
|
45
|
+
|
|
46
|
+
# What do you want Lotus to pretend you typed? (aliases, prefix builtins with b-)
|
|
47
|
+
lotus.aliases = {
|
|
48
|
+
"la": "ls -la",
|
|
49
|
+
"ls": "b-listdir",
|
|
50
|
+
"dir": "b-listdir",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# This function runs on startup!
|
|
54
|
+
def on_start():
|
|
55
|
+
pass
|
|
56
|
+
"""
|
|
57
|
+
if not self.config_path.exists():
|
|
58
|
+
self.config_path.mkdir(parents=True)
|
|
59
|
+
self.defaults()
|
|
60
|
+
|
|
61
|
+
def defaults(self):
|
|
62
|
+
with open(self.config_path / "config.py", "w") as f:
|
|
63
|
+
f.write(self.default_config)
|
|
64
|
+
|
|
65
|
+
def load(self):
|
|
66
|
+
config_file = self.config_path / "config.py"
|
|
67
|
+
|
|
68
|
+
spec = importlib.util.spec_from_file_location(
|
|
69
|
+
"lotus.config",
|
|
70
|
+
config_file,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if spec is None or spec.loader is None:
|
|
74
|
+
raise ImportError(f"Could not load config: {config_file}")
|
|
75
|
+
|
|
76
|
+
config_module = importlib.util.module_from_spec(spec)
|
|
77
|
+
|
|
78
|
+
config_module.Config = Config # pyright: ignore[reportAttributeAccessIssue]
|
|
79
|
+
|
|
80
|
+
spec.loader.exec_module(config_module)
|
|
81
|
+
|
|
82
|
+
config = config_module.lotus.__dict__
|
|
83
|
+
|
|
84
|
+
on_start: Callable | None = getattr(config_module, "on_start", None)
|
|
85
|
+
if config.get("version") != VERSION:
|
|
86
|
+
print("your config is outdated. ")
|
|
87
|
+
res = input(f"do you want to update it to {VERSION}? [y/N] ")
|
|
88
|
+
if res.lower() == "n" or not res:
|
|
89
|
+
print(f"cannot load outdated config, loading default for version {VERSION}")
|
|
90
|
+
namespace = {
|
|
91
|
+
"Config": Config,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
exec(self.default_config, namespace)
|
|
95
|
+
|
|
96
|
+
config = namespace["lotus"].__dict__
|
|
97
|
+
else:
|
|
98
|
+
print(f"updating config to version {VERSION}")
|
|
99
|
+
self.defaults()
|
|
100
|
+
print("config updated.\nrestart the shell.")
|
|
101
|
+
sys.exit()
|
|
102
|
+
|
|
103
|
+
return config, on_start # pyright: ignore[reportAny]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .config import Config, Context
|
|
5
|
+
from .builtins import _get
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import importlib
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
class CTX(Context):
|
|
14
|
+
def __init__(self, config, info):
|
|
15
|
+
super().__init__(config, info)
|
|
16
|
+
|
|
17
|
+
class Configuration:
|
|
18
|
+
def __init__(self, config, on_start, config_path):
|
|
19
|
+
# lotus.indicator
|
|
20
|
+
if not config.get("indicator"):
|
|
21
|
+
config["indicator"] = ["#", "$"]
|
|
22
|
+
self._indicators: list[str] = config.get("indicator")
|
|
23
|
+
self._advert: bool = config.get("advert")
|
|
24
|
+
self._aliases: dict[str, str] = config.get("aliases")
|
|
25
|
+
self._verbose: bool = config.get("verbose")
|
|
26
|
+
self._dynamic: bool = config.get("dynamic")
|
|
27
|
+
self._on_start: Callable = on_start
|
|
28
|
+
self.config_path = config_path
|
|
29
|
+
@property
|
|
30
|
+
def indicators(self):
|
|
31
|
+
return self._indicators
|
|
32
|
+
@property
|
|
33
|
+
def advert(self):
|
|
34
|
+
return self._advert
|
|
35
|
+
@property
|
|
36
|
+
def aliases(self):
|
|
37
|
+
return self._aliases
|
|
38
|
+
@property
|
|
39
|
+
def verbose(self):
|
|
40
|
+
return self._verbose
|
|
41
|
+
@property
|
|
42
|
+
def dynamic_shell(self):
|
|
43
|
+
return self._dynamic
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Information:
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.user: str | None = os.environ.get("USER") or os.environ.get("USERNAME")
|
|
51
|
+
self.host: str = socket.gethostname()
|
|
52
|
+
self.home = Path.home()
|
|
53
|
+
self.builtins: dict[str,Callable] = _get()
|
|
54
|
+
@property
|
|
55
|
+
def username(self):
|
|
56
|
+
return self.user
|
|
57
|
+
@property
|
|
58
|
+
def hostname(self):
|
|
59
|
+
return self.host
|
|
60
|
+
@property
|
|
61
|
+
def BUILTINS(self):
|
|
62
|
+
return self.builtins
|
|
63
|
+
@property
|
|
64
|
+
def cwd(self) -> str:
|
|
65
|
+
path = Path.cwd()
|
|
66
|
+
|
|
67
|
+
if path == self.home:
|
|
68
|
+
return "~"
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
parts = list(path.relative_to(self.home).parts)
|
|
72
|
+
except ValueError:
|
|
73
|
+
parts = list(path.parts)
|
|
74
|
+
|
|
75
|
+
if not parts:
|
|
76
|
+
return "~"
|
|
77
|
+
|
|
78
|
+
width = shutil.get_terminal_size().columns
|
|
79
|
+
available = max(width // 3, 20)
|
|
80
|
+
|
|
81
|
+
if len("/".join(parts)) <= available:
|
|
82
|
+
return "/".join(parts)
|
|
83
|
+
|
|
84
|
+
# Shorten parent directories first.
|
|
85
|
+
for i in range(len(parts) - 1):
|
|
86
|
+
while len("/".join(parts)) > available and len(parts[i]) > 1:
|
|
87
|
+
parts[i] = parts[i][:-1]
|
|
88
|
+
|
|
89
|
+
# If we're still too long, progressively shorten deeper parents.
|
|
90
|
+
while len("/".join(parts)) > available:
|
|
91
|
+
changed = False
|
|
92
|
+
|
|
93
|
+
for i in range(len(parts) - 1):
|
|
94
|
+
if len(parts[i]) > 1:
|
|
95
|
+
parts[i] = parts[i][:-1]
|
|
96
|
+
changed = True
|
|
97
|
+
|
|
98
|
+
if len("/".join(parts)) <= available:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
if not changed:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
return "/".join(parts)
|
|
105
|
+
@property
|
|
106
|
+
def git(self) -> str | None:
|
|
107
|
+
result = subprocess.run(
|
|
108
|
+
["git", "status", "--porcelain", "--branch"],
|
|
109
|
+
capture_output=True,
|
|
110
|
+
text=True,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
lines = result.stdout.splitlines()
|
|
117
|
+
branch = lines[0].removeprefix("## ").split("...")[0]
|
|
118
|
+
|
|
119
|
+
unstaged = 0
|
|
120
|
+
staged = 0
|
|
121
|
+
|
|
122
|
+
for line in lines[1:]:
|
|
123
|
+
if line[0] != " ":
|
|
124
|
+
staged += 1
|
|
125
|
+
|
|
126
|
+
if line[1] != " ":
|
|
127
|
+
unstaged += 1
|
|
128
|
+
|
|
129
|
+
def indicator(symbol: str, count: int) -> str:
|
|
130
|
+
if count == 0:
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
if count <= 4:
|
|
134
|
+
return symbol * count
|
|
135
|
+
|
|
136
|
+
return f"{symbol}×{count}"
|
|
137
|
+
|
|
138
|
+
status = (
|
|
139
|
+
indicator("!", unstaged)
|
|
140
|
+
+ indicator("*", staged)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if "ahead" in lines[0]:
|
|
144
|
+
status += "^"
|
|
145
|
+
|
|
146
|
+
return f"{branch}{status}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_config():
|
|
150
|
+
unloaded = Config()
|
|
151
|
+
loaded, on_start = unloaded.load()
|
|
152
|
+
|
|
153
|
+
if on_start is not None and not callable(on_start):
|
|
154
|
+
raise TypeError("on_start isn't callable")
|
|
155
|
+
|
|
156
|
+
configuration = Configuration(loaded, on_start, unloaded.config_path)
|
|
157
|
+
return configuration
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from prompt_toolkit import PromptSession
|
|
4
|
+
from prompt_toolkit.history import FileHistory
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import subprocess
|
|
7
|
+
import shlex
|
|
8
|
+
import importlib
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from .init import Configuration, Information, CTX, load_config
|
|
12
|
+
from .utils import open_invite
|
|
13
|
+
from . import builtins
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Shell:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
status = self.status
|
|
19
|
+
print("shell [WAIT]")
|
|
20
|
+
self.config: Configuration = load_config()
|
|
21
|
+
status("config [OK]")
|
|
22
|
+
self.info: Information = Information()
|
|
23
|
+
status("info [OK]")
|
|
24
|
+
self.info.BUILTINS["reload"] = self.reload
|
|
25
|
+
status("reload inject [OK]")
|
|
26
|
+
|
|
27
|
+
def reload(self, args: list[str], ctx: CTX):
|
|
28
|
+
importlib.reload(builtins)
|
|
29
|
+
self.__init__()
|
|
30
|
+
|
|
31
|
+
def status(self, message: str):
|
|
32
|
+
if self.config.verbose:
|
|
33
|
+
print(message)
|
|
34
|
+
time.sleep(0.1)
|
|
35
|
+
|
|
36
|
+
def go(self):
|
|
37
|
+
status = self.status
|
|
38
|
+
# doing the ad
|
|
39
|
+
status("ad [WAIT]")
|
|
40
|
+
if self.config.advert:
|
|
41
|
+
print("wait, pause, you should probably join this cool amazing funny super duper best discord server!\nyou can turn this off by setting advert to false in your config!")
|
|
42
|
+
res = input("would you like to join the server? [y/N]: ")
|
|
43
|
+
if res.lower() == "n" or not res:
|
|
44
|
+
status("ad [FAIL]")
|
|
45
|
+
else:
|
|
46
|
+
open_invite("f9GsbvRadg")
|
|
47
|
+
status("ad [OK]")
|
|
48
|
+
else:
|
|
49
|
+
status("ad [FAIL]")
|
|
50
|
+
status("history [WAIT]" )
|
|
51
|
+
history = self.config.config_path / "history"
|
|
52
|
+
status("file init [OK]")
|
|
53
|
+
history.touch(exist_ok=True)
|
|
54
|
+
status("file creation [OK]")
|
|
55
|
+
session = PromptSession(
|
|
56
|
+
history=FileHistory(str(history)),
|
|
57
|
+
)
|
|
58
|
+
status("prompter'd'session creation [OK]")
|
|
59
|
+
status("history [OK]")
|
|
60
|
+
if self.config._on_start:
|
|
61
|
+
status("on_start [WAIT]")
|
|
62
|
+
self.config._on_start()
|
|
63
|
+
status("on_start [OK]")
|
|
64
|
+
print("do you wanna build a snowman..? (shell [OK])")
|
|
65
|
+
code = 0
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
try:
|
|
69
|
+
indicator = (
|
|
70
|
+
self.config.indicators[0]
|
|
71
|
+
if self.info.username == "root"
|
|
72
|
+
else self.config.indicators[1]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if self.config.dynamic_shell:
|
|
76
|
+
git = self.info.git
|
|
77
|
+
time = datetime.now().strftime("%H:%M")
|
|
78
|
+
if Path.cwd().resolve() == Path.home():
|
|
79
|
+
prompt = f"-- Home -- {time} > "
|
|
80
|
+
elif git:
|
|
81
|
+
prompt = f"-- {Path.cwd().name} -- ({git}) -- {time} > "
|
|
82
|
+
else:
|
|
83
|
+
prompt = f"-- {Path.cwd().name} -- {time} > "
|
|
84
|
+
else:
|
|
85
|
+
prompt = f"{self.info.username}@{self.info.hostname}:{self.info.cwd} > "
|
|
86
|
+
cmd = shlex.split(session.prompt(prompt))
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
print(f"what is this? ({e})")
|
|
89
|
+
status("syntax error [ERROR]")
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if not cmd:
|
|
93
|
+
print("what do i do with NOTHING?")
|
|
94
|
+
status("no text given [WARN]")
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if cmd[0] in self.info.BUILTINS:
|
|
99
|
+
code=self.info.BUILTINS[cmd[0]](cmd[1:], CTX(self.config, self.info))
|
|
100
|
+
continue
|
|
101
|
+
if cmd[0] in self.config.aliases:
|
|
102
|
+
cmd = shlex.split(self.config.aliases[cmd[0]]) + cmd[1:]
|
|
103
|
+
if cmd and cmd[0].startswith("b-"):
|
|
104
|
+
name = cmd[0][2:]
|
|
105
|
+
if name in self.info.BUILTINS:
|
|
106
|
+
self.info.BUILTINS[name](cmd[1:], CTX(self.config, self.info))
|
|
107
|
+
else:
|
|
108
|
+
print(f"{name}: no such builtin")
|
|
109
|
+
continue
|
|
110
|
+
except TypeError:
|
|
111
|
+
print("fatal: a builtin didn't take it's arguments\nit's refusing to move to ram now..\ncan we get a crane for this guy?")
|
|
112
|
+
status("builtin has no positional argument slot [FATAL]")
|
|
113
|
+
continue
|
|
114
|
+
result=subprocess.run(cmd, check=False)
|
|
115
|
+
code = result.returncode
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
print(f"{cmd[0]}: command not found\nshall i put the missing posters up?")
|
|
118
|
+
status("ErrorNo2/WinError2 [ERROR]")
|
|
119
|
+
continue
|
|
120
|
+
except EOFError:
|
|
121
|
+
status("shutdown [OK]")
|
|
122
|
+
print("\nokay, bye..")
|
|
123
|
+
break
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
status("caught KeyboardInterrupt [WARN]")
|
|
126
|
+
print("\nlotus: the keyboard did WHAT now!?")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import socket
|
|
8
|
+
import struct
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import webbrowser
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
__all__ = ["open_invite", "parse_code", "InviteError", "InviteResult", "METHODS"]
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("discord_invite")
|
|
21
|
+
|
|
22
|
+
METHODS = ("ipc", "client", "opener", "web")
|
|
23
|
+
|
|
24
|
+
CLIENT_BINS = ("vesktop", "equibop", "legcord", "webcord", "vencord",
|
|
25
|
+
"discord", "discord-ptb", "discord-canary", "Discord")
|
|
26
|
+
FLATPAK_IDS = ("dev.vencord.Vesktop", "com.discordapp.Discord",
|
|
27
|
+
"app.legcord.Legcord", "io.github.equicord.equibop")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InviteError(RuntimeError):
|
|
31
|
+
"""Raised when every requested method failed. `.attempted` lists them."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, attempted: Sequence[str] = ()):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.attempted = tuple(attempted)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class InviteResult:
|
|
40
|
+
method: str
|
|
41
|
+
code: str
|
|
42
|
+
url: str
|
|
43
|
+
attempted: tuple = field(default_factory=tuple)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_code(invite: str) -> str:
|
|
47
|
+
"""Accept a full link (discord.gg/…, discord.com/invite/…) or a bare code."""
|
|
48
|
+
code = invite.strip().split("?")[0].split("#")[0].rstrip("/").rsplit("/", 1)[-1]
|
|
49
|
+
if not code or not all(c.isalnum() or c in "-_" for c in code):
|
|
50
|
+
raise ValueError(f"Can't parse invite: {invite!r}")
|
|
51
|
+
return code
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run(cmd: Sequence[str], timeout: float = 10) -> tuple[int, str]:
|
|
55
|
+
try:
|
|
56
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
57
|
+
return r.returncode, r.stdout.strip()
|
|
58
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
59
|
+
log.debug("run failed %s: %s", cmd, e)
|
|
60
|
+
return 1, ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _spawn(cmd: Sequence[str]) -> bool:
|
|
64
|
+
"""Launch detached: never blocks, survives the calling process."""
|
|
65
|
+
kwargs = {}
|
|
66
|
+
if sys.platform == "win32":
|
|
67
|
+
kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED | NEW_GROUP
|
|
68
|
+
else:
|
|
69
|
+
kwargs["start_new_session"] = True
|
|
70
|
+
try:
|
|
71
|
+
subprocess.Popen(list(cmd), stdin=subprocess.DEVNULL,
|
|
72
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
73
|
+
**kwargs)
|
|
74
|
+
log.debug("spawned %s", cmd)
|
|
75
|
+
return True
|
|
76
|
+
except OSError as e:
|
|
77
|
+
log.debug("spawn failed %s: %s", cmd, e)
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def _ipc_sockets() -> Iterator[Path]:
|
|
81
|
+
if sys.platform == "win32":
|
|
82
|
+
return
|
|
83
|
+
base = (os.environ.get("XDG_RUNTIME_DIR") or os.environ.get("TMPDIR")
|
|
84
|
+
or f"/run/user/{os.getuid()}")
|
|
85
|
+
roots = [Path(base), Path(base, "app/com.discordapp.Discord"),
|
|
86
|
+
Path(base, "app/dev.vencord.Vesktop"), Path(base, "snap.discord")]
|
|
87
|
+
for root in roots:
|
|
88
|
+
for i in range(10):
|
|
89
|
+
p = root / f"discord-ipc-{i}"
|
|
90
|
+
if p.exists():
|
|
91
|
+
yield p
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _frame(op: int, payload: dict) -> bytes:
|
|
95
|
+
data = json.dumps(payload).encode()
|
|
96
|
+
return struct.pack("<II", op, len(data)) + data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
|
100
|
+
buf = b""
|
|
101
|
+
while len(buf) < n:
|
|
102
|
+
chunk = sock.recv(n - len(buf))
|
|
103
|
+
if not chunk:
|
|
104
|
+
break
|
|
105
|
+
buf += chunk
|
|
106
|
+
return buf
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _read_frame(sock: socket.socket) -> tuple[Optional[int], dict]:
|
|
110
|
+
head = _recv_exact(sock, 8)
|
|
111
|
+
if len(head) < 8:
|
|
112
|
+
return None, {}
|
|
113
|
+
op, length = struct.unpack("<II", head)
|
|
114
|
+
body = _recv_exact(sock, length)
|
|
115
|
+
try:
|
|
116
|
+
return op, json.loads(body)
|
|
117
|
+
except ValueError:
|
|
118
|
+
return op, {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _via_ipc(code: str, url: str) -> bool:
|
|
122
|
+
for path in _ipc_sockets():
|
|
123
|
+
log.debug("trying IPC socket %s", path)
|
|
124
|
+
try:
|
|
125
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
126
|
+
s.settimeout(3)
|
|
127
|
+
s.connect(str(path))
|
|
128
|
+
s.sendall(_frame(0, {"v": 1, "client_id": "1234567890123456789"}))
|
|
129
|
+
op, msg = _read_frame(s)
|
|
130
|
+
if op != 1 or msg.get("evt") == "ERROR":
|
|
131
|
+
log.debug("handshake rejected: %s %s", op, msg)
|
|
132
|
+
continue
|
|
133
|
+
s.sendall(_frame(1, {"cmd": "INVITE_BROWSER",
|
|
134
|
+
"args": {"code": code},
|
|
135
|
+
"nonce": "invite-1"}))
|
|
136
|
+
op, msg = _read_frame(s)
|
|
137
|
+
if op == 1 and msg.get("evt") != "ERROR":
|
|
138
|
+
return True
|
|
139
|
+
log.debug("invite rejected: %s %s", op, msg)
|
|
140
|
+
except (OSError, ValueError) as e:
|
|
141
|
+
log.debug("IPC error on %s: %s", path, e)
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _handler_binary() -> Optional[str]:
|
|
146
|
+
if not shutil.which("xdg-mime"):
|
|
147
|
+
return None
|
|
148
|
+
_, out = _run(["xdg-mime", "query", "default", "x-scheme-handler/discord"])
|
|
149
|
+
name = out[:-8] if out.endswith(".desktop") else out
|
|
150
|
+
return name or None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _via_client(code: str, url: str) -> bool:
|
|
154
|
+
if sys.platform == "win32":
|
|
155
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
156
|
+
if local:
|
|
157
|
+
for v in ("Discord", "DiscordPTB", "DiscordCanary"):
|
|
158
|
+
upd = Path(local, v, "Update.exe")
|
|
159
|
+
if upd.exists():
|
|
160
|
+
exe = {"Discord": "Discord.exe", "DiscordPTB": "DiscordPTB.exe",
|
|
161
|
+
"DiscordCanary": "DiscordCanary.exe"}[v]
|
|
162
|
+
return _spawn([str(upd), "--processStart", exe, "--process-start-args", url])
|
|
163
|
+
return False
|
|
164
|
+
if sys.platform == "darwin":
|
|
165
|
+
for app in ("Vesktop", "Discord", "Discord PTB", "Discord Canary", "Legcord"):
|
|
166
|
+
if _run(["open", "-a", app, url])[0] == 0:
|
|
167
|
+
return True
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
ordered: list[str] = []
|
|
171
|
+
h = _handler_binary()
|
|
172
|
+
if h:
|
|
173
|
+
ordered.append(h)
|
|
174
|
+
ordered += [b for b in CLIENT_BINS if b != h]
|
|
175
|
+
for b in ordered:
|
|
176
|
+
if shutil.which(b) and _spawn([b, url]):
|
|
177
|
+
return True
|
|
178
|
+
if shutil.which("flatpak"):
|
|
179
|
+
_, out = _run(["flatpak", "list", "--app", "--columns=application"])
|
|
180
|
+
installed = out.split()
|
|
181
|
+
for fid in FLATPAK_IDS:
|
|
182
|
+
if fid in installed and _spawn(["flatpak", "run", fid, url]):
|
|
183
|
+
return True
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _via_opener(code: str, url: str) -> bool:
|
|
188
|
+
if sys.platform == "win32":
|
|
189
|
+
try:
|
|
190
|
+
os.startfile(url) # type: ignore[attr-defined]
|
|
191
|
+
return True
|
|
192
|
+
except OSError as e:
|
|
193
|
+
log.debug("startfile failed: %s", e)
|
|
194
|
+
return False
|
|
195
|
+
if sys.platform == "darwin":
|
|
196
|
+
return _run(["open", url])[0] == 0
|
|
197
|
+
# gio uses GLib's handler lookup and doesn't fall back to a browser the
|
|
198
|
+
# way xdg-open does.
|
|
199
|
+
for cmd in (["gio", "open", url], ["xdg-open", url]):
|
|
200
|
+
if shutil.which(cmd[0]) and _run(cmd)[0] == 0:
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _via_web(code: str, url: str) -> bool:
|
|
206
|
+
web = f"https://discord.gg/{code}"
|
|
207
|
+
if sys.platform.startswith("linux") and shutil.which("xdg-open"):
|
|
208
|
+
return _run(["xdg-open", web])[0] == 0
|
|
209
|
+
return webbrowser.open(web)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_IMPL: dict[str, Callable[[str, str], bool]] = {
|
|
213
|
+
"ipc": _via_ipc,
|
|
214
|
+
"client": _via_client,
|
|
215
|
+
"opener": _via_opener,
|
|
216
|
+
"web": _via_web,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def open_invite(invite: str, *,
|
|
221
|
+
methods: Optional[Iterable[str]] = None) -> InviteResult:
|
|
222
|
+
"""Open `invite` in the desktop client.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
invite: full invite URL or bare code.
|
|
226
|
+
methods: ordered subset of METHODS to try. Default is all of them,
|
|
227
|
+
most seamless first. Pass e.g. ("ipc", "client") to forbid the
|
|
228
|
+
browser fallback.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ValueError: the invite couldn't be parsed.
|
|
232
|
+
InviteError: every requested method failed.
|
|
233
|
+
"""
|
|
234
|
+
code = parse_code(invite)
|
|
235
|
+
url = f"discord://-/invite/{code}"
|
|
236
|
+
order = tuple(methods) if methods is not None else METHODS
|
|
237
|
+
|
|
238
|
+
unknown = [m for m in order if m not in _IMPL]
|
|
239
|
+
if unknown:
|
|
240
|
+
raise ValueError(f"Unknown method(s) {unknown}; choose from {METHODS}")
|
|
241
|
+
|
|
242
|
+
attempted: list[str] = []
|
|
243
|
+
for name in order:
|
|
244
|
+
attempted.append(name)
|
|
245
|
+
log.debug("trying method %s", name)
|
|
246
|
+
try:
|
|
247
|
+
ok = _IMPL[name](code, url)
|
|
248
|
+
except Exception: # a broken method must never stop the fallthrough
|
|
249
|
+
log.debug("method %s raised", name, exc_info=True)
|
|
250
|
+
ok = False
|
|
251
|
+
if ok:
|
|
252
|
+
return InviteResult(name, code, url, tuple(attempted))
|
|
253
|
+
raise InviteError(f"No method worked (tried: {', '.join(attempted)})", attempted)
|