- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with presets and live preview) and dbanner (SSH login banner editor: canvas tools, fields with values, widgets, das-banner service for cached ones, Das OS logo and tryzub pictures). Python tests in make test. - das-server 0.3.0 depends on das-shell; the server installer mentions dprompt and dbanner; the desktop image includes it. - run-qemu.sh forwards the guest's SSH to localhost:2222. - Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"""Bash prompt model of dprompt: segments in a frame, turned into PS1 code or a preview.
|
||||
|
||||
A design is JSON:
|
||||
{"name": "das", "frame": "das", "frame_fg": "red", "open": "[", "close": "]",
|
||||
"sep": "─", "segments": [{"type": "status"}, {"type": "userhost"}, ...]}
|
||||
|
||||
Static segments become PS1 escapes (\\u, \\w, \\D{...}). Conditional ones (exit
|
||||
status, git branch, venv, jobs, ssh, command output) are computed by a function
|
||||
that the PROMPT_COMMAND hook of /usr/lib/das/prompt-lib.sh calls before each
|
||||
prompt; PS1 only reads their variables, so branch names are never evaluated as code.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
from . import DATA_DIR, colors
|
||||
from .i18n import t, title
|
||||
|
||||
FRAMES = ("das", "plain")
|
||||
|
||||
# type: (Ukrainian name, English name, default options)
|
||||
SEGMENTS = {
|
||||
"status": ("Код помилки", "Exit status", {"fg": "white", "mode": "mark", "mark": "✗"}),
|
||||
"userhost": ("Користувач@хост", "User@host",
|
||||
{"fg": "bright_white", "at_fg": "yellow", "at_bold": True,
|
||||
"host_fg": "cyan", "host_bold": True, "root_fg": "red", "root_bold": True}),
|
||||
"user": ("Користувач", "User", {"fg": "bright_white", "root_fg": "red", "root_bold": True}),
|
||||
"host": ("Хост", "Host", {"fg": "cyan", "bold": True, "full": False}),
|
||||
"cwd": ("Каталог", "Directory", {"fg": "bright_green", "mode": "full", "depth": 3}),
|
||||
"date": ("Дата", "Date", {"fg": "white", "format": "%Y-%m-%d"}),
|
||||
"time": ("Час", "Time", {"fg": "white", "seconds": False}),
|
||||
"git": ("Гілка git", "Git branch", {"fg": "bright_magenta"}),
|
||||
"venv": ("Python venv", "Python venv", {"fg": "bright_blue"}),
|
||||
"jobs": ("Фонові задачі", "Background jobs", {"fg": "yellow", "prefix": "jobs:"}),
|
||||
"ssh": ("Ознака SSH", "SSH mark", {"fg": "bright_red", "bold": True, "text": "ssh"}),
|
||||
"text": ("Текст", "Text", {"fg": None, "text": ""}),
|
||||
"cmd": ("Вивід команди", "Command output", {"fg": "white", "cmd": ""}),
|
||||
"newline": ("Новий рядок", "New line", {}),
|
||||
"symbol": ("Символ $/#", "Symbol $/#", {"fg": "yellow", "char": ""}),
|
||||
}
|
||||
|
||||
# Glyphs missing from the Linux console font
|
||||
CONSOLE_GLYPHS = {"╼": ">", "✗": "x", "❯": ">", "➜": ">", "λ": ">", "⚙": "*", "●": "*"}
|
||||
|
||||
VOID_PS1 = "[\\u@\\h \\W]\\$ "
|
||||
|
||||
|
||||
def segment_name(seg_type):
|
||||
uk, en, _ = SEGMENTS[seg_type]
|
||||
return t(uk, en)
|
||||
|
||||
|
||||
def new_segment(seg_type):
|
||||
seg = {"type": seg_type}
|
||||
seg.update(copy.deepcopy(SEGMENTS[seg_type][2]))
|
||||
return seg
|
||||
|
||||
|
||||
def normalize(design):
|
||||
"""Fills in the defaults, so older or hand-written files keep working."""
|
||||
d = copy.deepcopy(design)
|
||||
d.setdefault("name", "custom")
|
||||
d.setdefault("frame", "das")
|
||||
if d["frame"] not in FRAMES:
|
||||
d["frame"] = "das"
|
||||
d.setdefault("frame_fg", "red")
|
||||
d.setdefault("open", "[" if d["frame"] == "das" else "")
|
||||
d.setdefault("close", "]" if d["frame"] == "das" else "")
|
||||
d.setdefault("sep", "─" if d["frame"] == "das" else " ")
|
||||
segs = []
|
||||
for seg in d.get("segments", []):
|
||||
if seg.get("type") not in SEGMENTS:
|
||||
continue
|
||||
full = new_segment(seg["type"])
|
||||
full.update(seg)
|
||||
segs.append(full)
|
||||
d["segments"] = segs
|
||||
return d
|
||||
|
||||
|
||||
# --- presets and files ------------------------------------------------------
|
||||
|
||||
def preset_dir():
|
||||
return os.path.join(DATA_DIR, "prompt", "presets")
|
||||
|
||||
|
||||
def list_presets():
|
||||
"""(name, title) of the shipped presets, "das" first."""
|
||||
out = []
|
||||
try:
|
||||
names = sorted(f[:-5] for f in os.listdir(preset_dir()) if f.endswith(".json"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in sorted(names, key=lambda n: (n != "das", n)):
|
||||
try:
|
||||
out.append((name, title(load_preset(name).get("title"), name)))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def load_preset(name):
|
||||
return load_file(os.path.join(preset_dir(), name + ".json"))
|
||||
|
||||
|
||||
def load_file(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return normalize(json.load(f))
|
||||
|
||||
|
||||
def load(spec):
|
||||
"""A preset name or a path to a design file."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return load_file(spec)
|
||||
return load_preset(spec)
|
||||
|
||||
|
||||
def dumps(design):
|
||||
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
# --- pieces -------------------------------------------------------------------
|
||||
# Lit: literal text; Esc: a PS1 escape such as \u; Var: a variable the hook sets;
|
||||
# Dyn: pieces shown only when a condition holds. Style is (fg, bold) or None.
|
||||
|
||||
class Lit:
|
||||
def __init__(self, text, style=None):
|
||||
self.text, self.style = text, style
|
||||
|
||||
|
||||
class Esc:
|
||||
def __init__(self, code, style=None):
|
||||
self.code, self.style = code, style
|
||||
|
||||
|
||||
class Var:
|
||||
def __init__(self, name, style=None):
|
||||
self.name, self.style = name, style
|
||||
|
||||
|
||||
class Dyn:
|
||||
def __init__(self, cond, pieces, extra=None):
|
||||
self.cond, self.pieces, self.extra = cond, pieces, extra or {}
|
||||
|
||||
|
||||
def _st(seg, prefix=""):
|
||||
return (seg.get(prefix + "fg"), bool(seg.get(prefix + "bold")))
|
||||
|
||||
|
||||
def _content(seg, index, root):
|
||||
"""(pieces, condition or None, bracketed?) for one segment."""
|
||||
kind = seg["type"]
|
||||
style = _st(seg)
|
||||
if kind == "status":
|
||||
inner = [Var("rc", style)] if seg.get("mode") == "code" else [Lit(seg.get("mark") or "✗", style)]
|
||||
return inner, "error", True
|
||||
if kind == "userhost":
|
||||
user = _st(seg, "root_") if root else style
|
||||
return [Esc("\\u", user), Lit("@", _st(seg, "at_")), Esc("\\h", _st(seg, "host_"))], None, True
|
||||
if kind == "user":
|
||||
return [Esc("\\u", _st(seg, "root_") if root else style)], None, True
|
||||
if kind == "host":
|
||||
return [Esc("\\H" if seg.get("full") else "\\h", style)], None, True
|
||||
if kind == "cwd":
|
||||
return [Esc("\\W" if seg.get("mode") == "base" else "\\w", style)], None, True
|
||||
if kind == "date":
|
||||
return [Esc("\\D{%s}" % (seg.get("format") or "%Y-%m-%d"), style)], None, True
|
||||
if kind == "time":
|
||||
return [Esc("\\t" if seg.get("seconds") else "\\A", style)], None, True
|
||||
if kind == "git":
|
||||
return [Var("git", style)], "git", True
|
||||
if kind == "venv":
|
||||
return [Var("venv", style)], "venv", True
|
||||
if kind == "jobs":
|
||||
return [Lit(seg.get("prefix") or "", style), Var("jobs", style)], "jobs", True
|
||||
if kind == "ssh":
|
||||
return [Lit(seg.get("text") or "ssh", style)], "ssh", True
|
||||
if kind == "text":
|
||||
return [Lit(seg.get("text") or "", style)], None, bool(seg.get("bracket", False))
|
||||
if kind == "cmd":
|
||||
return [Var(f"cmd{index}", style)], f"cmd{index}", True
|
||||
if kind == "symbol":
|
||||
char = seg.get("char") or ""
|
||||
return [Lit(char, style) if char else Esc("\\$", style)], None, False
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def pieces(design, root=False):
|
||||
"""The prompt as a flat list of pieces."""
|
||||
d = normalize(design)
|
||||
frame = (d["frame_fg"], False)
|
||||
lines = [[]]
|
||||
for i, seg in enumerate(d["segments"]):
|
||||
if seg["type"] == "newline":
|
||||
lines.append([])
|
||||
else:
|
||||
lines[-1].append((i, seg))
|
||||
out = []
|
||||
for n, items in enumerate(lines):
|
||||
if n:
|
||||
out.append(Lit("\n"))
|
||||
if d["frame"] == "das" and len(lines) > 1:
|
||||
prefix = "┌─" if n == 0 else ("└─╼ " if n == len(lines) - 1 else "├─")
|
||||
out.append(Lit(prefix, frame))
|
||||
first_static = next((k for k, (i, s) in enumerate(items)
|
||||
if _content(s, i, root)[1] is None), len(items))
|
||||
for k, (i, seg) in enumerate(items):
|
||||
content, cond, bracket = _content(seg, i, root)
|
||||
is_symbol = seg["type"] == "symbol"
|
||||
group = []
|
||||
if bracket and d["open"]:
|
||||
group.append(Lit(d["open"], frame))
|
||||
group += content
|
||||
if bracket and d["close"]:
|
||||
group.append(Lit(d["close"], frame))
|
||||
sep = Lit(" " if is_symbol else d["sep"], frame) if d["sep"] else None
|
||||
if cond is not None:
|
||||
# A hidden segment must take its separator with it
|
||||
if k < first_static:
|
||||
group = group + ([sep] if sep and k + 1 < len(items) else [])
|
||||
elif sep:
|
||||
group = [sep] + group
|
||||
out.append(Dyn(cond, group, {"cmd": seg.get("cmd", "")}))
|
||||
else:
|
||||
if k > first_static and sep:
|
||||
out.append(sep)
|
||||
out += group
|
||||
out.append(Lit(" "))
|
||||
return _merge(out)
|
||||
|
||||
|
||||
def _merge(items):
|
||||
"""Joins neighbouring literals of the same style."""
|
||||
out = []
|
||||
for p in items:
|
||||
if isinstance(p, Lit) and out and isinstance(out[-1], Lit) and out[-1].style == p.style:
|
||||
out[-1] = Lit(out[-1].text + p.text, p.style)
|
||||
else:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
# --- bash code ----------------------------------------------------------------
|
||||
|
||||
def _ps1_text(text):
|
||||
return text.replace("\\", "\\\\").replace("$", "\\044").replace("`", "\\140")
|
||||
|
||||
|
||||
def _sq(text):
|
||||
return "'" + text.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _ansi_q(text):
|
||||
return text.replace("\\", "\\\\").replace("'", "\\'")
|
||||
|
||||
|
||||
class _Bash:
|
||||
def __init__(self, color, console):
|
||||
self.color, self.console = color, console
|
||||
|
||||
def glyphs(self, text):
|
||||
if self.console:
|
||||
for a, b in CONSOLE_GLYPHS.items():
|
||||
text = text.replace(a, b)
|
||||
return text
|
||||
|
||||
def ps1(self, items):
|
||||
out, current = [], None
|
||||
for p in items:
|
||||
style = getattr(p, "style", None)
|
||||
if self.color and not isinstance(p, Dyn) and style is not None and style != current:
|
||||
out.append("\\[\\e[%sm\\]" % colors.sgr_params(style[0], None, style[1]))
|
||||
current = style
|
||||
if isinstance(p, Lit):
|
||||
out.append(_ps1_text(self.glyphs(p.text)))
|
||||
elif isinstance(p, Esc):
|
||||
out.append(p.code)
|
||||
elif isinstance(p, Dyn):
|
||||
out.append("${__das_p_%s}" % p.cond)
|
||||
current = None # the dynamic part leaves its own color behind
|
||||
if self.color:
|
||||
out.append("\\[\\e[0m\\]")
|
||||
return "".join(out)
|
||||
|
||||
def value(self, items):
|
||||
"""A bash word with the dynamic part: $'...' literals and "$var"s."""
|
||||
out, current = [], None
|
||||
for p in items:
|
||||
style = getattr(p, "style", None)
|
||||
if self.color and style is not None and style != current:
|
||||
out.append("$'\\001\\e[%sm\\002'" % colors.sgr_params(style[0], None, style[1]))
|
||||
current = style
|
||||
if isinstance(p, Lit):
|
||||
out.append("$'%s'" % _ansi_q(self.glyphs(p.text)))
|
||||
elif isinstance(p, Var):
|
||||
out.append('"$__das_%s"' % p.name)
|
||||
return "".join(out) or "''"
|
||||
|
||||
def dynamic(self, items):
|
||||
lines = []
|
||||
for p in items:
|
||||
if not isinstance(p, Dyn):
|
||||
continue
|
||||
var, val = "__das_p_" + p.cond, self.value(p.pieces)
|
||||
test = {
|
||||
"error": "[[ $__das_rc != 0 ]]",
|
||||
"git": "__das_git_info",
|
||||
"venv": "[[ -n ${VIRTUAL_ENV:-} ]] && __das_venv=${VIRTUAL_ENV##*/}",
|
||||
"jobs": "__das_jobs_count",
|
||||
"ssh": "[[ -n ${SSH_CONNECTION:-} ]]",
|
||||
}.get(p.cond)
|
||||
if test is None: # command output
|
||||
lines.append(f" __das_{p.cond}=$({{ {p.extra['cmd']}; }} 2>/dev/null)")
|
||||
test = f"[[ -n $__das_{p.cond} ]]"
|
||||
lines.append(f" {var}=")
|
||||
lines.append(f" {test} && {var}={val}")
|
||||
return lines
|
||||
|
||||
|
||||
def script(design):
|
||||
"""The prompt.sh that /etc/bash/bashrc.d/das-prompt.sh sources."""
|
||||
d = normalize(design)
|
||||
out = [
|
||||
f"# Das OS bash prompt \"{d['name']}\", made by dprompt: run dprompt to change it.",
|
||||
"# shellcheck shell=bash disable=SC2016",
|
||||
]
|
||||
cwd = next((s for s in d["segments"] if s["type"] == "cwd"), None)
|
||||
if cwd and cwd.get("mode") == "short":
|
||||
out.append(f"PROMPT_DIRTRIM={int(cwd.get('depth') or 3)}")
|
||||
else:
|
||||
out.append("unset PROMPT_DIRTRIM")
|
||||
if any(s["type"] == "venv" for s in d["segments"]):
|
||||
out.append("VIRTUAL_ENV_DISABLE_PROMPT=1")
|
||||
variants = [("[[ $TERM == linux ]]", True, True),
|
||||
("__das_has_color", True, False),
|
||||
(None, False, False)]
|
||||
for n, (test, color, console) in enumerate(variants):
|
||||
b = _Bash(color, console)
|
||||
user, root = pieces(d, False), pieces(d, True)
|
||||
head = "if" if n == 0 else ("elif" if test else "else")
|
||||
out.append(f"{head} {test}; then" if test else "else")
|
||||
body = b.dynamic(user)
|
||||
out.append(" __das_prompt_dynamic() {")
|
||||
out += [" " + line for line in body] or [" :"]
|
||||
out.append(" }")
|
||||
out.append(" if ((EUID == 0)); then")
|
||||
out.append(" PS1=" + _sq(b.ps1(root)))
|
||||
out.append(" else")
|
||||
out.append(" PS1=" + _sq(b.ps1(user)))
|
||||
out.append(" fi")
|
||||
out.append("fi")
|
||||
out.append("case $TERM in xterm* | rxvt* | alacritty | foot*) PS1='\\[\\e]0;\\u@\\h: \\w\\a\\]'$PS1 ;; esac")
|
||||
out.append("__das_prompt_install")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# --- preview ----------------------------------------------------------------------
|
||||
|
||||
def sample_state(**over):
|
||||
now = datetime.datetime.now()
|
||||
state = {"user": "das", "host": "dasos", "fqdn": "dasos.lan", "home": "/home/das",
|
||||
"cwd": "/home/das/Projects/web", "rc": 0, "git": "", "venv": "", "jobs": 0,
|
||||
"ssh": False, "root": False, "cmd": "…", "now": now}
|
||||
state.update(over)
|
||||
if state["root"]:
|
||||
state["user"] = "root"
|
||||
return state
|
||||
|
||||
|
||||
def _esc_value(code, s):
|
||||
if code == "\\u":
|
||||
return s["user"]
|
||||
if code == "\\h":
|
||||
return s["host"]
|
||||
if code == "\\H":
|
||||
return s["fqdn"]
|
||||
if code in ("\\w", "\\W"):
|
||||
home = "/root" if s["root"] else s["home"]
|
||||
cwd = s["cwd"] if not s["root"] else "/etc/sv"
|
||||
short = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
|
||||
return short if code == "\\w" else (os.path.basename(cwd) or "/")
|
||||
if code.startswith("\\D{"):
|
||||
return s["now"].strftime(code[3:-1])
|
||||
if code == "\\A":
|
||||
return s["now"].strftime("%H:%M")
|
||||
if code == "\\t":
|
||||
return s["now"].strftime("%H:%M:%S")
|
||||
if code == "\\$":
|
||||
return "#" if s["root"] else "$"
|
||||
return code
|
||||
|
||||
|
||||
def _cond_holds(cond, s):
|
||||
return {"error": s["rc"] != 0, "git": bool(s["git"]), "venv": bool(s["venv"]),
|
||||
"jobs": s["jobs"] > 0, "ssh": s["ssh"]}.get(cond, bool(s["cmd"]))
|
||||
|
||||
|
||||
def _var_value(name, s):
|
||||
return {"rc": str(s["rc"]), "git": s["git"], "venv": s["venv"],
|
||||
"jobs": str(s["jobs"])}.get(name, s["cmd"])
|
||||
|
||||
|
||||
def preview(design, state):
|
||||
"""[(text, rich style or "")] of the prompt as the user would see it."""
|
||||
out = []
|
||||
|
||||
def walk(items):
|
||||
for p in items:
|
||||
style = colors.rich_style(p.style[0], None, p.style[1]) if getattr(p, "style", None) else ""
|
||||
if isinstance(p, Lit):
|
||||
out.append((p.text, style))
|
||||
elif isinstance(p, Esc):
|
||||
out.append((_esc_value(p.code, state), style))
|
||||
elif isinstance(p, Var):
|
||||
out.append((_var_value(p.name, state), style))
|
||||
elif isinstance(p, Dyn) and _cond_holds(p.cond, state):
|
||||
walk(p.pieces)
|
||||
|
||||
walk(pieces(design, state["root"]))
|
||||
return out
|
||||
|
||||
|
||||
def preview_text(design, state):
|
||||
return "".join(text for text, _ in preview(design, state))
|
||||
Reference in New Issue
Block a user