cli-tools-kit 0.8.3__tar.gz → 1.0.0__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.
Files changed (60) hide show
  1. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/PKG-INFO +63 -10
  2. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/README.md +54 -8
  3. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/__init__.py +1 -1
  4. cli_tools_kit-1.0.0/cli_tools_kit/autostart.py +284 -0
  5. cli_tools_kit-1.0.0/cli_tools_kit/cli.py +635 -0
  6. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/cron_installer.py +41 -23
  7. cli_tools_kit-1.0.0/cli_tools_kit/discovery.py +294 -0
  8. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/gui_installer.py +161 -2439
  9. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/host.py +52 -1
  10. cli_tools_kit-1.0.0/cli_tools_kit/icons.py +423 -0
  11. cli_tools_kit-1.0.0/cli_tools_kit/install.py +298 -0
  12. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/onboarding.py +1 -1
  13. cli_tools_kit-1.0.0/cli_tools_kit/settings.py +125 -0
  14. cli_tools_kit-1.0.0/cli_tools_kit/state.py +196 -0
  15. cli_tools_kit-1.0.0/cli_tools_kit/sweep.py +187 -0
  16. cli_tools_kit-1.0.0/cli_tools_kit/testing.py +126 -0
  17. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/tool_installer.py +10 -0
  18. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/tui_installer.py +9 -3
  19. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/PKG-INFO +63 -10
  20. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/SOURCES.txt +16 -3
  21. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/requires.txt +4 -0
  22. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/pyproject.toml +37 -2
  23. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_capability_groups.py +0 -2
  24. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_cron_installer.py +23 -0
  25. cli_tools_kit-1.0.0/tests/test_e2e.py +164 -0
  26. cli_tools_kit-1.0.0/tests/test_engine.py +182 -0
  27. cli_tools_kit-1.0.0/tests/test_gui_smoke.py +68 -0
  28. cli_tools_kit-1.0.0/tests/test_hooks.py +45 -0
  29. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_host.py +42 -0
  30. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_identity.py +22 -20
  31. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_llm_groups.py +0 -2
  32. cli_tools_kit-1.0.0/tests/test_public_api.py +117 -0
  33. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_sources.py +29 -18
  34. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_taxonomy_cluster.py +0 -2
  35. cli_tools_kit-1.0.0/tests/test_testing.py +57 -0
  36. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_tool_installer.py +22 -1
  37. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_tui_installer.py +25 -5
  38. cli_tools_kit-0.8.3/cli_tools_kit/venvs.py +0 -319
  39. cli_tools_kit-0.8.3/tests/test_venvs.py +0 -242
  40. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/LICENSE +0 -0
  41. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/__main__.py +0 -0
  42. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/advertise.py +0 -0
  43. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/autostart_gate.py +0 -0
  44. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/identity.py +0 -0
  45. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/skills.py +0 -0
  46. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/sources.py +0 -0
  47. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/__init__.py +0 -0
  48. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/build.py +0 -0
  49. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/capability.py +0 -0
  50. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/cluster.py +0 -0
  51. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/corpus.py +0 -0
  52. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/embedder.py +0 -0
  53. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/groups.py +0 -0
  54. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  55. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  56. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  57. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/cli_tools_kit.egg-info/top_level.txt +0 -0
  58. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/setup.cfg +0 -0
  59. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_autostart_gate.py +0 -0
  60. {cli_tools_kit-0.8.3 → cli_tools_kit-1.0.0}/tests/test_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 0.8.3
3
+ Version: 1.0.0
4
4
  Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
5
5
  Author: Steffen Probst
6
6
  License: MIT License
@@ -26,18 +26,21 @@ License: MIT License
26
26
  SOFTWARE.
27
27
 
28
28
  Project-URL: Homepage, https://github.com/Probst1nator/cli-tools-kit
29
+ Project-URL: Changelog, https://github.com/Probst1nator/cli-tools-kit/blob/main/CHANGELOG.md
29
30
  Project-URL: Issues, https://github.com/Probst1nator/cli-tools-kit/issues
30
- Keywords: installer,desktop,cron,cli,linux,kde,gnome
31
+ Keywords: installer,desktop,cron,cli,linux,windows,kde,gnome
31
32
  Classifier: Development Status :: 4 - Beta
32
33
  Classifier: Environment :: Console
33
34
  Classifier: Intended Audience :: Developers
34
35
  Classifier: License :: OSI Approved :: MIT License
