cli-tools-kit 0.6.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.
@@ -0,0 +1,59 @@
1
+ """cli-tools-kit — installer protocol + helpers for self-installing Python CLI/GUI tools.
2
+
3
+ Public API:
4
+
5
+ from cli_tools_kit import (
6
+ ToolInstaller, ToolMetadata, # desktop file / bash alias install/remove
7
+ CronInstaller, # idempotent cron-line management
8
+ advertise, # --advertise JSON helper
9
+ skill_status, # is an installed Claude skill stale?
10
+ InstallerIdentity, # which names your installer claims on a host
11
+ )
12
+
13
+ The GUI installer engine is a submodule, since importing it pulls in tkinter:
14
+
15
+ from cli_tools_kit.gui_installer import run
16
+
17
+ run(identity=InstallerIdentity(slug="acme-tools"), root_dir=HERE,
18
+ entry_script=__file__)
19
+
20
+ Tools that live in several repos are listed in an installer.toml and resolved
21
+ by cli_tools_kit.sources:
22
+
23
+ from cli_tools_kit.sources import run_installer
24
+
25
+ run_installer("installer.toml", identity=InstallerIdentity(slug="acme-tools"),
26
+ entry_script=__file__)
27
+
28
+ See README.md § Reusing the installer in your org for the full run() signature,
29
+ README.md § Sources for the TOML format, and `python3 -m cli_tools_kit` to be
30
+ walked through the setup.
31
+
32
+ See PROTOCOL.md for the full --advertise specification.
33
+ """
34
+
35
+ from .tool_installer import ToolInstaller, ToolMetadata
36
+ from .identity import InstallerIdentity, LEGACY_IDENTITY
37
+ from .cron_installer import CronInstaller
38
+ from .advertise import advertise
39
+ from .skills import (
40
+ installed_skill_hash,
41
+ read_installed_skill,
42
+ skill_payload_hash,
43
+ skill_status,
44
+ )
45
+
46
+ __all__ = [
47
+ "ToolInstaller",
48
+ "ToolMetadata",
49
+ "InstallerIdentity",
50
+ "LEGACY_IDENTITY",
51
+ "CronInstaller",
52
+ "advertise",
53
+ "skill_status",
54
+ "skill_payload_hash",
55
+ "installed_skill_hash",
56
+ "read_installed_skill",
57
+ ]
58
+
59
+ __version__ = "0.6.0"
@@ -0,0 +1,43 @@
1
+ """``python3 -m cli_tools_kit`` — set up an installer for your organisation.
2
+
3
+ Two surfaces:
4
+
5
+ python3 -m cli_tools_kit first-run setup (the default)
6
+ python3 -m cli_tools_kit install FILE [flags] run the installer for a
7
+ sources file (see sources.py)
8
+
9
+ ``install`` takes the path of an ``installer.toml`` and passes every remaining
10
+ flag to the installer engine. It uses the default installer identity, so an
11
+ organisation that wants its own namespace on the host writes the short
12
+ ``installer.py`` in README § Sources instead and runs that.
13
+ """
14
+
15
+ import os
16
+ import sys
17
+
18
+ from .onboarding import main
19
+
20
+ USAGE = "usage: python3 -m cli_tools_kit install <installer.toml> [engine flags]"
21
+
22
+
23
+ def _install(argv):
24
+ """The ``install`` subcommand: run the installer for one sources file."""
25
+ if not argv or argv[0].startswith("-"):
26
+ print(USAGE, file=sys.stderr)
27
+ return 2
28
+ config = argv[0]
29
+ if not os.path.isfile(config):
30
+ print(f"{config}: no such file\n{USAGE}", file=sys.stderr)
31
+ return 2
32
+ from .sources import run_installer # noqa: PLC0415 — imports the engine
33
+ # ENTRY_SCRIPT is what the manager shortcut and the login check re-enter.
34
+ # The wrapper next to the sources file is the right one when there is one.
35
+ wrapper = os.path.join(os.path.dirname(os.path.abspath(config)), "installer.py")
36
+ entry = wrapper if os.path.isfile(wrapper) else os.path.abspath(sys.argv[0])
37
+ return run_installer(config, argv=argv[1:], entry_script=entry)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ if len(sys.argv) > 1 and sys.argv[1] == "install":
42
+ sys.exit(_install(sys.argv[2:]))
43
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ """Helper for the `--advertise` JSON convention.
2
+
3
+ Every tool that opts into the installer protocol must emit a JSON list of
4
+ metadata dicts when invoked with `--advertise`, then exit 0, BEFORE any heavy
5
+ imports. This is enforced by a 5-second timeout in the installer's discovery
6
+ probe; tools that import slow modules before answering the probe time out and
7
+ disappear from the GUI.
8
+
9
+ Usage:
10
+
11
+ # At the very top of your tool's main script:
12
+ import sys
13
+ from cli_tools_kit import ToolMetadata, advertise
14
+
15
+ if "--advertise" in sys.argv:
16
+ advertise(ToolMetadata(
17
+ name="My Tool",
18
+ desktop_file="my_tool.desktop",
19
+ icon="utilities-terminal",
20
+ desc="Does the thing",
21
+ tags=["CLI"],
22
+ alias="mytool",
23
+ ))
24
+
25
+ # Heavy imports AFTER the advertise check
26
+ import pandas as pd
27
+ ...
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import sys
35
+ from typing import List, NoReturn, Union
36
+
37
+ from .tool_installer import ToolMetadata
38
+
39
+
40
+ def advertise(metadata: Union[ToolMetadata, List[ToolMetadata]]) -> NoReturn:
41
+ """Emit the --advertise JSON for one or more variants, then exit 0.
42
+
43
+ Call this inside an `if "--advertise" in sys.argv:` guard at the top of
44
+ your tool's main script. Heavy imports must come after the guard so the
45
+ installer's 5-second discovery probe doesn't time out.
46
+
47
+ The output schema includes `tags` (defaults to `["GUI", "Icon"]` when
48
+ unset) and `alias` (only when the tool is CLI-only or explicitly sets one).
49
+ """
50
+ items = list(metadata) if isinstance(metadata, (list, tuple)) else [metadata]
51
+ out = []
52
+ for m in items:
53
+ tags = m.tags if m.tags else ["GUI", "Icon"]
54
+ record = {
55
+ "name": m.name,
56
+ "desktop_file": m.desktop_file,
57
+ "icon": m.icon,
58
+ "desc": m.desc,
59
+ "terminal": m.terminal,
60
+ "args": m.args or [],
61
+ "tags": tags,
62
+ }
63
+ # alias is surfaced when the tool is CLI-only (no Icon tag) OR when
64
+ # an Icon tool explicitly opts into a shell alias alongside the .desktop.
65
+ if "Icon" not in tags or m.alias:
66
+ record["alias"] = m.alias or os.path.splitext(m.desktop_file)[0]
67
+ if m.alias_args is not None:
68
+ record["alias_args"] = list(m.alias_args)
69
+ # Optional fields are emitted only when set, so a tool that never
70
+ # touched them produces exactly the pre-0.2.0 record.
71
+ for key in ("capability", "domain", "category", "skill_name", "skill_status"):
72
+ value = getattr(m, key, None)
73
+ if value:
74
+ record[key] = value
75
+ out.append(record)
76
+ print(json.dumps(out))
77
+ sys.exit(0)
@@ -0,0 +1,159 @@
1
+ """Atomic, idempotent cron-line management with marker comments.
2
+
3
+ Each CronInstaller instance owns a unique marker string. `install()` rewrites
4
+ the crontab so any pre-existing lines bearing this marker are stripped, then
5
+ the new lines are appended with the marker as a trailing comment. Other tools'
6
+ cron lines (with different markers or none at all) are left untouched.
7
+
8
+ Example:
9
+
10
+ from cli_tools_kit import CronInstaller
11
+
12
+ cron = CronInstaller("studon-client")
13
+ cron.install([
14
+ f"@reboot cd {script_dir} && {python} {script} --daily-sync",
15
+ f"@reboot cd {script_dir} && {python} {script} --lecture-sync",
16
+ ])
17
+ # ... later
18
+ cron.remove()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+ from typing import List
25
+
26
+ try:
27
+ from termcolor import colored
28
+ except ImportError:
29
+ def colored(text: str, *_args, **_kwargs) -> str:
30
+ return text
31
+
32
+
33
+ # The marker on real crontabs since the first release. It keeps the old
34
+ # project name on purpose: renaming it would leave every already-installed
35
+ # cron line unmatched by remove().
36
+ _TAG_PREFIX = "# cli-tool-kit:"
37
+
38
+
39
+ class CronInstaller:
40
+ """Manage a tool's cron lines via a unique marker.
41
+
42
+ A trailing comment of the form `# cli-tool-kit:<marker>` is appended to
43
+ each managed line. install() and remove() match on this tag, so lines
44
+ added by other tools (or hand-edited entries) survive unchanged.
45
+ """
46
+
47
+ # Class attribute so tests can swap in a fake crontab shim without
48
+ # monkeypatching subprocess.
49
+ CRONTAB_BIN: str = "crontab"
50
+
51
+ def __init__(self, marker: str) -> None:
52
+ if not marker or "\n" in marker or "#" in marker:
53
+ raise ValueError(
54
+ "marker must be non-empty and contain no '\\n' or '#' characters"
55
+ )
56
+ self.marker = marker
57
+ self.tag = f"{_TAG_PREFIX}{marker}"
58
+
59
+ # ---- crontab I/O ----
60
+
61
+ def _read(self) -> str:
62
+ """Return current crontab contents, or '' if there is no crontab.
63
+
64
+ `crontab -l` exits 1 with "no crontab for <user>" on stderr when the
65
+ user simply has no crontab — that maps to ''. Other non-zero exits
66
+ (binary missing, permission denied, transient failure) must NOT be
67
+ silently coerced to '', because that would cause a subsequent
68
+ `install([...])` to write a fresh crontab containing only the new
69
+ lines, wiping any pre-existing entries.
70
+ """
71
+ try:
72
+ result = subprocess.run(
73
+ [self.CRONTAB_BIN, "-l"], capture_output=True, text=True
74
+ )
75
+ except FileNotFoundError as e:
76
+ raise RuntimeError(f"crontab binary not found: {self.CRONTAB_BIN}") from e
77
+ if result.returncode == 0:
78
+ return result.stdout
79
+ if "no crontab" in (result.stderr or "").lower():
80
+ return ""
81
+ raise RuntimeError(
82
+ f"crontab -l failed (rc={result.returncode}): {result.stderr.strip()}"
83
+ )
84
+
85
+ def _write(self, content: str) -> None:
86
+ """Replace the crontab with the given content."""
87
+ if content and not content.endswith("\n"):
88
+ content += "\n"
89
+ subprocess.run(
90
+ [self.CRONTAB_BIN, "-"], input=content, text=True, check=True
91
+ )
92
+
93
+ def _strip_marked(self, crontab: str) -> List[str]:
94
+ """Return crontab lines not bearing this installer's tag."""
95
+ return [line for line in crontab.splitlines() if self.tag not in line]
96
+
97
+ # ---- public API ----
98
+
99
+ def install(self, lines: List[str]) -> None:
100
+ """Install (or replace) cron lines for this marker.
101
+
102
+ Strips any existing lines bearing our tag, then appends each given line
103
+ suffixed with the tag. Calling install() twice with the same lines
104
+ leaves the crontab in the same final state.
105
+ """
106
+ for line in lines:
107
+ if "\n" in line:
108
+ raise ValueError("cron lines must not contain '\\n'")
109
+ if self.tag in line:
110
+ raise ValueError(
111
+ "cron lines must not include the tag (it is appended automatically)"
112
+ )
113
+
114
+ existing = self._strip_marked(self._read())
115
+ # Trim trailing blanks so our block sits cleanly at the end.
116
+ while existing and not existing[-1].strip():
117
+ existing.pop()
118
+
119
+ tagged = [f"{line} {self.tag}" for line in lines]
120
+ self._write("\n".join(existing + tagged))
121
+
122
+ for line in tagged:
123
+ print(colored(f"Cron line installed: {line}", "green"))
124
+
125
+ def remove(self) -> None:
126
+ """Remove all cron lines bearing this installer's tag."""
127
+ original = self._read()
128
+ if not original:
129
+ print(colored(f"No crontab present (marker '{self.marker}').", "yellow"))
130
+ return
131
+
132
+ kept = self._strip_marked(original)
133
+ removed_count = len(original.splitlines()) - len(kept)
134
+ if removed_count == 0:
135
+ print(colored(
136
+ f"No cron lines to remove for marker '{self.marker}'.", "yellow"
137
+ ))
138
+ return
139
+
140
+ self._write("\n".join(kept))
141
+ print(colored(
142
+ f"Removed {removed_count} cron line(s) for marker '{self.marker}'.",
143
+ "green",
144
+ ))
145
+
146
+ def is_installed(self) -> bool:
147
+ """Return True iff any crontab line bears this installer's tag."""
148
+ return self.tag in self._read()
149
+
150
+ def installed_lines(self) -> List[str]:
151
+ """Return the currently-installed lines (without the trailing tag)."""
152
+ out: List[str] = []
153
+ for line in self._read().splitlines():
154
+ if self.tag not in line:
155
+ continue
156
+ # Strip the tag and any whitespace that preceded it.
157
+ body = line.split(self.tag)[0].rstrip()
158
+ out.append(body)
159
+ return out