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.
- cli_tools_kit/__init__.py +59 -0
- cli_tools_kit/__main__.py +43 -0
- cli_tools_kit/advertise.py +77 -0
- cli_tools_kit/cron_installer.py +159 -0
- cli_tools_kit/gui_installer.py +5928 -0
- cli_tools_kit/host.py +239 -0
- cli_tools_kit/identity.py +229 -0
- cli_tools_kit/onboarding.py +261 -0
- cli_tools_kit/skills.py +97 -0
- cli_tools_kit/sources.py +335 -0
- cli_tools_kit/taxonomy/__init__.py +40 -0
- cli_tools_kit/taxonomy/build.py +171 -0
- cli_tools_kit/taxonomy/capability.py +116 -0
- cli_tools_kit/taxonomy/cluster.py +261 -0
- cli_tools_kit/taxonomy/corpus.py +268 -0
- cli_tools_kit/taxonomy/embedder.py +170 -0
- cli_tools_kit/taxonomy/groups.py +108 -0
- cli_tools_kit/taxonomy/llm_groups.py +748 -0
- cli_tools_kit/tool_installer.py +572 -0
- cli_tools_kit/tui_installer.py +462 -0
- cli_tools_kit-0.6.0.dist-info/METADATA +505 -0
- cli_tools_kit-0.6.0.dist-info/RECORD +26 -0
- cli_tools_kit-0.6.0.dist-info/WHEEL +5 -0
- cli_tools_kit-0.6.0.dist-info/entry_points.txt +3 -0
- cli_tools_kit-0.6.0.dist-info/licenses/LICENSE +21 -0
- cli_tools_kit-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -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
|