35
36
  Classifier: Operating System :: POSIX :: Linux
37
+ Classifier: Operating System :: Microsoft :: Windows
36
38
  Classifier: Programming Language :: Python :: 3
37
39
  Classifier: Programming Language :: Python :: 3 :: Only
38
40
  Classifier: Programming Language :: Python :: 3.10
39
41
  Classifier: Programming Language :: Python :: 3.11
40
42
  Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
41
44
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
45
  Classifier: Topic :: System :: Installation/Setup
43
46
  Requires-Python: >=3.10
@@ -47,6 +50,10 @@ Requires-Dist: termcolor>=2.0
47
50
  Requires-Dist: tomli>=2.0; python_version < "3.11"
48
51
  Provides-Extra: dev
49
52
  Requires-Dist: pytest>=7.0; extra == "dev"
53
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
54
+ Requires-Dist: ruff>=0.6; extra == "dev"
55
+ Requires-Dist: build>=1.0; extra == "dev"
56
+ Requires-Dist: twine>=5.0; extra == "dev"
50
57
  Provides-Extra: gui
51
58
  Requires-Dist: Pillow>=9.0; extra == "gui"
52
59
  Dynamic: license-file
@@ -88,17 +95,42 @@ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
88
95
  pip install cli-tools-kit
89
96
  ```
90
97
 
91
- Or pin in `requirements.txt`:
98
+ Or pin a range in `requirements.txt`:
92
99
 
93
100
  ```
94
- cli-tools-kit==0.6.0
101
+ cli-tools-kit>=1.0,<2
95
102
  ```
96
103
 
104
+ Pin a range, not an exact version. An installer adds the kit to each tool's
105
+ own `pip install -r requirements.txt`, and two exact pins of one package on
106
+ one pip command line (`==0.6.0` from the tool, `==0.6.3` from the installer)
107
+ can never resolve. Two overlapping ranges always do.
108
+
97
109
  The git URL form still works if you need an unreleased commit:
98
- `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v0.6.0`.
110
+ `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v1.0.0`.
111
+
112
+ Requires Python ≥ 3.10 and `termcolor` (installed with the package). Linux
113
+ and Windows are tested in CI.
114
+
115
+ ## Stability
116
+
117
+ From 1.0.0 the kit follows [Semantic Versioning](https://semver.org/). The
118
+ public surface is what `tests/test_public_api.py` pins: the names in
119
+ `cli_tools_kit.__all__`, `ToolInstaller`/`ToolMetadata`/`CronInstaller` and
120
+ their methods, `gui_installer.run()` and its keywords, `InstallHooks`,
121
+ `sources.run_installer()`, `tui_installer.SkillTarget`,
122
+ `testing.assert_advertises()`, the taxonomy
123
+ functions a tree's regroup script calls, the installer's command-line flags,
124
+ and the `--advertise` JSON in [`PROTOCOL.md`](PROTOCOL.md).
99
125
 
100
- Requires Python ≥ 3.10. Optional runtime dep: `termcolor` (colored
101
- install/remove output; falls back to plain text if absent).
126
+ - Removing or renaming anything there is a major release.
127
+ - A name that is going away warns for at least one minor release first.
128
+ - The `--advertise` schema only gains optional fields; a parent installer
129
+ ignores fields it does not know.
130
+ - Anything with a leading underscore, and every module-level name not listed
131
+ above, can change in any release.
132
+
133
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md).
102
134
 
103
135
  ## Minimal example
104
136
 
@@ -272,6 +304,7 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
272
304
  | `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
273
305
  | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
274
306
  | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
307
+ | `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
275
308
 
276
309
  The identity is applied first and these individual names override it, so you can
277
310
  take the whole namespace from a slug and still change one thing.
@@ -376,7 +409,7 @@ and needs no network.
376
409
  Icon thumbnails need Pillow:
377
410
 
378
411
  ```bash
379
- pip install "cli-tools-kit[gui]==0.6.0"
412
+ pip install "cli-tools-kit[gui]>=1.0,<2"
380
413
  ```
381
414
 
382
415
  Installing the package also exposes a `cli-tool-installer` console script.
@@ -528,13 +561,33 @@ per repo it could not (`log=` takes any callable, `print` by default). Pass
528
561
  Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
529
562
  on 3.10.
530
563
 
564
+ ## Testing a tool's `--advertise`
565
+
566
+ A tool that breaks the protocol is skipped by the parent installer and simply
567
+ disappears from the list. Put this in the tool's own tests to catch that:
568
+
569
+ ```python
570
+ from cli_tools_kit.testing import assert_advertises
571
+
572
+ def test_advertise():
573
+ assert_advertises("main.py")
574
+ ```
575
+
576
+ It runs the probe the way a parent does (5 s limit, only JSON on stdout) and
577
+ fails with every problem it finds.
578
+
531
579
  ## Tests
532
580
 
533
581
  ```bash
