terok 0.8.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.
Files changed (149) hide show
  1. terok/__init__.py +39 -0
  2. terok/cli/__init__.py +8 -0
  3. terok/cli/__main__.py +9 -0
  4. terok/cli/commands/__init__.py +9 -0
  5. terok/cli/commands/_completers.py +111 -0
  6. terok/cli/commands/_desktop_entry.py +436 -0
  7. terok/cli/commands/_storage_view.py +278 -0
  8. terok/cli/commands/acp.py +381 -0
  9. terok/cli/commands/agents.py +134 -0
  10. terok/cli/commands/auth.py +159 -0
  11. terok/cli/commands/clearance.py +32 -0
  12. terok/cli/commands/completions.py +148 -0
  13. terok/cli/commands/image.py +282 -0
  14. terok/cli/commands/info.py +501 -0
  15. terok/cli/commands/panic.py +110 -0
  16. terok/cli/commands/project.py +405 -0
  17. terok/cli/commands/setup.py +376 -0
  18. terok/cli/commands/shield.py +226 -0
  19. terok/cli/commands/sickbay.py +635 -0
  20. terok/cli/commands/task.py +792 -0
  21. terok/cli/commands/uninstall.py +166 -0
  22. terok/cli/main.py +339 -0
  23. terok/cli/tree.py +139 -0
  24. terok/lib/__init__.py +9 -0
  25. terok/lib/api/__init__.py +465 -0
  26. terok/lib/api/agents.py +96 -0
  27. terok/lib/api/clearance.py +42 -0
  28. terok/lib/api/gate.py +27 -0
  29. terok/lib/api/project.py +94 -0
  30. terok/lib/api/setup.py +54 -0
  31. terok/lib/api/shield.py +54 -0
  32. terok/lib/api/task.py +113 -0
  33. terok/lib/api/vault.py +170 -0
  34. terok/lib/core/__init__.py +4 -0
  35. terok/lib/core/config.py +820 -0
  36. terok/lib/core/images.py +113 -0
  37. terok/lib/core/paths.py +219 -0
  38. terok/lib/core/project_model.py +206 -0
  39. terok/lib/core/projects.py +596 -0
  40. terok/lib/core/runtime.py +118 -0
  41. terok/lib/core/task_display.py +89 -0
  42. terok/lib/core/task_state.py +123 -0
  43. terok/lib/core/version.py +238 -0
  44. terok/lib/core/work_status.py +194 -0
  45. terok/lib/core/yaml_schema.py +491 -0
  46. terok/lib/domain/__init__.py +3 -0
  47. terok/lib/domain/auth.py +235 -0
  48. terok/lib/domain/image_cleanup.py +237 -0
  49. terok/lib/domain/log_format.py +338 -0
  50. terok/lib/domain/panic.py +390 -0
  51. terok/lib/domain/project.py +889 -0
  52. terok/lib/domain/project_state.py +239 -0
  53. terok/lib/domain/ssh.py +37 -0
  54. terok/lib/domain/storage.py +310 -0
  55. terok/lib/domain/task.py +246 -0
  56. terok/lib/domain/task_credentials.py +172 -0
  57. terok/lib/domain/task_logs.py +243 -0
  58. terok/lib/domain/vault.py +76 -0
  59. terok/lib/domain/wizards/__init__.py +4 -0
  60. terok/lib/domain/wizards/new_project.py +764 -0
  61. terok/lib/integrations/__init__.py +21 -0
  62. terok/lib/integrations/clearance.py +41 -0
  63. terok/lib/integrations/executor.py +112 -0
  64. terok/lib/integrations/sandbox.py +162 -0
  65. terok/lib/integrations/shield.py +37 -0
  66. terok/lib/orchestration/__init__.py +3 -0
  67. terok/lib/orchestration/agent_config.py +106 -0
  68. terok/lib/orchestration/container_doctor.py +772 -0
  69. terok/lib/orchestration/container_exec.py +85 -0
  70. terok/lib/orchestration/environment.py +607 -0
  71. terok/lib/orchestration/hooks.py +184 -0
  72. terok/lib/orchestration/image.py +475 -0
  73. terok/lib/orchestration/ports.py +25 -0
  74. terok/lib/orchestration/task_runners/__init__.py +49 -0
  75. terok/lib/orchestration/task_runners/cli.py +184 -0
  76. terok/lib/orchestration/task_runners/config.py +106 -0
  77. terok/lib/orchestration/task_runners/container.py +341 -0
  78. terok/lib/orchestration/task_runners/headless.py +471 -0
  79. terok/lib/orchestration/task_runners/restart.py +150 -0
  80. terok/lib/orchestration/task_runners/shield.py +283 -0
  81. terok/lib/orchestration/task_runners/toad.py +344 -0
  82. terok/lib/orchestration/tasks/__init__.py +135 -0
  83. terok/lib/orchestration/tasks/archive.py +106 -0
  84. terok/lib/orchestration/tasks/identity.py +149 -0
  85. terok/lib/orchestration/tasks/lifecycle.py +669 -0
  86. terok/lib/orchestration/tasks/meta.py +340 -0
  87. terok/lib/orchestration/tasks/naming.py +100 -0
  88. terok/lib/orchestration/tasks/query.py +364 -0
  89. terok/lib/util/__init__.py +4 -0
  90. terok/lib/util/ansi.py +93 -0
  91. terok/lib/util/check_reporter.py +232 -0
  92. terok/lib/util/emoji.py +132 -0
  93. terok/lib/util/fs.py +79 -0
  94. terok/lib/util/host_cmd.py +67 -0
  95. terok/lib/util/logging_utils.py +67 -0
  96. terok/lib/util/net.py +16 -0
  97. terok/lib/util/subprocess_env.py +33 -0
  98. terok/lib/util/yaml.py +57 -0
  99. terok/resources/desktop/terok-symbolic.svg +75 -0
  100. terok/resources/desktop/terok-xdg-terminal-exec.sh +38 -0
  101. terok/resources/desktop/terok.desktop.template +13 -0
  102. terok/resources/instructions/default.md +54 -0
  103. terok/resources/presets/review.yml +23 -0
  104. terok/resources/presets/solo.yml +16 -0
  105. terok/resources/presets/team.yml +144 -0
  106. terok/resources/templates/l2.project.Dockerfile.template +18 -0
  107. terok/resources/templates/projects/project.yml.template +57 -0
  108. terok/resources/tmux/host-tmux.conf +19 -0
  109. terok/tui/__init__.py +8 -0
  110. terok/tui/__main__.py +9 -0
  111. terok/tui/_worker_entry.py +82 -0
  112. terok/tui/agents_screen.py +234 -0
  113. terok/tui/app.py +1856 -0
  114. terok/tui/askpass.py +93 -0
  115. terok/tui/askpass_protocol.py +114 -0
  116. terok/tui/askpass_service.py +370 -0
  117. terok/tui/clearance_screen.py +410 -0
  118. terok/tui/clipboard.py +210 -0
  119. terok/tui/console_log.py +369 -0
  120. terok/tui/console_output_screen.py +132 -0
  121. terok/tui/log_viewer.py +547 -0
  122. terok/tui/polling.py +299 -0
  123. terok/tui/project_actions.py +1058 -0
  124. terok/tui/screens.py +2541 -0
  125. terok/tui/selinux_fix_screen.py +147 -0
  126. terok/tui/serve.py +413 -0
  127. terok/tui/setup_screen.py +198 -0
  128. terok/tui/shell_launch.py +215 -0
  129. terok/tui/task_actions.py +973 -0
  130. terok/tui/text_screens.py +152 -0
  131. terok/tui/widgets/__init__.py +43 -0
  132. terok/tui/widgets/panic_button.py +89 -0
  133. terok/tui/widgets/project_list.py +127 -0
  134. terok/tui/widgets/project_state.py +293 -0
  135. terok/tui/widgets/status_bar.py +41 -0
  136. terok/tui/widgets/task_detail.py +262 -0
  137. terok/tui/widgets/task_list.py +183 -0
  138. terok/tui/wizard_screens.py +1082 -0
  139. terok/tui/worker_actions.py +262 -0
  140. terok/tui/worker_log_screen.py +176 -0
  141. terok/ui_utils/__init__.py +4 -0
  142. terok/ui_utils/editor.py +56 -0
  143. terok/ui_utils/terminal.py +77 -0
  144. terok-0.8.0.dist-info/METADATA +265 -0
  145. terok-0.8.0.dist-info/RECORD +149 -0
  146. terok-0.8.0.dist-info/WHEEL +4 -0
  147. terok-0.8.0.dist-info/entry_points.txt +8 -0
  148. terok-0.8.0.dist-info/licenses/LICENSE +177 -0
  149. terok-0.8.0.dist-info/licenses/LICENSES/Apache-2.0.txt +202 -0
