toolrack-cli 0.1.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.
- toolrack/__init__.py +3 -0
- toolrack/__main__.py +4 -0
- toolrack/cli.py +946 -0
- toolrack/completion.py +351 -0
- toolrack/entrypoint.py +14 -0
- toolrack/runtime.py +178 -0
- toolrack_cli-0.1.0.dist-info/METADATA +201 -0
- toolrack_cli-0.1.0.dist-info/RECORD +12 -0
- toolrack_cli-0.1.0.dist-info/WHEEL +5 -0
- toolrack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- toolrack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- toolrack_cli-0.1.0.dist-info/top_level.txt +1 -0
toolrack/completion.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Shell-completion fast path that answers from the metadata cache.
|
|
2
|
+
|
|
3
|
+
Importing Click and building the dynamic command tree dominates the latency of
|
|
4
|
+
a <TAB> press. When the command-metadata cache is current, the answer can be
|
|
5
|
+
computed from the cached entries alone, using only the standard library.
|
|
6
|
+
|
|
7
|
+
Design rules:
|
|
8
|
+
|
|
9
|
+
* The fast path never *guesses*. Anything it does not model exactly (unknown
|
|
10
|
+
tokens, option bundles, hidden alias groups, a stale or missing cache, ...)
|
|
11
|
+
returns ``None`` and the request is handled by Click as before.
|
|
12
|
+
* Repository discovery and cache validation come from ``toolrack.runtime`` so
|
|
13
|
+
they cannot drift from the CLI.
|
|
14
|
+
* ``tests/test_completion.py`` runs both implementations side by side and
|
|
15
|
+
requires byte-identical output, so Click upgrades that change completion
|
|
16
|
+
behaviour fail loudly instead of silently diverging.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import shlex
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
from toolrack import runtime
|
|
27
|
+
|
|
28
|
+
Completion = tuple[str, str, str] # (type, value, help)
|
|
29
|
+
|
|
30
|
+
_MODES = {"bash_complete", "zsh_complete", "fish_complete"}
|
|
31
|
+
_HELP = ("--help", "Show this message and exit.")
|
|
32
|
+
_VERSION = ("--version", "Show the version and exit.")
|
|
33
|
+
|
|
34
|
+
# Mirrors the `core` group in toolrack.cli; tests compare it with the real
|
|
35
|
+
# Click group so the two cannot diverge unnoticed.
|
|
36
|
+
CORE_COMMANDS: dict[str, dict] = {
|
|
37
|
+
"register": {
|
|
38
|
+
"description": "Register a script as a CLI subcommand.",
|
|
39
|
+
"args": [{"name": "script_path", "positional": True, "required": True}],
|
|
40
|
+
},
|
|
41
|
+
"auto-register": {
|
|
42
|
+
"description": "Scan scripts/ for sidecar+script pairs and register unregistered ones.",
|
|
43
|
+
"args": [
|
|
44
|
+
{
|
|
45
|
+
"name": "dry_run",
|
|
46
|
+
"flag": True,
|
|
47
|
+
"option": "--dry-run",
|
|
48
|
+
"help": "Show what would be registered without writing.",
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
"unregister": {
|
|
53
|
+
"description": "Remove a script from the registry.",
|
|
54
|
+
"args": [{"name": "script_path", "positional": True, "required": True}],
|
|
55
|
+
},
|
|
56
|
+
"reregister": {"description": "Re-validate all registered commands."},
|
|
57
|
+
"refresh-cache": {"description": "Rebuild the command metadata cache next to the registry."},
|
|
58
|
+
"list": {"description": "List all registered scripts."},
|
|
59
|
+
"install-completion": {
|
|
60
|
+
"description": "Print the shell completion script to stdout.",
|
|
61
|
+
"args": [{"name": "shell", "positional": True, "choices": ["bash", "zsh", "fish"]}],
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
CORE_GROUP_HELP = "Registry management and shell-completion commands."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
# Request parsing
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _split_words(value: str) -> list[str]:
|
|
73
|
+
lexer = shlex.shlex(value, posix=True)
|
|
74
|
+
lexer.whitespace_split = True
|
|
75
|
+
lexer.commenters = ""
|
|
76
|
+
try:
|
|
77
|
+
return list(lexer)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return value.split()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _request(shell: str, environ: Mapping[str, str]) -> tuple[list[str], str] | None:
|
|
83
|
+
"""Return (args, incomplete) the way Click's shell classes derive them."""
|
|
84
|
+
try:
|
|
85
|
+
words = _split_words(environ["COMP_WORDS"])
|
|
86
|
+
if shell == "fish":
|
|
87
|
+
incomplete = environ.get("COMP_CWORD", "")
|
|
88
|
+
args = words[1:]
|
|
89
|
+
if incomplete and args and args[-1] == incomplete:
|
|
90
|
+
args.pop()
|
|
91
|
+
return args, incomplete
|
|
92
|
+
cword = int(environ["COMP_CWORD"])
|
|
93
|
+
except (KeyError, ValueError):
|
|
94
|
+
return None
|
|
95
|
+
return words[1:cword], words[cword] if cword < len(words) else ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------
|
|
99
|
+
# Command tree
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _short_help(text: object, limit: int = 45) -> str:
|
|
104
|
+
"""Equivalent of ``click.utils.make_default_short_help``."""
|
|
105
|
+
if not isinstance(text, str):
|
|
106
|
+
return ""
|
|
107
|
+
paragraph_end = text.find("\n\n")
|
|
108
|
+
if paragraph_end != -1:
|
|
109
|
+
text = text[:paragraph_end]
|
|
110
|
+
words = text.split()
|
|
111
|
+
if not words:
|
|
112
|
+
return ""
|
|
113
|
+
if words[0] == "\b":
|
|
114
|
+
words = words[1:]
|
|
115
|
+
|
|
116
|
+
total = 0
|
|
117
|
+
last = len(words) - 1
|
|
118
|
+
index = 0
|
|
119
|
+
for index, word in enumerate(words):
|
|
120
|
+
total += len(word) + (index > 0)
|
|
121
|
+
if total > limit:
|
|
122
|
+
break
|
|
123
|
+
if word[-1] == ".":
|
|
124
|
+
return " ".join(words[: index + 1])
|
|
125
|
+
if total == limit and index != last:
|
|
126
|
+
break
|
|
127
|
+
else:
|
|
128
|
+
return " ".join(words)
|
|
129
|
+
|
|
130
|
+
total += len("...")
|
|
131
|
+
while index > 0:
|
|
132
|
+
total -= len(words[index]) + (index > 0)
|
|
133
|
+
if total <= limit:
|
|
134
|
+
break
|
|
135
|
+
index -= 1
|
|
136
|
+
return " ".join(words[:index]) + "..."
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _new_node(help_text: str = "") -> dict:
|
|
140
|
+
return {"help": help_text, "groups": {}, "commands": {}}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cli_name(name: str) -> str:
|
|
144
|
+
return name.replace("_", "-")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _group_help(entry_path: str, group_parts: list[str], depth: int) -> str:
|
|
148
|
+
"""Reproduce the help text cli._build_cli_tree gives a generated group."""
|
|
149
|
+
name = group_parts[depth]
|
|
150
|
+
if depth == 0:
|
|
151
|
+
parts = entry_path.replace("\\", "/").split("/")
|
|
152
|
+
if parts[0] == "scripts":
|
|
153
|
+
parts = parts[1:]
|
|
154
|
+
original = parts[0] if len(parts) > 1 else name
|
|
155
|
+
folder = original if _cli_name(original) != name else name
|
|
156
|
+
return f"Commands from scripts/{folder}/"
|
|
157
|
+
return f"Commands from scripts/{'/'.join(group_parts[: depth + 1])}/"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_tree(entries: list[dict]) -> dict | None:
|
|
161
|
+
root = _new_node()
|
|
162
|
+
root["groups"]["core"] = core = _new_node(CORE_GROUP_HELP)
|
|
163
|
+
core["commands"].update(CORE_COMMANDS)
|
|
164
|
+
|
|
165
|
+
for entry in entries:
|
|
166
|
+
if not isinstance(entry, dict):
|
|
167
|
+
return None
|
|
168
|
+
group_parts = entry.get("group_parts")
|
|
169
|
+
command_name = entry.get("cmd_name")
|
|
170
|
+
sidecar = entry.get("sidecar")
|
|
171
|
+
entry_path = entry.get("entry")
|
|
172
|
+
if not (
|
|
173
|
+
isinstance(group_parts, list)
|
|
174
|
+
and all(isinstance(part, str) for part in group_parts)
|
|
175
|
+
and isinstance(command_name, str)
|
|
176
|
+
and isinstance(sidecar, dict)
|
|
177
|
+
and isinstance(entry_path, str)
|
|
178
|
+
):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
node = root
|
|
182
|
+
for depth, part in enumerate(group_parts):
|
|
183
|
+
if part not in node["groups"]:
|
|
184
|
+
node["groups"][part] = _new_node(_group_help(entry_path, group_parts, depth))
|
|
185
|
+
node = node["groups"][part]
|
|
186
|
+
node["commands"][command_name] = sidecar
|
|
187
|
+
return root
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --------------------------------------------------------------------------
|
|
191
|
+
# Completion
|
|
192
|
+
# --------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _arg_specs(sidecar: dict) -> list[dict] | None:
|
|
196
|
+
raw = sidecar.get("args") or []
|
|
197
|
+
if not isinstance(raw, list):
|
|
198
|
+
return None
|
|
199
|
+
specs = [arg for arg in raw if isinstance(arg, dict) and isinstance(arg.get("name"), str)]
|
|
200
|
+
return specs if len(specs) == len(raw) else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _option_name(arg: dict) -> str:
|
|
204
|
+
return arg.get("option") or "--" + arg["name"].replace("_", "-")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _value_completions(arg: dict, incomplete: str) -> list[Completion]:
|
|
208
|
+
choices = arg.get("choices")
|
|
209
|
+
if choices:
|
|
210
|
+
return [("plain", str(c), "") for c in choices if str(c).startswith(incomplete)]
|
|
211
|
+
if arg.get("type") == "path":
|
|
212
|
+
return [("file", incomplete, "")]
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _command_completions(
|
|
217
|
+
sidecar: dict, args: list[str], incomplete: str
|
|
218
|
+
) -> list[Completion] | None:
|
|
219
|
+
specs = _arg_specs(sidecar)
|
|
220
|
+
if specs is None:
|
|
221
|
+
return None
|
|
222
|
+
options = {_option_name(a): a for a in specs if not a.get("positional")}
|
|
223
|
+
positionals = [a for a in specs if a.get("positional")]
|
|
224
|
+
used: set[str] = set()
|
|
225
|
+
positional_count = 0
|
|
226
|
+
pending: dict | None = None
|
|
227
|
+
parse_options = True
|
|
228
|
+
|
|
229
|
+
for token in args:
|
|
230
|
+
if pending is not None:
|
|
231
|
+
pending = None
|
|
232
|
+
continue
|
|
233
|
+
if parse_options and token == "--":
|
|
234
|
+
parse_options = False
|
|
235
|
+
elif parse_options and token.startswith("-") and token != "-":
|
|
236
|
+
name, separator, _value = token.partition("=")
|
|
237
|
+
option = options.get(name)
|
|
238
|
+
if option is None:
|
|
239
|
+
return None # --help, short-option bundles, unknown: let Click decide
|
|
240
|
+
used.add(name)
|
|
241
|
+
if not option.get("flag") and not separator:
|
|
242
|
+
pending = option
|
|
243
|
+
else:
|
|
244
|
+
positional_count += 1
|
|
245
|
+
|
|
246
|
+
if pending is not None:
|
|
247
|
+
return _value_completions(pending, incomplete)
|
|
248
|
+
|
|
249
|
+
if parse_options and incomplete.startswith("-"):
|
|
250
|
+
name, separator, value = incomplete.partition("=")
|
|
251
|
+
if separator:
|
|
252
|
+
option = options.get(name)
|
|
253
|
+
return None if option is None else _value_completions(option, value)
|
|
254
|
+
matches = [
|
|
255
|
+
("plain", flag, str(spec.get("help") or ""))
|
|
256
|
+
for flag, spec in options.items()
|
|
257
|
+
if flag.startswith(incomplete)
|
|
258
|
+
and (spec.get("multiple") or flag not in used)
|
|
259
|
+
]
|
|
260
|
+
if _HELP[0].startswith(incomplete) and _HELP[0] not in used:
|
|
261
|
+
matches.append(("plain", *_HELP))
|
|
262
|
+
return matches
|
|
263
|
+
|
|
264
|
+
if positional_count < len(positionals):
|
|
265
|
+
return _value_completions(positionals[positional_count], incomplete)
|
|
266
|
+
if positionals and positionals[-1].get("variadic"):
|
|
267
|
+
return _value_completions(positionals[-1], incomplete)
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _node_completions(node: dict, incomplete: str, *, root: bool) -> list[Completion]:
|
|
272
|
+
if incomplete.startswith("-"):
|
|
273
|
+
flags = [_VERSION, _HELP] if root else [_HELP]
|
|
274
|
+
return [("plain", *flag) for flag in flags if flag[0].startswith(incomplete)]
|
|
275
|
+
|
|
276
|
+
items = [
|
|
277
|
+
("plain", name, _short_help(group["help"])) for name, group in node["groups"].items()
|
|
278
|
+
]
|
|
279
|
+
items += [
|
|
280
|
+
("plain", name, _short_help(sidecar.get("description")))
|
|
281
|
+
for name, sidecar in node["commands"].items()
|
|
282
|
+
]
|
|
283
|
+
return sorted(
|
|
284
|
+
(item for item in items if item[1].startswith(incomplete)), key=lambda item: item[1]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def complete(entries: list[dict], args: list[str], incomplete: str) -> list[Completion] | None:
|
|
289
|
+
"""Return completions, or None when Click must answer instead."""
|
|
290
|
+
node = _build_tree(entries)
|
|
291
|
+
if node is None:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
root = True
|
|
295
|
+
for index, token in enumerate(args):
|
|
296
|
+
if token.startswith("-"):
|
|
297
|
+
return None
|
|
298
|
+
if token in node["groups"]:
|
|
299
|
+
node = node["groups"][token]
|
|
300
|
+
root = False
|
|
301
|
+
elif token in node["commands"]:
|
|
302
|
+
return _command_completions(node["commands"][token], args[index + 1 :], incomplete)
|
|
303
|
+
else:
|
|
304
|
+
return None # hidden alias group, typo, ...
|
|
305
|
+
return _node_completions(node, incomplete, root=root)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# --------------------------------------------------------------------------
|
|
309
|
+
# Output
|
|
310
|
+
# --------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _format(shell: str, item: Completion) -> str:
|
|
314
|
+
kind, value, help_text = item
|
|
315
|
+
if shell == "zsh":
|
|
316
|
+
if help_text:
|
|
317
|
+
value = value.replace(":", r"\:")
|
|
318
|
+
return f"{kind}\n{value}\n{help_text or '_'}"
|
|
319
|
+
if shell == "fish" and help_text:
|
|
320
|
+
return f"{kind},{value}\t{help_text}"
|
|
321
|
+
return f"{kind},{value}"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def try_fast_completion(environ: Mapping[str, str] = os.environ) -> bool:
|
|
325
|
+
"""Answer a completion request from the cache. Return False to defer to Click."""
|
|
326
|
+
mode = environ.get(runtime.completion_var(runtime.cli_name(environ)))
|
|
327
|
+
if mode not in _MODES:
|
|
328
|
+
return False
|
|
329
|
+
shell = mode.removesuffix("_complete")
|
|
330
|
+
|
|
331
|
+
request = _request(shell, environ)
|
|
332
|
+
if request is None:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
repo = runtime.resolve_repository(environ)
|
|
336
|
+
try:
|
|
337
|
+
entries = runtime.read_cache(runtime.read_registry(repo), repo)
|
|
338
|
+
except (OSError, ValueError):
|
|
339
|
+
return False
|
|
340
|
+
if entries is None:
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
items = complete(entries, *request)
|
|
344
|
+
if items is None:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
if os.name == "nt" and hasattr(sys.stdout, "reconfigure"):
|
|
348
|
+
sys.stdout.reconfigure(newline="", errors="replace")
|
|
349
|
+
sys.stdout.write("\n".join(_format(shell, item) for item in items) + "\n")
|
|
350
|
+
sys.stdout.flush()
|
|
351
|
+
return True
|
toolrack/entrypoint.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Process entry point with a lightweight shell-completion fast path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from toolrack.completion import try_fast_completion
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
if try_fast_completion():
|
|
10
|
+
return
|
|
11
|
+
|
|
12
|
+
from toolrack.cli import main as click_main
|
|
13
|
+
|
|
14
|
+
click_main()
|
toolrack/runtime.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Click-free runtime primitives shared by the CLI and the completion fast path.
|
|
2
|
+
|
|
3
|
+
Everything here must stay importable without Click or PyYAML: the shell
|
|
4
|
+
completion fast path (``toolrack.completion``) imports this module on every
|
|
5
|
+
<TAB> press, and the CLI imports it so both sides agree on how the repository
|
|
6
|
+
is located and how the command-metadata cache is validated.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import ntpath
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from collections import namedtuple
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
LEGACY_REGISTRY_BASENAME = ".attic"
|
|
19
|
+
DEFAULT_REGISTRY_BASENAME = ".toolrack"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# A namedtuple rather than a dataclass: importing `dataclasses` costs more than
|
|
23
|
+
# the rest of this module put together, and this runs on every <TAB>.
|
|
24
|
+
RepositoryContext = namedtuple(
|
|
25
|
+
"RepositoryContext",
|
|
26
|
+
["repo_root", "scripts_root", "registry_file", "cache_file", "aliases_file"],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_cli_name() -> str:
|
|
31
|
+
prog = os.path.basename(sys.argv[0] or "").strip()
|
|
32
|
+
if prog:
|
|
33
|
+
prog = os.path.splitext(prog)[0]
|
|
34
|
+
if prog not in {"", "__main__", "-m"}:
|
|
35
|
+
return prog
|
|
36
|
+
return "toolrack"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cli_name(environ: Mapping[str, str] = os.environ) -> str:
|
|
40
|
+
return environ.get("TOOLRACK_CLI_NAME") or default_cli_name()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def completion_var(name: str) -> str:
|
|
44
|
+
return "_" + name.replace("-", "_").upper() + "_COMPLETE"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def registry_basename(environ: Mapping[str, str] = os.environ) -> str:
|
|
48
|
+
return environ.get("TOOLRACK_REGISTRY_BASENAME") or DEFAULT_REGISTRY_BASENAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_windows() -> bool:
|
|
52
|
+
return os.name == "nt"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_env_path(value: str | None) -> str | None:
|
|
56
|
+
"""Map Git Bash / Cygwin Windows mount paths back to native Windows paths."""
|
|
57
|
+
if not value or not is_windows():
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
normalized = value.replace("\\", "/")
|
|
61
|
+
if normalized.startswith("/cygdrive/") and len(normalized) > len("/cygdrive/x/"):
|
|
62
|
+
drive = normalized[len("/cygdrive/")]
|
|
63
|
+
remainder = normalized[len("/cygdrive/x") :]
|
|
64
|
+
return ntpath.normpath(f"{drive.upper()}:{remainder}")
|
|
65
|
+
|
|
66
|
+
if normalized.startswith("/") and len(normalized) > 3 and normalized[2] == "/":
|
|
67
|
+
drive = normalized[1]
|
|
68
|
+
if drive.isalpha():
|
|
69
|
+
return ntpath.normpath(f"{drive.upper()}:{normalized[2:]}")
|
|
70
|
+
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_repo_root(environ: Mapping[str, str] = os.environ) -> str:
|
|
75
|
+
"""Walk up from cwd until we find the registry file or .git."""
|
|
76
|
+
candidates = [
|
|
77
|
+
environ.get("TOOLRACK_REGISTRY_FILE"),
|
|
78
|
+
registry_basename(environ),
|
|
79
|
+
LEGACY_REGISTRY_BASENAME,
|
|
80
|
+
]
|
|
81
|
+
names = [name for name in candidates if name and not os.path.isabs(name)]
|
|
82
|
+
|
|
83
|
+
here = os.path.abspath(os.getcwd())
|
|
84
|
+
candidate = here
|
|
85
|
+
while True:
|
|
86
|
+
if any(os.path.isfile(os.path.join(candidate, name)) for name in names):
|
|
87
|
+
return candidate
|
|
88
|
+
if os.path.isdir(os.path.join(candidate, ".git")):
|
|
89
|
+
return candidate
|
|
90
|
+
parent = os.path.dirname(candidate)
|
|
91
|
+
if parent == candidate:
|
|
92
|
+
return here
|
|
93
|
+
candidate = parent
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_repository(environ: Mapping[str, str] = os.environ) -> RepositoryContext:
|
|
97
|
+
"""Resolve repository locations from the TOOLRACK_* environment."""
|
|
98
|
+
|
|
99
|
+
def path_from_env(name: str) -> str | None:
|
|
100
|
+
return normalize_env_path(environ.get(name))
|
|
101
|
+
|
|
102
|
+
repo_root = path_from_env("TOOLRACK_REPO_ROOT") or find_repo_root(environ)
|
|
103
|
+
registry_file = path_from_env("TOOLRACK_REGISTRY_FILE") or os.path.join(
|
|
104
|
+
repo_root, registry_basename(environ)
|
|
105
|
+
)
|
|
106
|
+
return RepositoryContext(
|
|
107
|
+
repo_root=repo_root,
|
|
108
|
+
scripts_root=path_from_env("TOOLRACK_SCRIPTS_ROOT") or os.path.join(repo_root, "scripts"),
|
|
109
|
+
registry_file=registry_file,
|
|
110
|
+
cache_file=path_from_env("TOOLRACK_CACHE_FILE") or registry_file + ".cache.json",
|
|
111
|
+
aliases_file=path_from_env("TOOLRACK_ALIASES_FILE")
|
|
112
|
+
or os.path.join(repo_root, "aliases.cfg"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_registry(repo: RepositoryContext) -> list[str]:
|
|
117
|
+
if not os.path.isfile(repo.registry_file):
|
|
118
|
+
return []
|
|
119
|
+
with open(repo.registry_file, encoding="utf-8") as handle:
|
|
120
|
+
return [
|
|
121
|
+
line.strip()
|
|
122
|
+
for line in handle
|
|
123
|
+
if line.strip() and not line.strip().startswith("#")
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def abs_script_path(registry_entry: str, repo: RepositoryContext) -> str:
|
|
128
|
+
return os.path.normpath(os.path.join(repo.repo_root, registry_entry))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sidecar_path(script_path: str) -> str:
|
|
132
|
+
return os.path.splitext(script_path)[0] + ".yml"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def file_state(path: str) -> dict[str, int | bool]:
|
|
136
|
+
try:
|
|
137
|
+
stat = os.stat(path)
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
return {"exists": False, "size": 0, "mtime_ns": 0}
|
|
140
|
+
return {"exists": True, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cache_signature(registry: list[str], repo: RepositoryContext) -> str:
|
|
144
|
+
payload = {
|
|
145
|
+
"repo_root": repo.repo_root,
|
|
146
|
+
"scripts_root": repo.scripts_root,
|
|
147
|
+
"registry_file": repo.registry_file,
|
|
148
|
+
"aliases_file": repo.aliases_file,
|
|
149
|
+
"registry": registry,
|
|
150
|
+
"registry_state": file_state(repo.registry_file),
|
|
151
|
+
"aliases_state": file_state(repo.aliases_file),
|
|
152
|
+
"entries": [
|
|
153
|
+
{
|
|
154
|
+
"registry_entry": entry,
|
|
155
|
+
"script_state": file_state(abs_script_path(entry, repo)),
|
|
156
|
+
"sidecar_state": file_state(sidecar_path(abs_script_path(entry, repo))),
|
|
157
|
+
}
|
|
158
|
+
for entry in registry
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_cache(registry: list[str], repo: RepositoryContext) -> list[dict] | None:
|
|
165
|
+
"""Return cached command entries, or None when missing or stale."""
|
|
166
|
+
if not os.path.isfile(repo.cache_file):
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
with open(repo.cache_file, encoding="utf-8") as handle:
|
|
170
|
+
payload = json.load(handle)
|
|
171
|
+
except (OSError, json.JSONDecodeError):
|
|
172
|
+
return None
|
|
173
|
+
if not isinstance(payload, dict) or payload.get("signature") != cache_signature(
|
|
174
|
+
registry, repo
|
|
175
|
+
):
|
|
176
|
+
return None
|
|
177
|
+
entries = payload.get("entries")
|
|
178
|
+
return entries if isinstance(entries, list) else None
|