534
- pip install -e ".[dev]"
535
- pytest
582
+ pip install -e ".[dev,gui]"
583
+ ruff check .
584
+ pytest # xvfb-run -a pytest on a host without a display
536
585
  ```
537
586
 
587
+ CI runs the same on Linux and Windows for Python 3.10 to 3.13, plus an
588
+ install from the built wheel. The GUI smoke test skips where tkinter or a
589
+ display is missing.
590
+
538
591
  ## Used by
539
592
 
540
593
  Consumers, each a self-installing tool that answers `--advertise`:
@@ -35,17 +35,42 @@ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
35
35
  pip install cli-tools-kit
36
36
  ```
37
37
 
38
- Or pin in `requirements.txt`:
38
+ Or pin a range in `requirements.txt`:
39
39
 
40
40
  ```
41
- cli-tools-kit==0.6.0
41
+ cli-tools-kit>=1.0,<2
42
42
  ```
43
43
 
44
+ Pin a range, not an exact version. An installer adds the kit to each tool's
45
+ own `pip install -r requirements.txt`, and two exact pins of one package on
46
+ one pip command line (`==0.6.0` from the tool, `==0.6.3` from the installer)
47
+ can never resolve. Two overlapping ranges always do.
48
+
44
49
  The git URL form still works if you need an unreleased commit:
45
- `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v0.6.0`.
50
+ `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v1.0.0`.
51
+
52
+ Requires Python ≥ 3.10 and `termcolor` (installed with the package). Linux
53
+ and Windows are tested in CI.
54
+
55
+ ## Stability
56
+
57
+ From 1.0.0 the kit follows [Semantic Versioning](https://semver.org/). The
58
+ public surface is what `tests/test_public_api.py` pins: the names in
59
+ `cli_tools_kit.__all__`, `ToolInstaller`/`ToolMetadata`/`CronInstaller` and
60
+ their methods, `gui_installer.run()` and its keywords, `InstallHooks`,
61
+ `sources.run_installer()`, `tui_installer.SkillTarget`,
62
+ `testing.assert_advertises()`, the taxonomy
63
+ functions a tree's regroup script calls, the installer's command-line flags,
64
+ and the `--advertise` JSON in [`PROTOCOL.md`](PROTOCOL.md).
46
65
 
47
- Requires Python ≥ 3.10. Optional runtime dep: `termcolor` (colored
48
- install/remove output; falls back to plain text if absent).
66
+ - Removing or renaming anything there is a major release.
67
+ - A name that is going away warns for at least one minor release first.
68
+ - The `--advertise` schema only gains optional fields; a parent installer
69
+ ignores fields it does not know.
70
+ - Anything with a leading underscore, and every module-level name not listed
71
+ above, can change in any release.
72
+
73
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md).
49
74
 
50
75
  ## Minimal example
51
76
 
@@ -219,6 +244,7 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
219
244
  | `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
220
245
  | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
221
246
  | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
247
+ | `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
222
248
 
223
249
  The identity is applied first and these individual names override it, so you can
224
250
  take the whole namespace from a slug and still change one thing.
@@ -323,7 +349,7 @@ and needs no network.
323
349
  Icon thumbnails need Pillow:
324
350
 
325
351
  ```bash
326
- pip install "cli-tools-kit[gui]==0.6.0"
352
+ pip install "cli-tools-kit[gui]>=1.0,<2"
327
353
  ```
328
354
 
329
355
  Installing the package also exposes a `cli-tool-installer` console script.
@@ -475,13 +501,33 @@ per repo it could not (`log=` takes any callable, `print` by default). Pass
475
501
  Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
476
502
  on 3.10.
477
503
 
504
+ ## Testing a tool's `--advertise`
505
+
506
+ A tool that breaks the protocol is skipped by the parent installer and simply
507
+ disappears from the list. Put this in the tool's own tests to catch that:
508
+
509
+ ```python
510
+ from cli_tools_kit.testing import assert_advertises
511
+
512
+ def test_advertise():
513
+ assert_advertises("main.py")
514
+ ```
515
+
516
+ It runs the probe the way a parent does (5 s limit, only JSON on stdout) and
517
+ fails with every problem it finds.
518
+
478
519
  ## Tests