terok/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ # SPDX-FileCopyrightText: 2025 Jiri Vyskocil
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """terok package.
5
+
6
+ Modules:
7
+ - terok.cli: CLI entry point package (terok)
8
+ - terok.tui: Text UI entry point package (terok)
9
+ - terok.ui_utils: Shared UI helpers (terminal ANSI, editor launch)
10
+ - terok.lib: Business logic layer (core, containers, security, wizards, integrations, util)
11
+ """
12
+
13
+ __all__ = [
14
+ "cli",
15
+ "tui",
16
+ "ui_utils",
17
+ "lib",
18
+ ]
19
+
20
+ # Version information - single source of truth using importlib.metadata
21
+ import tomllib
22
+ from importlib.metadata import PackageNotFoundError, version
23
+
24
+ try:
25
+ __version__ = version("terok")
26
+ except PackageNotFoundError:
27
+ # Fallback for development mode when package is not installed
28
+ try:
29
+ from pathlib import Path
30
+
31
+ pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
32
+ if pyproject_path.exists():
33
+ with open(pyproject_path, "rb") as f:
34
+ pyproject_data = tomllib.load(f)
35
+ __version__ = pyproject_data["tool"]["poetry"]["version"]
36
+ else:
37
+ __version__ = "unknown"
38
+ except (OSError, KeyError, tomllib.TOMLDecodeError):
39
+ __version__ = "unknown"
terok/cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2025 Jiri Vyskocil
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """terok CLI package."""
5
+
6
+ from .main import main
7
+
8
+ __all__ = ["main"]
terok/cli/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2025 Jiri Vyskocil
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """CLI entry point for ``python -m terok.cli``."""
5
+
6
+ from .main import main
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Jiri Vyskocil
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """CLI command modules.
5
+
6
+ Each module exposes ``register(subparsers)`` to add its argument parsers
7
+ and ``dispatch(args) -> bool`` to handle parsed arguments. The dispatch
8
+ function returns ``True`` if it handled the command, ``False`` otherwise.
9
+ """
@@ -0,0 +1,111 @@
1
+ # SPDX-FileCopyrightText: 2026 Jiri Vyskocil
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Shared argcomplete completers and helpers for CLI commands.
5
+
6
+ All completers assume the standard ``project_id`` / ``task_id`` dest
7
+ names. Parsers whose positionals display as ``<project>`` / ``<task>``
8
+ (e.g. ``sickbay``) should set ``dest="project_id"`` / ``dest="task_id"``
9
+ with a custom ``metavar=`` for display, so completers and argparse help
10
+ stay decoupled.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ from ...lib.api import get_tasks
20
+ from ...lib.core.projects import list_presets, list_projects
21
+ from ...lib.orchestration.tasks import normalize_task_id_input
22
+
23
+
24
+ def complete_project_ids(
25
+ prefix: str, parsed_args: argparse.Namespace, **kwargs: object
26
+ ) -> list[str]: # pragma: no cover
27
+ """Return project IDs matching *prefix* for argcomplete."""
28
+ try:
29
+ ids = [p.id for p in list_projects()]
30
+ except Exception:
31
+ return []
32
+ if prefix:
33
+ ids = [i for i in ids if str(i).startswith(prefix)]
34
+ return ids
35
+
36
+
37
+ def complete_task_ids(
38
+ prefix: str, parsed_args: argparse.Namespace, **kwargs: object
39
+ ) -> list[str]: # pragma: no cover
40
+ """Return task IDs matching *prefix* within ``parsed_args.project_id``.
41
+
42
+ Returns an empty list when the project arg hasn't been typed yet —
43
+ argcomplete uses the partially-parsed namespace, which is exactly
44
+ what we want to scope task-ID suggestions.
45
+
46
+ The prefix is run through [`normalize_task_id_input`][terok.lib.orchestration.tasks.normalize_task_id_input], so
47
+ ``K3V<TAB>`` or ``k3-v<TAB>`` rewrite to the canonical lowercase
48
+ form — the same surface-form tolerance ``resolve_task_id`` gives
49
+ at dispatch time.
50
+ """
51
+ project_id = getattr(parsed_args, "project_id", None)
52
+ if not project_id:
53
+ return []
54
+ try:
55
+ tids = [t.task_id for t in get_tasks(project_id) if t.task_id]
56
+ except Exception:
57
+ return []
58
+ normalized = normalize_task_id_input(prefix)
59
+ if normalized:
60
+ tids = [t for t in tids if t.startswith(normalized)]
61
+ return tids
62
+
63
+
64
+ def complete_preset_names(
65
+ prefix: str, parsed_args: argparse.Namespace, **kwargs: object
66
+ ) -> list[str]: # pragma: no cover
67
+ """Return preset names matching *prefix* for the scoped project.
68
+
69
+ ``list_presets`` requires a project ID to resolve the full tier
70
+ (bundled → global → project), so we only suggest presets once the
71
+ user has typed the project arg. No project typed yet → empty list,
72
+ which leaves argcomplete silent rather than misleading.
73
+ """
74
+ project_id = getattr(parsed_args, "project_id", None)
75
+ if not project_id:
76
+ return []
77
+ try:
78
+ names = [p.name for p in list_presets(project_id)]
79
+ except Exception:
80
+ return []
81
+ if prefix:
82
+ names = [n for n in names if n.startswith(prefix)]
83
+ return names
84
+
85
+
86
+ def set_completer(action: argparse.Action, fn: Callable[..., Any]) -> None:
87
+ """Attach an argcomplete completer to *action*, ignoring missing argcomplete."""
88
+ action.completer = fn # type: ignore[attr-defined]
89
+
90
+
91
+ def add_project_id(parser: argparse.ArgumentParser, **kwargs: Any) -> argparse.Action:
92
+ """Add a ``project_id`` positional with the project-ID completer attached.
93
+
94
+ Returns the argparse action so callers can further customise it.
95
+ Accepts any argparse kwargs (``nargs``, ``metavar``, ``help``, etc.).
96
+ """
97
+ action = parser.add_argument("project_id", **kwargs)
98
+ set_completer(action, complete_project_ids)
99
+ return action
100
+
101
+
102
+ def add_task_id(parser: argparse.ArgumentParser, **kwargs: Any) -> argparse.Action:
103
+ """Add a ``task_id`` positional with the task-ID completer attached.
104
+
105
+ Returns the argparse action. Callers should typically precede this
106
+ with `add_project_id` so argcomplete has a project scope to
107
+ look up tasks under.
108
+ """
109
+ action = parser.add_argument("task_id", **kwargs)
110
+ set_completer(action, complete_task_ids)
111
+ return action
@@ -0,0 +1,436 @@
1
+ # SPDX-FileCopyrightText: 2026 Jiri Vyskocil
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Install the XDG desktop entry + symbolic SVG icon for ``terok-tui``.
5
+
6
+ ``terok setup`` calls `install_desktop_entry` (or the matching
7
+ `uninstall_desktop_entry`) as a default-on phase, so the TUI
8
+ appears as *Terok* in GNOME / KDE / XFCE application menus without the
9
+ operator knowing the template layout. Every step soft-fails so a
10
+ headless host without ``.local/share`` or without ``xdg-utils`` never
11
+ kills the wider ``terok setup`` flow.
12
+
13
+ Preferred path for the ``.desktop`` file is ``xdg-utils`` —
14
+ ``xdg-desktop-menu install`` runs ``desktop-file-install`` (validates
15
+ the file, catches malformed keys) and refreshes
16
+ ``update-desktop-database`` for us. The icon, however, is always
17
+ written manually: ``xdg-icon-resource install --size`` only accepts
18
+ numeric sizes or ``scalable`` (per the upstream xdg-utils source —
19
+ ``size argument must be numeric or the word 'scalable'``), so a
20
+ symbolic icon (which lives in ``hicolor/symbolic/apps/``) can't be
21
+ registered through that path. We drop the icon directly into the
22
+ hicolor tree and kick ``gtk-update-icon-cache`` ourselves.
23
+
24
+ When ``xdg-utils`` isn't on PATH (minimal container images, some CI
25
+ runners) we fall back to writing the ``.desktop`` ourselves too.
26
+ This is *best-effort*: the file ends up in the right place on hosts
27
+ that match the spec, but there's no ``desktop-file-install``
28
+ validation and no cover for DE-specific layout drift.
29
+ `install_desktop_entry` returns a `DesktopBackend` so the caller can
30
+ surface a gentle warning when the fallback kicks in.
31
+
32
+ The passive assets (``.desktop`` template, logo SVG) live under
33
+ ``terok/resources/desktop/`` — this module is the *builder* that
34
+ renders them and delegates to the XDG tool of choice. When ``ptyxis``
35
+ is on PATH, `_render_desktop_file` routes the launch through the
36
+ bundled ``terok-xdg-terminal-exec.sh`` shim to dodge a Ptyxis
37
+ standalone-mode bug — Fedora patches GLib to inject ``ptyxis`` into
38
+ GIO's hardcoded ``known_terminals[]`` (right after ``xdg-terminal-exec``)
39
+ so a vanilla ``Terminal=true`` launcher ends up as ``ptyxis -- terok-tui``,
40
+ which trips the bug. See the shim's header for the rationale.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import logging
46
+ import os
47
+ import shutil
48
+ import subprocess # nosec B404 — cache refresh binaries are trusted
49
+ import tempfile
50
+ from enum import StrEnum
51
+ from importlib import resources as importlib_resources
52
+ from importlib.resources.abc import Traversable
53
+ from pathlib import Path
54
+
55
+ import jinja2
56
+
57
+ _log = logging.getLogger(__name__)
58
+
59
+ #: Base name of the application launcher.
60
+ APP_NAME = "terok"
61
+
62
+ #: Icon name — the ``-symbolic`` suffix is honoured by GTK and Qt as a
63
+ #: marker that triggers the toolkit's symbolic-icon rendering pipeline,
64
+ #: which substitutes the placeholder fill (``#bebebe`` in our SVG) with
65
+ #: the active theme's foreground colour. Same mechanism as ``Icon=
66
+ #: <name>-symbolic`` on every well-behaved GNOME / KDE app.
67
+ _ICON_NAME = f"{APP_NAME}-symbolic"
68
+
69
+ _DESKTOP_FILE = f"{APP_NAME}.desktop"
70
+ _ICON_FILE = f"{_ICON_NAME}.svg"
71
+ _TEMPLATE_NAME = "terok.desktop.template"
72
+ _LOGO_NAME = _ICON_FILE
73
+ _PTYXIS_SHIM_NAME = "terok-xdg-terminal-exec.sh"
74
+
75
+ # XDG Base Directory + Icon Theme spec path fragments. Named so a
76
+ # future theme-dir shift is a single-constant change and so ``grep`` for
77
+ # the fragment lands on the canonical definition rather than every join
78
+ # site. Symbolic icons live under ``hicolor/symbolic/apps/`` (the
79
+ # ``symbolic`` directory is hicolor's well-known symbolic-icon slot).
80
+ _APPLICATIONS_SUBDIR = "applications"
81
+ _ICONS_SUBDIR = "icons"
82
+ _HICOLOR_THEME = "hicolor"
83
+ _APPS_SUBDIR = "apps"
84
+ _ICON_SIZE_DIR = "symbolic"
85
+ _DEFAULT_DATA_HOME = (".local", "share") # $HOME/.local/share — XDG fallback
86
+
87
+ _XDG_MENU_BINARY = "xdg-desktop-menu"
88
+ # xdg-icon-resource intentionally NOT used — its ``--size`` accepts only
89
+ # numeric values or ``scalable``, never ``symbolic``, so symbolic icons
90
+ # can't be registered through it. Manual write into
91
+ # ``hicolor/symbolic/apps/`` instead.
92
+
93
+ _SUBPROCESS_TIMEOUT_S = 10
94
+
95
+
96
+ class DesktopBackend(StrEnum):
97
+ """Which install path `install_desktop_entry` actually took."""
98
+
99
+ XDG_UTILS = "xdg-utils"
100
+ FALLBACK = "fallback"
101
+
102
+
103
+ def _resource_dir() -> Traversable:
104
+ """Return a ``Traversable`` rooted at the passive ``resources/desktop/`` assets.
105
+
106
+ Uses the namespace-package idiom already used by
107
+ [`terok.lib.core.config.bundled_presets_dir`][terok.lib.core.config.bundled_presets_dir]: walk the top-level
108
+ ``terok`` package into the ``resources`` + ``desktop`` subdirs (no
109
+ ``__init__.py`` anywhere under ``resources/``, matching the project's
110
+ "resources hold only data files" convention).
111
+ """
112
+ return importlib_resources.files("terok").joinpath("resources", "desktop")
113
+
114
+
115
+ def install_desktop_entry(bin_path: str | Path) -> DesktopBackend:
116
+ """Render the launcher + copy the icon, via xdg-utils when available.
117
+
118
+ Args:
119
+ bin_path: Absolute path (or bare name) to ``terok-tui``. The
120
+ freedesktop ``Exec=`` / ``TryExec=`` keys need this — the
121
+ launcher's minimal PATH often misses ``~/.local/bin``, so
122
+ ``shutil.which("terok-tui")``'s absolute result is preferred
123
+ over the short name.
124
+
125
+ Returns:
126
+ The `DesktopBackend` actually used. Callers wire this to
127
+ a status-line warning when the fallback kicks in so the operator
128
+ knows ``xdg-utils`` is missing.
129
+ """
130
+ rendered = _render_desktop_file(str(bin_path))
131
+ logo_bytes = _resource_dir().joinpath(_LOGO_NAME).read_bytes()
132
+ if xdg_utils_available() and _install_via_xdg_utils(rendered, logo_bytes):
133
+ return DesktopBackend.XDG_UTILS
134
+ # xdg-utils missing *or* it barfed (readonly menu dir, timeout, bad
135
+ # DE detection) — land the files ourselves so the operator still
136
+ # gets a working launcher, and report FALLBACK so the caller can
137
+ # warn. The DEBUG log carries the xdg-utils failure detail.
138
+ _install_manually(rendered, logo_bytes)
139
+ return DesktopBackend.FALLBACK
140
+
141
+
142
+ def uninstall_desktop_entry() -> DesktopBackend:
143
+ """Remove the launcher + icon, via xdg-utils when available.
144
+
145
+ Returns:
146
+ The `DesktopBackend` actually used — symmetric with
147
+ `install_desktop_entry`. XDG_UTILS only when both
148
+ front-ends reported rc 0; on failure (or xdg-utils absent) we
149
+ retry via manual unlinks and report FALLBACK so the teardown
150
+ leaves no stragglers even when xdg-utils misbehaves.
151
+ """
152
+ if xdg_utils_available() and _uninstall_via_xdg_utils():
153
+ return DesktopBackend.XDG_UTILS
154
+ _uninstall_manually()
155
+ return DesktopBackend.FALLBACK
156
+
157
+
158
+ def is_desktop_entry_installed() -> bool:
159
+ """Return True when both the ``.desktop`` and icon files exist on disk.
160
+
161
+ Probes the install tree directly rather than asking xdg-utils — both
162
+ backends land the same files in the same XDG-spec locations, so the
163
+ presence check is backend-agnostic.
164
+ """
165
+ return _desktop_entry_path().is_file() and _icon_path().is_file()
166
+
167
+
168
+ # ── xdg-utils backend ─────────────────────────────────────────────────
169
+
170
+
171
+ def xdg_utils_available() -> bool:
172
+ """Return True when xdg-desktop-menu is on PATH (icon side is always manual)."""
173
+ return bool(shutil.which(_XDG_MENU_BINARY))
174
+
175
+
176
+ def _install_via_xdg_utils(desktop_contents: str, logo_bytes: bytes) -> bool:
177
+ """Install the ``.desktop`` via xdg-utils; write the icon manually.
178
+
179
+ ``xdg-desktop-menu install`` runs ``desktop-file-install`` (catches
180
+ malformed keys), drops the file under the user's applications dir,
181
+ and kicks ``update-desktop-database``. We stage to a tempdir
182
+ because xdg-desktop-menu names the installed file after the source
183
+ basename — staging to ``/tmp/.../terok.desktop`` makes the launcher
184
+ register as ``terok``.
185
+
186
+ Icon: ``xdg-icon-resource install --size`` accepts only numeric
187
+ sizes or ``scalable``, never ``symbolic``, so we write the symbolic
188
+ icon directly into ``hicolor/symbolic/apps/`` and refresh
189
+ ``gtk-update-icon-cache`` ourselves.
190
+
191
+ Returns:
192
+ True only when the ``.desktop`` install reported success.
193
+ Icon install is always attempted (manual write). A failed
194
+ ``.desktop`` install reads as False so the caller retries via
195
+ the manual path.
196
+ """
197
+ with tempfile.TemporaryDirectory(prefix="terok-desktop-") as td:
198
+ staged_desktop = Path(td) / _DESKTOP_FILE
199
+ staged_desktop.write_text(desktop_contents, encoding="utf-8")
200
+ menu_ok = _run_xdg(
201
+ _XDG_MENU_BINARY,
202
+ "install",
203
+ "--novendor",
204
+ str(staged_desktop),
205
+ )
206
+ if not menu_ok:
207
+ return False
208
+ _write_icon(logo_bytes)
209
+ _refresh_icon_cache()
210
+ return True
211
+
212
+
213
+ def _uninstall_via_xdg_utils() -> bool:
214
+ """Remove the ``.desktop`` via xdg-utils; unlink the icon manually.
215
+
216
+ Symmetric with `_install_via_xdg_utils` — xdg-utils can't manage
217
+ symbolic icons, so the icon side is always direct unlink +
218
+ ``gtk-update-icon-cache``.
219
+
220
+ Returns:
221
+ True when the xdg-desktop-menu uninstall reports success.
222
+ Icon unlink is always attempted.
223
+ """
224
+ menu_ok = _run_xdg(_XDG_MENU_BINARY, "uninstall", "--novendor", _DESKTOP_FILE)
225
+ _unlink_icon()
226
+ _refresh_icon_cache()
227
+ return menu_ok
228
+
229
+
230
+ def _run_xdg(binary: str, *args: str) -> bool:
231
+ """Invoke an xdg-utils front-end; return True only on rc-0, False otherwise.
232
+
233
+ Never raises — a hung / missing / broken front-end lands in DEBUG
234
+ so an operator chasing a weird install state can grep
235
+ ``journalctl --user`` without ``terok setup`` exploding. The
236
+ return value lets `_install_via_xdg_utils` decide whether to
237
+ hand off to the manual fallback.
238
+ """
239
+ found = shutil.which(binary)
240
+ if not found: # pragma: no cover — gated by xdg_utils_available
241
+ return False
242
+ # nosec B603 — argv is our own literal binary path plus subcommand/arg tokens.
243
+ try:
244
+ result = subprocess.run( # noqa: S603 # nosec B603
245
+ [found, *args],
246
+ check=False,
247
+ capture_output=True,
248
+ timeout=_SUBPROCESS_TIMEOUT_S,
249
+ )
250
+ except (OSError, subprocess.TimeoutExpired) as exc:
251
+ _log.debug("%s %s failed: %s", binary, args, exc)
252
+ return False
253
+ if result.returncode != 0:
254
+ _log.debug(
255
+ "%s %s exited with %d: %s",
256
+ binary,
257
+ args,
258
+ result.returncode,
259
+ (result.stderr or b"").decode(errors="replace").strip(),
260
+ )
261
+ return False
262
+ return True
263
+
264
+
265
+ # ── Manual fallback ───────────────────────────────────────────────────
266
+
267
+
268
+ def _install_manually(desktop_contents: str, logo_bytes: bytes) -> None:
269
+ """Write the launcher + icon directly and trigger cache refreshes by hand."""
270
+ desktop_path = _desktop_entry_path()
271
+ desktop_path.parent.mkdir(parents=True, exist_ok=True)
272
+ desktop_path.write_text(desktop_contents, encoding="utf-8")
273
+ desktop_path.chmod(0o644)
274
+
275
+ _write_icon(logo_bytes)
276
+
277
+ _refresh_desktop_database()
278
+ _refresh_icon_cache()
279
+
280
+
281
+ def _uninstall_manually() -> None:
282
+ """Unlink the launcher + icon and refresh caches so menus forget."""
283
+ try:
284
+ _desktop_entry_path().unlink(missing_ok=True)
285
+ except OSError as exc:
286
+ _log.warning("failed to unlink %s: %s", _desktop_entry_path(), exc)
287
+ _unlink_icon()
288
+ _refresh_desktop_database()
289
+ _refresh_icon_cache()
290
+
291
+
292
+ def _write_icon(logo_bytes: bytes) -> None:
293
+ """Write the symbolic SVG to ``hicolor/symbolic/apps/`` directly.
294
+
295
+ Used by both the xdg-utils path and the manual path — xdg-icon-resource
296
+ can't register symbolic icons (its ``--size`` rejects anything but a
297
+ numeric value or ``scalable``), so the symbolic install is always a
298
+ direct write. Caller is expected to refresh the icon cache afterwards.
299
+ """
300
+ icon_path = _icon_path()
301
+ icon_path.parent.mkdir(parents=True, exist_ok=True)
302
+ icon_path.write_bytes(logo_bytes)
303
+ icon_path.chmod(0o644)
304
+
305
+
306
+ def _unlink_icon() -> None:
307
+ """Remove the installed icon; symmetric with `_write_icon`."""
308
+ icon_path = _icon_path()
309
+ try:
310
+ icon_path.unlink(missing_ok=True)
311
+ except OSError as exc:
312
+ _log.warning("failed to unlink %s: %s", icon_path, exc)
313
+
314
+
315
+ # ── Path derivation ───────────────────────────────────────────────────
316
+
317
+
318
+ def _desktop_entry_path() -> Path:
319
+ """Return ``$XDG_DATA_HOME/applications/terok.desktop`` (XDG default)."""
320
+ return _data_home() / _APPLICATIONS_SUBDIR / _DESKTOP_FILE
321
+
322
+
323
+ def _icon_path() -> Path:
324
+ """Return ``$XDG_DATA_HOME/icons/hicolor/symbolic/apps/terok-symbolic.svg``."""
325
+ return (
326
+ _data_home() / _ICONS_SUBDIR / _HICOLOR_THEME / _ICON_SIZE_DIR / _APPS_SUBDIR / _ICON_FILE
327
+ )
328
+
329
+
330
+ def _data_home() -> Path:
331
+ """Return the user's XDG data home, honouring ``$XDG_DATA_HOME`` when set."""
332
+ override = os.environ.get("XDG_DATA_HOME")
333
+ return Path(override) if override else Path.home().joinpath(*_DEFAULT_DATA_HOME)
334
+
335
+
336
+ # ── Template rendering ────────────────────────────────────────────────
337
+
338
+
339
+ def _render_desktop_file(bin_str: str) -> str:
340
+ """Render ``terok.desktop`` with the right Exec / TryExec / Terminal values."""
341
+ # We gate on `ptyxis` alone. A more precise "is this a Fedora-
342
+ # patched glib" probe exists — `grep -aow ptyxis /lib64/libgio-2.0.so.0`
343
+ # exits 0 iff Fedora's `default-terminal.patch` injected
344
+ # { "ptyxis", "--" } into gio's hardcoded `known_terminals[]` — but
345
+ # it's (a) un-pythonic (shelling out to grep at a fixed sopath) and
346
+ # (b) not actually sufficient: when `xdg-terminal-exec` is installed
347
+ # it precedes ptyxis in the glib list and consults the user's
348
+ # `xdg-terminals.list`, so the rodata literal mis-predicts the
349
+ # real launch. Hijacking on PATH-presence is over-eager on hosts
350
+ # where vanilla glib wouldn't pick ptyxis anyway, but that's fine
351
+ # — the user installed Ptyxis on purpose; the shim gives them the
352
+ # container-tabs UI they want.
353
+ if shutil.which("ptyxis"):
354
+ shim = str(_resource_dir().joinpath(_PTYXIS_SHIM_NAME))
355
+ # ``TryExec`` points at the binary, not the shim: pipx (and any
356
+ # PEP 517 wheel installer) ships package data without the
357
+ # executable bit, and GNOME silently hides any launcher whose
358
+ # ``TryExec`` target isn't ``+x``. ``Exec`` invokes the shim
359
+ # via ``/bin/sh``, so the shim doesn't need to be executable —
360
+ # and the semantic we want to gate on ("is terok-tui actually
361
+ # installed?") is best expressed by ``TryExec``-ing the binary
362
+ # anyway.
363
+ variables = {
364
+ "EXEC": f"/bin/sh {shim} {bin_str}",
365
+ "TRY_EXEC": bin_str,
366
+ "TERMINAL": "false",
367
+ }
368
+ else:
369
+ variables = {"EXEC": bin_str, "TRY_EXEC": bin_str, "TERMINAL": "true"}
370
+ # The fallback install path skips ``desktop-file-install`` validation,
371
+ # so refuse any value with a C0/DEL/C1 character before substitution —
372
+ # a stray control byte in ``Exec=`` / ``TryExec=`` corrupts the
373
+ # launcher's key syntax and the unvalidated path lands as-is. In
374
+ # practice the values come from ``shutil.which`` results (path
375
+ # strings), but the cost of the guard is zero compared to debugging
376
+ # a silently-broken launcher.
377
+ for key, value in variables.items():
378
+ if any(ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F for ch in value):
379
+ raise ValueError(f"{key} contains a control character: {value!r}")
380
+ with importlib_resources.as_file(_resource_dir().joinpath(_TEMPLATE_NAME)) as template_path:
381
+ # ``StrictUndefined`` upgrades silent ``{{TYPO}}`` to a hard error;
382
+ # ``autoescape=False`` because ``.desktop`` syntax is not HTML and
383
+ # any escaping would corrupt ``Exec=`` quoting.
384
+ env = jinja2.Environment( # nosec B701 — see comment above # noqa: S701
385
+ loader=jinja2.FileSystemLoader(str(template_path.parent)),
386
+ keep_trailing_newline=True,
387
+ undefined=jinja2.StrictUndefined,
388
+ autoescape=False,
389
+ )
390
+ return env.get_template(template_path.name).render(**variables)
391
+
392
+
393
+ # ── Manual cache refresh (fallback backend only) ──────────────────────
394
+
395
+
396
+ def _refresh_desktop_database() -> None:
397
+ """Nudge ``update-desktop-database`` if present; silent otherwise."""
398
+ _run_cache_refresh(
399
+ "update-desktop-database",
400
+ [_data_home() / _APPLICATIONS_SUBDIR],
401
+ )
402
+
403
+
404
+ def _refresh_icon_cache() -> None:
405
+ """Nudge ``gtk-update-icon-cache`` on the hicolor theme if present."""
406
+ _run_cache_refresh(
407
+ "gtk-update-icon-cache",
408
+ ["-q", "-t", _data_home() / _ICONS_SUBDIR / _HICOLOR_THEME],
409
+ )
410
+
411
+
412
+ def _run_cache_refresh(binary: str, args: list[str | Path]) -> None:
413
+ """Invoke *binary* with *args*, swallow every failure — caches are optional."""
414
+ found = shutil.which(binary)
415
+ if not found:
416
+ return
417
+ # nosec B603 — argv is a literal + controlled Path; no shell, no user input.
418
+ try:
419
+ result = subprocess.run( # noqa: S603 # nosec B603
420
+ [found, *[str(a) for a in args]],
421
+ check=False,
422
+ capture_output=True,
423
+ timeout=_SUBPROCESS_TIMEOUT_S,
424
+ )
425
+ except (OSError, subprocess.TimeoutExpired) as exc:
426
+ _log.debug("%s refresh failed: %s", binary, exc)
427
+ return
428
+ if result.returncode != 0:
429
+ # Same DEBUG trail as _run_xdg — ``check=False`` keeps us quiet,
430
+ # the log makes the failure diagnosable after the fact.
431
+ _log.debug(
432
+ "%s exited with %d: %s",
433
+ binary,
434
+ result.returncode,
435
+ (result.stderr or b"").decode(errors="replace").strip(),
436
+ )