cli-tools-kit 0.6.3__tar.gz → 0.7.1__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 (41) hide show
  1. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/PKG-INFO +35 -1
  2. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/README.md +34 -0
  3. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/__init__.py +1 -1
  4. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/gui_installer.py +37 -3
  5. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/sources.py +407 -10
  6. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/PKG-INFO +35 -1
  7. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/pyproject.toml +1 -1
  8. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_sources.py +454 -0
  9. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/LICENSE +0 -0
  10. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/__main__.py +0 -0
  11. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/advertise.py +0 -0
  12. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/cron_installer.py +0 -0
  13. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/host.py +0 -0
  14. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/identity.py +0 -0
  15. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/onboarding.py +0 -0
  16. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/skills.py +0 -0
  17. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
  18. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/build.py +0 -0
  19. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/capability.py +0 -0
  20. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
  21. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
  22. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
  23. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/groups.py +0 -0
  24. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  25. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/tool_installer.py +0 -0
  26. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/tui_installer.py +0 -0
  27. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/SOURCES.txt +0 -0
  28. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  29. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  30. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/requires.txt +0 -0
  31. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
  32. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/setup.cfg +0 -0
  33. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_capability_groups.py +0 -0
  34. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_cron_installer.py +0 -0
  35. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_host.py +0 -0
  36. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_identity.py +0 -0
  37. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_llm_groups.py +0 -0
  38. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_skills.py +0 -0
  39. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_taxonomy_cluster.py +0 -0
  40. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_tool_installer.py +0 -0
  41. {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_tui_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 0.6.3
3
+ Version: 0.7.1
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
@@ -403,6 +403,37 @@ name = "manim-kit"
403
403
  url = "https://github.com/AutomatedAlchemy/manim-kit"
404
404
  ```
405
405
 
406
+ An entry may name a GitHub organisation instead of one repo. The installer lists
407
+ the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
408
+ source, so a new tool in the org appears without anyone editing this file:
409
+
410
+ ```toml
411
+ [[source]]
412
+ org = "AutomatedAlchemy"
413
+ topic = "cli-tool-kit" # the default when omitted
414
+ exclude = ["alchemy-installer"] # repo names to skip
415
+ include = ["manim-kit"] # allowlist; wins over exclude
416
+ ```
417
+
418
+ The topic decides what is cloned, and the walker plus the `--advertise` probe
419
+ decide what is a tool: a repo that carries the topic but holds no tool clones,
420
+ advertises nothing and is dropped like any other directory. `org` cannot be
421
+ combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
422
+ as a listed repo wins, so one tool can be pinned to a fork or a local checkout
423
+ while the rest of the org follows the listing. Archived repos are left out.
424
+ `include` is an allowlist and overrides `exclude`; the topic is required either
425
+ way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
426
+ pins a tracked source, so a checkout already on the machine is used instead of
427
+ being cloned.
428
+
429
+ The listing is one `GET` to `api.github.com`, cached for a day under the
430
+ identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
431
+ logged in, its token is used and the org's private repos are listed too; without
432
+ it the public listing is used and nothing is required. When the listing fails the
433
+ cached one is used however old it is, and with no cache at all the directories
434
+ already under the root are used — both say so in one line. `--check` never
435
+ fetches: it reads the cache, or the root.
436
+
406
437
  `installer.local.toml` next to it is optional and belongs to one machine, so keep
407
438
  it out of git. It sets the root and replaces a `path` for a source matched by
408
439
  `name`:
@@ -442,6 +473,9 @@ colleague without access to a private repo still gets everybody else's tools.
442
473
  given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
443
474
  hook, which `--check` skips, so the login check stays network-free.
444
475
 
476
+ An `org` entry is the only thing in this module that reaches anything but git,
477
+ it is opt-in per entry, and nothing is fetched when no such entry exists.
478
+
445
479
  A repo that is itself an installer tree can carry its own `installer.toml`. Its
446
480
  `[[source]]` entries are resolved too, one nested level deep and no further, with
447
481
  paths relative to that file and clones under the same root. A path that is
@@ -350,6 +350,37 @@ name = "manim-kit"
350
350
  url = "https://github.com/AutomatedAlchemy/manim-kit"
351
351
  ```
352
352
 
353
+ An entry may name a GitHub organisation instead of one repo. The installer lists
354
+ the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
355
+ source, so a new tool in the org appears without anyone editing this file:
356
+
357
+ ```toml
358
+ [[source]]
359
+ org = "AutomatedAlchemy"
360
+ topic = "cli-tool-kit" # the default when omitted
361
+ exclude = ["alchemy-installer"] # repo names to skip
362
+ include = ["manim-kit"] # allowlist; wins over exclude
363
+ ```
364
+
365
+ The topic decides what is cloned, and the walker plus the `--advertise` probe
366
+ decide what is a tool: a repo that carries the topic but holds no tool clones,
367
+ advertises nothing and is dropped like any other directory. `org` cannot be
368
+ combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
369
+ as a listed repo wins, so one tool can be pinned to a fork or a local checkout
370
+ while the rest of the org follows the listing. Archived repos are left out.
371
+ `include` is an allowlist and overrides `exclude`; the topic is required either
372
+ way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
373
+ pins a tracked source, so a checkout already on the machine is used instead of
374
+ being cloned.
375
+
376
+ The listing is one `GET` to `api.github.com`, cached for a day under the
377
+ identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
378
+ logged in, its token is used and the org's private repos are listed too; without
379
+ it the public listing is used and nothing is required. When the listing fails the
380
+ cached one is used however old it is, and with no cache at all the directories
381
+ already under the root are used — both say so in one line. `--check` never
382
+ fetches: it reads the cache, or the root.
383
+
353
384
  `installer.local.toml` next to it is optional and belongs to one machine, so keep
354
385
  it out of git. It sets the root and replaces a `path` for a source matched by
355
386
  `name`:
@@ -389,6 +420,9 @@ colleague without access to a private repo still gets everybody else's tools.
389
420
  given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
390
421
  hook, which `--check` skips, so the login check stays network-free.
391
422
 
423
+ An `org` entry is the only thing in this module that reaches anything but git,
424
+ it is opt-in per entry, and nothing is fetched when no such entry exists.
425
+
392
426
  A repo that is itself an installer tree can carry its own `installer.toml`. Its
393
427
  `[[source]]` entries are resolved too, one nested level deep and no further, with
394
428
  paths relative to that file and clones under the same root. A path that is
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.6.1"
59
+ __version__ = "0.7.1"
@@ -5434,13 +5434,37 @@ class InstallerApp:
5434
5434
  # ================= CLI FUNCTIONS =================
5435
5435
 
5436
5436
  def _self_shortcut_path() -> str:
5437
- """The manager's own shortcut: a .desktop file, or a .lnk on Windows."""
5437
+ """The manager's own shortcut: a .desktop file, or a .lnk on Windows.
5438
+
5439
+ Windows shows the file name in the Start Menu, where freedesktop reads the
5440
+ Name= line inside the file, so the .lnk is named after SELF_DESKTOP_NAME.
5441
+ Characters a file name cannot hold become spaces, and a name that is empty
5442
+ or all-punctuation falls back to the desktop file's stem.
5443
+ """
5438
5444
  if host.IS_WINDOWS:
5439
- return os.path.join(APPS_DIR,
5440
- os.path.splitext(SELF_DESKTOP_FILE)[0] + ".lnk")
5445
+ stem = "".join(" " if c in '<>:"/\\|?*' else c
5446
+ for c in SELF_DESKTOP_NAME).strip(" .")
5447
+ stem = " ".join(stem.split()) or os.path.splitext(SELF_DESKTOP_FILE)[0]
5448
+ return os.path.join(APPS_DIR, stem + ".lnk")
5441
5449
  return os.path.join(APPS_DIR, SELF_DESKTOP_FILE)
5442
5450
 
5443
5451
 
5452
+ def _windows_icon(icon: str) -> Optional[str]:
5453
+ """The .ico Windows can draw for `icon`, or None.
5454
+
5455
+ A .lnk renders only .ico/.exe/.dll; the shell stores a .png path without
5456
+ complaint and then draws nothing. Consumers configure one icon, normally a
5457
+ .png for freedesktop, so take a sibling .ico of the same stem when there is
5458
+ one and otherwise leave the shortcut on its default picture.
5459
+ """
5460
+ if not icon:
5461
+ return None
5462
+ for candidate in (icon, os.path.splitext(icon)[0] + ".ico"):
5463
+ if candidate.lower().endswith(".ico") and os.path.isfile(candidate):
5464
+ return candidate
5465
+ return None
5466
+
5467
+
5444
5468
  def cli_install_self(quiet: bool = False) -> tuple[bool, str]:
5445
5469
  """Install the manager's own desktop shortcut. Returns (success, message)."""
5446
5470
  try:
@@ -5450,9 +5474,19 @@ def cli_install_self(quiet: bool = False) -> tuple[bool, str]:
5450
5474
  if host.IS_WINDOWS:
5451
5475
  lnk_path = _self_shortcut_path()
5452
5476
  ok = host.write_shortcut(lnk_path, python_exec, f'"{script_path}"',
5477
+ icon=_windows_icon(SELF_DESKTOP_ICON),
5453
5478
  workdir=ROOT_DIR)
5454
5479
  if not ok:
5455
5480
  return False, f"Could not write {lnk_path}"
5481
+ # Before 0.6.4 the .lnk was named after the desktop file's stem.
5482
+ # Drop that one, or the Start Menu keeps both.
5483
+ legacy = os.path.join(APPS_DIR,
5484
+ os.path.splitext(SELF_DESKTOP_FILE)[0] + ".lnk")
5485
+ if legacy != lnk_path and os.path.isfile(legacy):
5486
+ try:
5487
+ os.remove(legacy)
5488
+ except OSError:
5489
+ pass
5456
5490
  if not quiet:
5457
5491
  print(f"Installed: {lnk_path}")
5458
5492
  return True, lnk_path
@@ -40,6 +40,21 @@ are resolved too, one nested level deep and no further. Paths in a nested file
40
40
  are relative to that file, clones still go under the same root, a path already
41
41
  resolved is not visited twice, and duplicates are dropped.
42
42
 
43
+ An entry may name a GitHub organisation instead of one repo. The installer
44
+ lists the org's repos, keeps the ones carrying a topic, and turns each into an
45
+ ordinary source, so everything after that step is unchanged:
46
+
47
+ [[source]]
48
+ org = "AutomatedAlchemy"
49
+ topic = "cli-tool-kit" # the default when omitted
50
+ exclude = ["alchemy-installer"] # repo names to skip
51
+ include = ["manim-kit"] # allowlist; wins over exclude
52
+
53
+ ``org`` is mutually exclusive with ``url`` and ``path``. The listing is cached
54
+ for a day, a network error falls back to the cached list and then to the
55
+ directories already under the root, and an explicit ``[[source]]`` with the
56
+ same ``name`` always wins over an org-derived one.
57
+
43
58
  The whole feature is three calls:
44
59
 
45
60
  sources = load_sources("installer.toml")
@@ -54,15 +69,22 @@ or, for a wrapper that just wants the installer:
54
69
 
55
70
  from __future__ import annotations
56
71
 
72
+ import json
57
73
  import os
74
+ import re
75
+ import shutil
58
76
  import subprocess
59
77
  import sys
78
+ import time
79
+ import urllib.error
80
+ import urllib.request
60
81
  from dataclasses import dataclass
61
82
  from pathlib import Path
62
- from typing import Callable, List, Optional, Sequence
83
+ from typing import Callable, List, Optional, Sequence, Tuple
63
84
 
64
- __all__ = ["Source", "load_sources", "resolve_sources", "run_installer",
65
- "local_root", "save_local_root", "default_root"]
85
+ __all__ = ["Source", "OrgSource", "load_sources", "expand_org_sources",
86
+ "resolve_sources", "run_installer", "local_root", "save_local_root",
87
+ "default_root"]
66
88
 
67
89
  # How far below the top-level installer.toml a nested one is still read.
68
90
  MAX_NESTING = 1
@@ -87,6 +109,53 @@ class Source:
87
109
  path: Optional[str] = None
88
110
 
89
111
 
112
+ # The default GitHub topic an org's repos are tagged with to be offered.
113
+ DEFAULT_ORG_TOPIC = "cli-tool-kit"
114
+
115
+ # A GitHub org or user name, and a topic: both are pasted into a URL path, so
116
+ # they stay to the characters GitHub itself allows. \Z, not $, so a trailing
117
+ # newline cannot smuggle a second path segment in.
118
+ _ORG_RE = re.compile(r"^[A-Za-z0-9-]{1,39}\Z")
119
+ _TOPIC_RE = re.compile(r"^[A-Za-z0-9-]{1,50}\Z")
120
+
121
+ # The one host this module talks to, hard-coded so a config file cannot point
122
+ # the listing at somewhere else.
123
+ GITHUB_API = "https://api.github.com"
124
+
125
+ # How long a cached listing is used without asking GitHub again.
126
+ ORG_CACHE_TTL = 24 * 60 * 60
127
+
128
+ # Enough for 500 repos; a listing longer than that is a config mistake.
129
+ ORG_MAX_PAGES = 5
130
+
131
+ ORG_TIMEOUT = 10
132
+ GH_TOKEN_TIMEOUT = 5
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class OrgSource:
137
+ """One GitHub organisation whose topic-tagged repos become sources.
138
+
139
+ Expanded into ordinary :class:`Source` entries by
140
+ :func:`expand_org_sources`, which is the only place in this module that
141
+ reaches the network.
142
+ """
143
+
144
+ org: str
145
+ topic: str = DEFAULT_ORG_TOPIC
146
+ include: Tuple[str, ...] = ()
147
+ exclude: Tuple[str, ...] = ()
148
+ # ``installer.local.toml``'s ``[[source]]`` path overrides, as already
149
+ # absolute paths keyed by repo name. A listed repo whose name is in here
150
+ # resolves to that checkout instead of being cloned, exactly as the
151
+ # override works for a tracked ``[[source]]``. A tuple of pairs, not a
152
+ # dict, because this dataclass is frozen and has to stay hashable.
153
+ pins: Tuple[Tuple[str, str], ...] = ()
154
+
155
+ def pin_for(self, name: str) -> Optional[str]:
156
+ return dict(self.pins).get(name)
157
+
158
+
90
159
  # --- TOML -------------------------------------------------------------------
91
160
 
92
161
  def _toml_module():
@@ -157,7 +226,15 @@ def save_local_root(config_path, root, local_path=None, log: Callable = print) -
157
226
  except OSError as exc:
158
227
  log(f"{local.name}: not updated ({exc})")
159
228
  return False
160
- line = f'root = "{root}"\n'
229
+ # A TOML literal string, because a Windows root is full of backslashes and
230
+ # a basic string would read them as escapes: "C:\Users\..." dies on \U and
231
+ # takes the whole file with it, so the question would be asked again on
232
+ # every launch. A path holding a single quote falls back to a basic string
233
+ # with the two characters TOML needs escaped there.
234
+ if "'" in root:
235
+ line = 'root = "{}"\n'.format(root.replace("\\", "\\\\").replace('"', '\\"'))
236
+ else:
237
+ line = f"root = '{root}'\n"
161
238
  try:
162
239
  local.write_text(line + ("\n" + existing.lstrip("\n") if existing.strip() else ""),
163
240
  encoding="utf-8")
@@ -167,12 +244,51 @@ def save_local_root(config_path, root, local_path=None, log: Callable = print) -
167
244
  return True
168
245
 
169
246
 
170
- def load_sources(config_path, local_path=None, log: Callable = print) -> List[Source]:
247
+ def _names(entry: dict, key: str) -> Tuple[str, ...]:
248
+ """One of the ``include`` / ``exclude`` lists, as a tuple of strings."""
249
+ raw = entry.get(key)
250
+ if not isinstance(raw, list):
251
+ return ()
252
+ return tuple(item for item in raw if isinstance(item, str) and item)
253
+
254
+
255
+ def _org_source(entry: dict, config_name: str, log: Callable,
256
+ pins: Tuple[Tuple[str, str], ...] = ()) -> Optional[OrgSource]:
257
+ """One ``[[source]]`` table with an ``org``, or None when it is unusable.
258
+
259
+ Reported and dropped the same way an https-only violation is: one line
260
+ naming what is wrong, and the other sources still install.
261
+ """
262
+ org = entry.get("org")
263
+ if not isinstance(org, str) or not _ORG_RE.match(org):
264
+ log(f"{config_name}: a [[source]] with an unusable org "
265
+ f"({org!r}), skipped")
266
+ return None
267
+ if entry.get("url") or entry.get("path"):
268
+ log(f"{org}: a [[source]] cannot have both org and url/path, skipped")
269
+ return None
270
+ topic = entry.get("topic", DEFAULT_ORG_TOPIC)
271
+ if not isinstance(topic, str) or not _TOPIC_RE.match(topic):
272
+ log(f"{org}: {topic!r} is not a usable topic, skipped")
273
+ return None
274
+ return OrgSource(org=org, topic=topic,
275
+ include=_names(entry, "include"),
276
+ exclude=_names(entry, "exclude"),
277
+ pins=pins)
278
+
279
+
280
+ def load_sources(config_path, local_path=None, log: Callable = print) -> List:
171
281
  """The ``[[source]]`` entries of one TOML file, local overrides applied.
172
282
 
173
283
  ``local_path`` defaults to ``installer.local.toml`` next to ``config_path``.
174
284
  A ``path`` is taken relative to the file it is written in. An entry without
175
285
  a name is reported and dropped.
286
+
287
+ An entry that carries an ``org`` instead of a ``name`` becomes an
288
+ :class:`OrgSource` in the returned list. :func:`expand_org_sources` turns
289
+ those into ordinary :class:`Source` entries; :func:`resolve_sources` ignores
290
+ any that are left, so a caller that does not expand simply gets no tools
291
+ from the org rather than an error.
176
292
  """
177
293
  config_path = Path(config_path)
178
294
  local_path = Path(local_path) if local_path is not None else _local_path_for(config_path)
@@ -183,14 +299,29 @@ def load_sources(config_path, local_path=None, log: Callable = print) -> List[So
183
299
  for entry in local.get("source") or []
184
300
  if isinstance(entry, dict) and entry.get("name")}
185
301
 
186
- sources: List[Source] = []
302
+ # The same overrides an explicit [[source]] gets, kept for the org
303
+ # expansion: the repos it derives do not exist yet at this point, so a pin
304
+ # naming one of them can only be applied later, by name.
305
+ pins = tuple((name, _absolute(local_path.parent, entry["path"]))
306
+ for name, entry in overrides.items()
307
+ if isinstance(entry.get("path"), str) and entry["path"])
308
+
309
+ sources: List = []
187
310
  for entry in data.get("source") or []:
188
311
  if not isinstance(entry, dict):
189
312
  continue
190
313
  name = entry.get("name")
191
314
  if not name:
315
+ if "org" in entry:
316
+ org_source = _org_source(entry, config_path.name, log, pins)
317
+ if org_source is not None:
318
+ sources.append(org_source)
319
+ continue
192
320
  log(f"{config_path.name}: a [[source]] without a name, skipped")
193
321
  continue
322
+ if entry.get("org"):
323
+ log(f"{name}: a [[source]] cannot have both org and url/path, skipped")
324
+ continue
194
325
  override = overrides.get(name, {}).get("path")
195
326
  raw = override or entry.get("path")
196
327
  base = local_path.parent if override else config_path.parent
@@ -199,6 +330,253 @@ def load_sources(config_path, local_path=None, log: Callable = print) -> List[So
199
330
  return sources
200
331
 
201
332
 
333
+ # --- GitHub org listings ----------------------------------------------------
334
+
335
+ def _gh_token() -> Optional[str]:
336
+ """The token ``gh auth token`` prints, or None.
337
+
338
+ Opportunistic: with the GitHub CLI logged in, the listing also sees the
339
+ org's private repos. Without it the public listing is used. The token is
340
+ never logged.
341
+ """
342
+ if not shutil.which("gh"):
343
+ return None
344
+ try:
345
+ result = subprocess.run(["gh", "auth", "token"], capture_output=True,
346
+ text=True, timeout=GH_TOKEN_TIMEOUT)
347
+ except (OSError, subprocess.SubprocessError):
348
+ return None
349
+ if result.returncode != 0:
350
+ return None
351
+ token = result.stdout.strip()
352
+ return token or None
353
+
354
+
355
+ def _api_version() -> str:
356
+ from . import __version__ # noqa: PLC0415 — avoids an import cycle at module load
357
+ return __version__
358
+
359
+
360
+ def _fetch_org_repos(org: str, token: Optional[str]) -> List[dict]:
361
+ """Every repo of one org, over as many pages as GitHub needs.
362
+
363
+ Raises ``OSError`` (which ``urllib`` errors are) on anything that goes
364
+ wrong, so the one caller can fall back in a single place.
365
+ """
366
+ headers = {"Accept": "application/vnd.github+json",
367
+ "User-Agent": f"cli-tools-kit/{_api_version()}"}
368
+ if token:
369
+ headers["Authorization"] = f"Bearer {token}"
370
+ repos: List[dict] = []
371
+ for page in range(1, ORG_MAX_PAGES + 1):
372
+ query = f"per_page=100&page={page}"
373
+ if not token:
374
+ # Without a token only public repos are visible anyway; asking for
375
+ # them explicitly keeps the response small.
376
+ query += "&type=public"
377
+ request = urllib.request.Request( # noqa: S310 — the host is hard-coded above
378
+ f"{GITHUB_API}/orgs/{org}/repos?{query}", headers=headers)
379
+ with urllib.request.urlopen(request, timeout=ORG_TIMEOUT) as response:
380
+ batch = json.loads(response.read().decode("utf-8"))
381
+ if not isinstance(batch, list):
382
+ raise OSError("the listing was not a JSON array")
383
+ repos.extend(item for item in batch if isinstance(item, dict))
384
+ if len(batch) < 100:
385
+ break
386
+ return repos
387
+
388
+
389
+ def _keep_fields(repos: Sequence[dict]) -> List[dict]:
390
+ """Only the fields this module uses, so the cache stays small and readable."""
391
+ keep = ("name", "clone_url", "topics", "archived", "default_branch",
392
+ "description")
393
+ return [{field_name: repo.get(field_name) for field_name in keep}
394
+ for repo in repos if repo.get("name")]
395
+
396
+
397
+ def _cache_file(cache_dir, org: str) -> Path:
398
+ return Path(os.path.expanduser(str(cache_dir))) / f"org-{org}.json"
399
+
400
+
401
+ def _read_org_cache(cache_dir, org: str) -> Optional[dict]:
402
+ """The cached listing for one org, or None when there is none to read."""
403
+ try:
404
+ with open(_cache_file(cache_dir, org), encoding="utf-8") as fh:
405
+ cached = json.load(fh)
406
+ except (OSError, ValueError):
407
+ return None
408
+ if not isinstance(cached, dict) or not isinstance(cached.get("repos"), list):
409
+ return None
410
+ return cached
411
+
412
+
413
+ def _write_org_cache(cache_dir, org: str, topic: str, repos: Sequence[dict],
414
+ log: Callable) -> None:
415
+ path = _cache_file(cache_dir, org)
416
+ try:
417
+ path.parent.mkdir(parents=True, exist_ok=True)
418
+ path.write_text(json.dumps({"fetched_at": time.time(), "topic": topic,
419
+ "repos": list(repos)}, indent=1),
420
+ encoding="utf-8")
421
+ except OSError as exc:
422
+ log(f"{org}: the listing was not cached ({exc})")
423
+
424
+
425
+ def _age(seconds: float) -> str:
426
+ """"2h ago", "3 days ago" — how long ago a listing was fetched."""
427
+ delta = max(0.0, time.time() - seconds)
428
+ if delta < 90 * 60:
429
+ return f"{int(delta // 60)}min ago"
430
+ if delta < 36 * 3600:
431
+ return f"{int(delta // 3600)}h ago"
432
+ return f"{int(delta // 86400)} days ago"
433
+
434
+
435
+ def _selected(entry: OrgSource, repos: Sequence[dict]) -> List[dict]:
436
+ """The repos of one listing this entry offers.
437
+
438
+ ``include`` is an allowlist and wins over ``exclude``; the topic is still
439
+ required either way, so a repo that lost its tag stops being offered
440
+ without anyone having to edit the config.
441
+ """
442
+ chosen = []
443
+ for repo in repos:
444
+ name = repo.get("name")
445
+ if not name or repo.get("archived"):
446
+ continue
447
+ topics = repo.get("topics") or []
448
+ if entry.topic not in topics:
449
+ continue
450
+ if entry.include:
451
+ if name not in entry.include:
452
+ continue
453
+ elif name in entry.exclude:
454
+ continue
455
+ chosen.append(repo)
456
+ return chosen
457
+
458
+
459
+ def _from_root(entry: OrgSource, root, log: Callable) -> List[Source]:
460
+ """The last fallback: the org's checkouts that are already under the root.
461
+
462
+ No listing and no cache, so what is on disk is all this run knows about.
463
+ A path-only source, because without a listing there is no clone URL.
464
+ """
465
+ base = Path(os.path.expanduser(str(root)))
466
+ found = []
467
+ try:
468
+ entries = sorted(item for item in base.iterdir() if item.is_dir())
469
+ except OSError:
470
+ entries = []
471
+ for item in entries:
472
+ if entry.include:
473
+ if item.name not in entry.include:
474
+ continue
475
+ elif item.name in entry.exclude:
476
+ continue
477
+ found.append(Source(name=item.name, path=str(item.resolve())))
478
+ log(f"{entry.org}: no listing and no cache, using the "
479
+ f"{len(found)} checkout(s) already under {base}")
480
+ return found
481
+
482
+
483
+ def _org_listing(entry: OrgSource, *, refresh: bool, clone: bool, cache_dir,
484
+ log: Callable) -> Optional[Tuple[List[dict], str]]:
485
+ """One org's repo list plus the line describing where it came from.
486
+
487
+ None when neither the network nor the cache produced one. ``clone=False``
488
+ is the network-free path, so it never fetches.
489
+ """
490
+ cached = _read_org_cache(cache_dir, entry.org)
491
+ fetched_at = cached.get("fetched_at") if cached else None
492
+ fresh_enough = (isinstance(fetched_at, (int, float))
493
+ and time.time() - fetched_at < ORG_CACHE_TTL)
494
+
495
+ if not clone:
496
+ if cached is None:
497
+ return None
498
+ return cached["repos"], f"cached listing from {_age(fetched_at or 0)}"
499
+ if cached is not None and fresh_enough and not refresh:
500
+ return cached["repos"], f"listed {_age(fetched_at)}"
501
+
502
+ try:
503
+ repos = _keep_fields(_fetch_org_repos(entry.org, _gh_token()))
504
+ except (OSError, ValueError, urllib.error.HTTPError) as exc:
505
+ reason = getattr(exc, "reason", None) or exc
506
+ if cached is None:
507
+ return None
508
+ log(f"{entry.org}: not listed, using the cached listing from "
509
+ f"{_age(fetched_at or 0)} ({reason})")
510
+ return cached["repos"], f"cached listing from {_age(fetched_at or 0)}"
511
+
512
+ if cached is not None:
513
+ before = {repo.get("name") for repo in _selected(entry, cached["repos"])}
514
+ gone = sorted(before - {repo.get("name") for repo in _selected(entry, repos)})
515
+ if gone:
516
+ log(f"{entry.org}: {len(gone)} repos dropped since the last "
517
+ f"listing: {', '.join(gone)}")
518
+ _write_org_cache(cache_dir, entry.org, entry.topic, repos, log)
519
+ return repos, "listed just now"
520
+
521
+
522
+ def expand_org_sources(sources: Sequence, *, refresh: bool = False, cache_dir,
523
+ root=None, clone: bool = True,
524
+ log: Callable = print) -> List[Source]:
525
+ """Replace every :class:`OrgSource` with the repos it stands for.
526
+
527
+ Each kept repo becomes an ordinary ``Source(name=<repo>, url=<clone_url>)``,
528
+ so resolution, cloning, nesting, discovery and the ``--advertise`` probe all
529
+ work on it unchanged. A repo that carries the topic but turns out to hold no
530
+ tool clones, advertises nothing, and is dropped by the walker as any other
531
+ directory is.
532
+
533
+ An explicit ``[[source]]`` with the same ``name`` wins, so one repo can be
534
+ pinned to a fork or a local checkout while the rest of the org follows the
535
+ listing. A ``path`` in ``installer.local.toml`` naming a listed repo pins it
536
+ the same way, carried here on ``OrgSource.pins`` because the repo it names
537
+ does not exist yet when that file is read.
538
+
539
+ ``clone=False`` is the network-free path the login check takes: it uses the
540
+ cache, then the directories already under ``root``, and never fetches.
541
+ """
542
+ explicit = {source.name for source in sources if isinstance(source, Source)}
543
+ expanded: List[Source] = []
544
+ for source in sources:
545
+ if isinstance(source, Source):
546
+ expanded.append(source)
547
+ continue
548
+ if not isinstance(source, OrgSource):
549
+ continue
550
+ listing = _org_listing(source, refresh=refresh, clone=clone,
551
+ cache_dir=cache_dir, log=log)
552
+ if listing is None:
553
+ derived = _from_root(source, root, log) if root is not None else []
554
+ else:
555
+ repos, provenance = listing
556
+ kept = _selected(source, repos)
557
+ log(f"{source.org}: {len(kept)} repos tagged {source.topic} "
558
+ f"({provenance})")
559
+ derived = [Source(name=repo["name"], url=repo.get("clone_url"))
560
+ for repo in kept]
561
+ # A local pin wins over the clone URL, so the checkout on this machine
562
+ # is used and nothing is fetched. A pin whose directory is gone is left
563
+ # in place: _resolve_one falls through to the URL, which is what the
564
+ # override does for a tracked source too.
565
+ derived = [Source(name=candidate.name, url=candidate.url,
566
+ path=source.pin_for(candidate.name) or candidate.path)
567
+ for candidate in derived]
568
+ for candidate in derived:
569
+ if candidate.name in explicit:
570
+ continue
571
+ url = candidate.url
572
+ if url is not None and not str(url).lower().startswith("https://"):
573
+ log(f"{candidate.name}: {url} is not an https:// URL, skipped")
574
+ continue
575
+ explicit.add(candidate.name)
576
+ expanded.append(candidate)
577
+ return expanded
578
+
579
+
202
580
  # --- resolution -------------------------------------------------------------
203
581
 
204
582
  def _git(*args):
@@ -265,6 +643,10 @@ def _resolve_level(sources: Sequence[Source], root: Path, refresh: bool, log: Ca
265
643
  clone: bool, config_name: str, depth: int,
266
644
  found: List[Path], seen: set) -> None:
267
645
  for source in sources:
646
+ if not isinstance(source, Source):
647
+ # An OrgSource nobody expanded. Ignored rather than fatal, so a
648
+ # caller that skipped expand_org_sources still installs the rest.
649
+ continue
268
650
  path = _resolve_one(source, root, refresh, log, clone)
269
651
  if path is None or path in seen:
270
652
  continue
@@ -458,6 +840,12 @@ def run_installer(config_path, argv=None, default_root_name: str = "tools", **ru
458
840
  pre-discovery hook, which the engine skips on the ``--check`` path, so that
459
841
  check stays network-free and sees whatever is already on disk.
460
842
 
843
+ An ``org`` entry in the sources file is expanded into one source per
844
+ topic-tagged repo before resolution, using the identity's cache directory
845
+ for the listing. That expansion is the only network call this module makes
846
+ besides git, it happens only when such an entry exists, and on the
847
+ ``--check`` path it reads the cache instead of GitHub.
848
+
461
849
  ``default_root_name`` is the folder name the suggestion ends in, so an
462
850
  organisation's installer can suggest ``<cwd>/WW3-tools`` rather than
463
851
  ``<cwd>/tools``.
@@ -482,15 +870,24 @@ def run_installer(config_path, argv=None, default_root_name: str = "tools", **ru
482
870
  except OSError as exc:
483
871
  print(f"{root}: not created ({exc})")
484
872
  sources = load_sources(config_path)
873
+ from .identity import LEGACY_IDENTITY # noqa: PLC0415 — keeps the import graph flat
874
+ identity = run_kwargs.get("identity") or LEGACY_IDENTITY
875
+ cache_dir = identity.cache_path
485
876
 
486
877
  # The engine reads DISCOVERY_ROOTS after the hook has run, so the hook fills
487
878
  # this list in place with what it resolved. It is pre-filled with what is on
488
- # disk already, for the --check path that never calls the hook.
489
- roots = [str(p) for p in resolve_sources(sources, root, clone=False,
490
- log=lambda *_: None)]
879
+ # disk already, for the --check path that never calls the hook — which is
880
+ # why the expansion here is the network-free one.
881
+ quiet = lambda *_: None # noqa: E731
882
+ roots = [str(p) for p in resolve_sources(
883
+ expand_org_sources(sources, cache_dir=cache_dir, root=root, clone=False,
884
+ log=quiet),
885
+ root, clone=False, log=quiet)]
491
886
 
492
887
  def pre_discovery(refresh):
493
- roots[:] = [str(p) for p in resolve_sources(sources, root, refresh=refresh)]
888
+ expanded = expand_org_sources(sources, refresh=refresh, cache_dir=cache_dir,
889
+ root=root)
890
+ roots[:] = [str(p) for p in resolve_sources(expanded, root, refresh=refresh)]
494
891
 
495
892
  from . import gui_installer # noqa: PLC0415 — imports tkinter, keep it lazy
496
893
  run_kwargs.setdefault("root_dir", root)