479
520
 
480
521
  ```bash
481
- pip install -e ".[dev]"
482
- pytest
522
+ pip install -e ".[dev,gui]"
523
+ ruff check .
524
+ pytest # xvfb-run -a pytest on a host without a display
483
525
  ```
484
526
 
527
+ CI runs the same on Linux and Windows for Python 3.10 to 3.13, plus an
528
+ install from the built wheel. The GUI smoke test skips where tkinter or a
529
+ display is missing.
530
+
485
531
  ## Used by
486
532
 
487
533
  Consumers, each a self-installing tool that answers `--advertise`:
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.8.3"
59
+ __version__ = "1.0.0"
@@ -0,0 +1,284 @@
1
+ """Starting tools at login (autostart entry or cron) and the login update check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ from typing import Optional
11
+
12
+ from . import discovery
13
+ from . import host
14
+ from . import state
15
+ from .autostart_gate import build_exec_prefix, load_tool_conditions, save_tool_conditions
16
+ from .cron_installer import read_crontab
17
+
18
+
19
+ def autostart_check_enabled() -> bool:
20
+ return os.path.exists(state.AUTOSTART_CHECK_DESKTOP)
21
+
22
+
23
+ def enable_autostart_check() -> str:
24
+ """Write the login update-check autostart entry. Returns its path."""
25
+ os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
26
+
27
+ if host.IS_WINDOWS:
28
+ # The Startup folder runs shortcuts; a .desktop file dropped there is
29
+ # never executed. pythonw keeps the console window from flashing up at
30
+ # every login, since the check reports through a notification anyway.
31
+ runner = sys.executable
32
+ windowless = os.path.join(os.path.dirname(runner), "pythonw.exe")
33
+ if os.path.exists(windowless):
34
+ runner = windowless
35
+ host.write_shortcut(
36
+ state.AUTOSTART_CHECK_DESKTOP,
37
+ target=runner,
38
+ args=f'"{state.ENTRY_SCRIPT}" --check',
39
+ workdir=os.path.dirname(state.ENTRY_SCRIPT),
40
+ )
41
+ return state.AUTOSTART_CHECK_DESKTOP
42
+
43
+ exec_line = f"{sys.executable} {state.ENTRY_SCRIPT} --check"
44
+ content = (
45
+ "[Desktop Entry]\n"
46
+ "Type=Application\n"
47
+ f"Name={state.SELF_DESKTOP_NAME} — login update check\n"
48
+ "Comment=Apply network-free tool reconciliations on login; notify for new tools\n"
49
+ f"Exec={exec_line}\n"
50
+ "Icon=system-software-update\n"
51
+ "Terminal=false\n"
52
+ "NoDisplay=true\n"
53
+ "X-KDE-autostart-after=panel\n"
54
+ "X-GNOME-Autostart-enabled=true\n"
55
+ )
56
+ with open(state.AUTOSTART_CHECK_DESKTOP, "w") as f:
57
+ f.write(content)
58
+ return state.AUTOSTART_CHECK_DESKTOP
59
+
60
+
61
+ def disable_autostart_check() -> bool:
62
+ """Remove the login update-check autostart entry. True if one was present."""
63
+ removed = False
64
+ # On Windows, also clear the .desktop an older version wrote into the
65
+ # Startup folder, where it sat inert instead of running the check.
66
+ stale = os.path.join(state.AUTOSTART_DIR, state.AUTOSTART_CHECK_DESKTOP_NAME)
67
+ for path in {state.AUTOSTART_CHECK_DESKTOP, stale}:
68
+ if os.path.exists(path):
69
+ os.remove(path)
70
+ removed = True
71
+ return removed
72
+
73
+
74
+ # ================= AUTOSTART UTILITIES =================
75
+
76
+ def get_autostart_path(tool: discovery.ToolEntry) -> str:
77
+ """Where a tool's autostart entry lives: a .desktop symlink in
78
+ ~/.config/autostart, or a copy of its .lnk in the Startup folder."""
79
+ if host.IS_WINDOWS:
80
+ return os.path.join(state.AUTOSTART_DIR,
81
+ os.path.splitext(tool.desktop_file)[0] + ".lnk")
82
+ return os.path.join(state.AUTOSTART_DIR, tool.desktop_file)
83
+
84
+
85
+ def _cron_line_for_tool(tool: discovery.ToolEntry) -> str:
86
+ """Build the crontab line for a cron-based tool."""
87
+ parts = [tool.cron_schedule, sys.executable, tool.script_path] + list(tool.cron_args)
88
+ return " ".join(parts)
89
+
90
+
91
+ def _cron_contains(line: str) -> bool:
92
+ try:
93
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
94
+ return result.returncode == 0 and line in result.stdout
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def is_autostart_enabled(tool: discovery.ToolEntry) -> bool:
100
+ """Check if autostart is enabled for a tool."""
101
+ if "Icon" in tool.tags:
102
+ return os.path.exists(get_autostart_path(tool))
103
+ if tool.cron_schedule:
104
+ return _cron_contains(_cron_line_for_tool(tool))
105
+ return False
106
+
107
+
108
+ def autostart_tool_key(tool: discovery.ToolEntry) -> str:
109
+ """The key a tool's conditions are stored under.
110
+
111
+ The .desktop stem: stable across renames of the display name, and already
112
+ unique per installed shortcut.
113
+ """
114
+ return os.path.splitext(tool.desktop_file)[0]
115
+
116
+
117
+ def get_autostart_conditions(tool: discovery.ToolEntry) -> dict:
118
+ """The conditions currently configured for *tool* on this host."""
119
+ return load_tool_conditions(state.IDENTITY.slug, autostart_tool_key(tool))
120
+
121
+
122
+ def set_autostart_conditions(tool: discovery.ToolEntry, conditions: Optional[dict]) -> None:
123
+ """Store *tool*'s conditions, then rewrite its entry if autostart is on.
124
+
125
+ The Exec line differs between a gated and an ungated entry, so a change
126
+ here only takes effect once the entry is rewritten.
127
+ """
128
+ save_tool_conditions(state.IDENTITY.slug, autostart_tool_key(tool), conditions)
129
+ if is_autostart_enabled(tool):
130
+ enable_autostart(tool)
131
+
132
+
133
+ def _read_desktop_exec(desktop_path: str) -> str:
134
+ """The Exec= line of an installed .desktop, or "" if it has none."""
135
+ try:
136
+ with open(desktop_path, "r", encoding="utf-8") as fh:
137
+ for line in fh:
138
+ if line.startswith("Exec="):
139
+ return line[len("Exec="):].strip()
140
+ except OSError:
141
+ pass
142
+ return ""
143
+
144
+
145
+ def _write_gated_autostart(tool: discovery.ToolEntry, desktop_path: str,
146
+ autostart_path: str, conditions: dict) -> tuple[bool, str]:
147
+ """Write an autostart .desktop whose Exec runs *tool* through the gate.
148
+
149
+ A real file rather than the usual symlink: the app entry in the menu must
150
+ keep launching the tool unconditionally, so only this copy carries the
151
+ gate. Everything else is inherited from the installed entry.
152
+ """
153
+ exec_line = _read_desktop_exec(desktop_path)
154
+ if not exec_line:
155
+ return False, f"No Exec line in {desktop_path}"
156
+
157
+ prefix = " ".join(shlex.quote(p) for p in
158
+ build_exec_prefix(state.IDENTITY.slug, autostart_tool_key(tool)))
159
+ gated_exec = f"{prefix} {exec_line}"
160
+
161
+ try:
162
+ with open(desktop_path, "r", encoding="utf-8") as fh:
163
+ lines = fh.read().splitlines()
164
+ except OSError as e:
165
+ return False, f"Failed to read {desktop_path}: {e}"
166
+
167
+ out = []
168
+ for line in lines:
169
+ if line.startswith("Exec="):
170
+ out.append(f"Exec={gated_exec}")
171
+ elif line.startswith("X-CliToolsKit-Gated="):
172
+ continue
173
+ else:
174
+ out.append(line)
175
+ # Marks the entry as ours and generated, so it is obvious in a diff why
176
+ # this one is a file where every other autostart entry is a symlink.
177
+ out.append("X-CliToolsKit-Gated=true")
178
+
179
+ try:
180
+ if os.path.exists(autostart_path) or os.path.islink(autostart_path):
181
+ os.remove(autostart_path)
182
+ with open(autostart_path, "w", encoding="utf-8") as fh:
183
+ fh.write("\n".join(out) + "\n")
184
+ # Deliberately not executable: systemd-xdg-autostart-generator warns
185
+ # on every login about an executable entry in ~/.config/autostart.
186
+ exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
187
+ os.chmod(autostart_path, os.stat(autostart_path).st_mode & ~exec_bits)
188
+ except OSError as e:
189
+ return False, f"Failed to write {autostart_path}: {e}"
190
+
191
+ return True, f"Autostart enabled (conditional): {tool.name}"
192
+
193
+
194
+ def enable_autostart(tool: discovery.ToolEntry) -> tuple[bool, str]:
195
+ """Enable autostart for a tool.
196
+
197
+ Icon tools: create a .desktop symlink in ~/.config/autostart.
198
+ Cron tools: add an @reboot (or other schedule) crontab entry.
199
+
200
+ Returns (success, message).
201
+ """
202
+ if "Icon" in tool.tags:
203
+ if host.IS_WINDOWS:
204
+ stem = os.path.splitext(tool.desktop_file)[0]
205
+ lnk_path = os.path.join(state.APPS_DIR, stem + ".lnk")
206
+ if not os.path.exists(lnk_path):
207
+ return False, f"Shortcut not found: {lnk_path}"
208
+ os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
209
+ try:
210
+ import shutil
211
+ shutil.copyfile(lnk_path, get_autostart_path(tool))
212
+ return True, f"Autostart enabled: {tool.name}"
213
+ except OSError as e:
214
+ return False, f"Failed to copy shortcut: {e}"
215
+
216
+ desktop_path = os.path.join(state.APPS_DIR, tool.desktop_file)
217
+ if not os.path.exists(desktop_path):
218
+ return False, f"Desktop file not found: {desktop_path}"
219
+
220
+ os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
221
+ autostart_path = get_autostart_path(tool)
222
+
223
+ # A tool with conditions configured gets a gated copy instead of the
224
+ # plain symlink, so the menu entry stays unconditional.
225
+ conditions = get_autostart_conditions(tool)
226
+ if conditions:
227
+ return _write_gated_autostart(tool, desktop_path, autostart_path, conditions)
228
+
229
+ if os.path.exists(autostart_path) or os.path.islink(autostart_path):
230
+ os.remove(autostart_path)
231
+
232
+ try:
233
+ os.symlink(desktop_path, autostart_path)
234
+ return True, f"Autostart enabled: {tool.name}"
235
+ except OSError as e:
236
+ return False, f"Failed to create symlink: {e}"
237
+
238
+ if tool.cron_schedule:
239
+ if host.IS_WINDOWS:
240
+ return False, "Cron autostart is not supported on Windows"
241
+ line = _cron_line_for_tool(tool)
242
+ try:
243
+ existing = read_crontab() # raises rather than read a failure as empty
244
+ if line in existing:
245
+ return True, "Cron entry already present"
246
+ new_crontab = existing.rstrip("\n") + ("\n" if existing else "") + line + "\n"
247
+ subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
248
+ return True, f"Cron entry added: {line}"
249
+ except Exception as e:
250
+ return False, f"Failed to add cron entry: {e}"
251
+
252
+ return False, "Tool has no supported autostart method"
253
+
254
+
255
+ def disable_autostart(tool: discovery.ToolEntry) -> tuple[bool, str]:
256
+ """Disable autostart for a tool.
257
+
258
+ Returns (success, message).
259
+ """
260
+ if "Icon" in tool.tags:
261
+ autostart_path = get_autostart_path(tool)
262
+ if not os.path.exists(autostart_path) and not os.path.islink(autostart_path):
263
+ return True, "Already disabled"
264
+ try:
265
+ os.remove(autostart_path)
266
+ return True, f"Autostart disabled: {tool.name}"
267
+ except OSError as e:
268
+ return False, f"Failed to remove {'shortcut' if host.IS_WINDOWS else 'symlink'}: {e}"
269
+
270
+ if tool.cron_schedule:
271
+ if host.IS_WINDOWS:
272
+ return False, "Cron autostart is not supported on Windows"
273
+ line = _cron_line_for_tool(tool)
274
+ try:
275
+ existing = read_crontab()
276
+ if line not in existing:
277
+ return True, "Already disabled"
278
+ new_crontab = "\n".join(l for l in existing.splitlines() if l != line) + "\n"
279
+ subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
280
+ return True, f"Cron entry removed: {tool.name}"
281
+ except Exception as e:
282
+ return False, f"Failed to remove cron entry: {e}"
283
+
284
+ return True, "Already disabled"