skit-cli 0.0.1__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.
- skit/__init__.py +3 -0
- skit/analyzer.py +410 -0
- skit/argspec.py +528 -0
- skit/argstate.py +166 -0
- skit/atomic.py +101 -0
- skit/cli.py +1621 -0
- skit/config.py +255 -0
- skit/editor.py +77 -0
- skit/flows.py +596 -0
- skit/i18n.py +324 -0
- skit/inlineform.py +61 -0
- skit/launcher.py +326 -0
- skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
- skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
- skit/metawriter.py +267 -0
- skit/models.py +154 -0
- skit/paths.py +44 -0
- skit/pep723.py +292 -0
- skit/promptform.py +67 -0
- skit/reconcile.py +321 -0
- skit/shim.py +386 -0
- skit/store.py +566 -0
- skit/theme.py +123 -0
- skit/tokens.py +112 -0
- skit/tui.py +774 -0
- skit/tui_add.py +361 -0
- skit/tui_footer.py +38 -0
- skit/tui_form.py +586 -0
- skit/tui_health.py +150 -0
- skit/tui_prefs.py +175 -0
- skit/tui_settings.py +374 -0
- skit/uvman.py +288 -0
- skit_cli-0.0.1.dist-info/METADATA +199 -0
- skit_cli-0.0.1.dist-info/RECORD +37 -0
- skit_cli-0.0.1.dist-info/WHEEL +4 -0
- skit_cli-0.0.1.dist-info/entry_points.txt +3 -0
- skit_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
skit/store.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Store + Registry (Layer 0).
|
|
2
|
+
|
|
3
|
+
- Each script directory scripts/<slug>/ carries its own meta.toml (self-describing, C7).
|
|
4
|
+
- registry.toml is only an index; doctor_rebuild() can fully reconstruct it from the metas.
|
|
5
|
+
- All writes go through atomic replace.
|
|
6
|
+
- This module is fully headless and imports no CLI/TUI dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import tomllib
|
|
18
|
+
from collections.abc import Callable, Iterator
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from . import argstate, pep723
|
|
23
|
+
from .atomic import atomic_write_toml
|
|
24
|
+
from .i18n import gettext
|
|
25
|
+
from .models import Entry, Kind, Mode, ScriptMeta, ScriptMetaError, now_iso, slugify
|
|
26
|
+
from .paths import registry_path, scripts_dir
|
|
27
|
+
|
|
28
|
+
# Corruption/error types every meta.toml reader must treat the same way: valid-but-unreadable file,
|
|
29
|
+
# invalid TOML, or valid TOML missing a required key are all "this entry is corrupt" — never a bare
|
|
30
|
+
# KeyError/OSError escaping to a caller that only handles store errors (models.py:64, store.py:210).
|
|
31
|
+
_META_CORRUPTION = (OSError, tomllib.TOMLDecodeError, ScriptMetaError)
|
|
32
|
+
|
|
33
|
+
# Registry read-modify-write lock (concurrency, store.py:181): a portable advisory lockfile via
|
|
34
|
+
# O_CREAT|O_EXCL, with retry + a stale-lock timeout so a crashed holder can't wedge the store
|
|
35
|
+
# forever. skit is a single-user CLI/TUI tool, so contention is rare and short-lived; this closes
|
|
36
|
+
# the remaining race left after the filesystem-truth fix below (_fs_truth) already prevents the
|
|
37
|
+
# worst case (a silent overwrite) even without a lock.
|
|
38
|
+
_LOCK_STALE_SECONDS = 30
|
|
39
|
+
_LOCK_POLL_SECONDS = 0.05
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class StoreError(Exception):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NameConflictError(StoreError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(StoreError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hash_file(path: Path) -> str:
|
|
55
|
+
h = hashlib.sha256()
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
for chunk in iter(lambda: f.read(65536), b""): # pragma: no mutate
|
|
58
|
+
h.update(chunk)
|
|
59
|
+
return f"sha256:{h.hexdigest()}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_meta(entry_dir: Path) -> ScriptMeta:
|
|
63
|
+
with open(entry_dir / "meta.toml", "rb") as f:
|
|
64
|
+
return ScriptMeta.from_toml_dict(tomllib.load(f))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write_meta(entry_dir: Path, meta: ScriptMeta) -> None:
|
|
68
|
+
atomic_write_toml(entry_dir / "meta.toml", meta.to_toml_dict())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_registry() -> dict[str, dict[str, Any]]:
|
|
72
|
+
path = registry_path()
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return {}
|
|
75
|
+
try:
|
|
76
|
+
with open(path, "rb") as f:
|
|
77
|
+
doc = tomllib.load(f)
|
|
78
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
79
|
+
# registry.toml is only a rebuildable index (module docstring), so degrade the same way a
|
|
80
|
+
# missing file already does: an empty registry that `doctor --rebuild` can reconstruct from
|
|
81
|
+
# the untouched scripts/<slug> metas. Preserve the bad bytes instead of discarding them
|
|
82
|
+
# outright — rename (not copy) so a corrupt file can't keep re-triggering this branch (and
|
|
83
|
+
# spawning a fresh backup) on every subsequent read before the next successful write.
|
|
84
|
+
with contextlib.suppress(OSError):
|
|
85
|
+
os.replace(path, path.with_name(f"{path.name}.corrupt"))
|
|
86
|
+
return {}
|
|
87
|
+
return doc.get("entries", {})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _save_registry(entries: dict[str, dict[str, Any]]) -> None:
|
|
91
|
+
atomic_write_toml(registry_path(), {"entries": entries})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextlib.contextmanager
|
|
95
|
+
def _registry_lock() -> Iterator[None]:
|
|
96
|
+
"""Serialize the registry read-modify-write + slug allocation across processes.
|
|
97
|
+
|
|
98
|
+
A portable advisory lock (no fcntl/msvcrt split needed): an exclusive lockfile created with
|
|
99
|
+
O_CREAT|O_EXCL. A holder that never releases it (crashed mid-operation) is reclaimed once the
|
|
100
|
+
lockfile is older than _LOCK_STALE_SECONDS, so a dead process can't wedge the store forever.
|
|
101
|
+
"""
|
|
102
|
+
lock_path = registry_path().with_suffix(".lock")
|
|
103
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
while True:
|
|
105
|
+
try:
|
|
106
|
+
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
107
|
+
except FileExistsError:
|
|
108
|
+
try:
|
|
109
|
+
age = time.time() - lock_path.stat().st_mtime
|
|
110
|
+
except OSError:
|
|
111
|
+
age = _LOCK_STALE_SECONDS + 1 # vanished mid-check; treat as reclaimable
|
|
112
|
+
if age > _LOCK_STALE_SECONDS:
|
|
113
|
+
with contextlib.suppress(OSError):
|
|
114
|
+
lock_path.unlink()
|
|
115
|
+
else:
|
|
116
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
117
|
+
continue
|
|
118
|
+
else:
|
|
119
|
+
os.close(fd)
|
|
120
|
+
break
|
|
121
|
+
try:
|
|
122
|
+
yield
|
|
123
|
+
finally:
|
|
124
|
+
with contextlib.suppress(OSError):
|
|
125
|
+
lock_path.unlink()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _unique_slug(base: str, existing: set[str]) -> str:
|
|
129
|
+
slug = base
|
|
130
|
+
i = 2
|
|
131
|
+
while slug in existing:
|
|
132
|
+
slug = f"{base}-{i}"
|
|
133
|
+
i += 1
|
|
134
|
+
return slug
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _fs_truth(entries: dict[str, dict[str, Any]]) -> tuple[set[str], set[str]]:
|
|
138
|
+
"""(taken slugs, taken names), cross-checked against the on-disk scripts/ directory.
|
|
139
|
+
|
|
140
|
+
registry.toml is only a rebuildable index (module docstring) — trusting it alone for slug
|
|
141
|
+
uniqueness / name-conflict checks means a lost or corrupt registry lets a name/slug collision
|
|
142
|
+
silently overwrite an existing stored script (store.py:187). A directory is only counted as
|
|
143
|
+
"taken" if it actually holds something (has any content): an empty leftover directory (e.g. from
|
|
144
|
+
a process that mkdir'd but crashed before writing anything) claims no slug and stays reusable.
|
|
145
|
+
"""
|
|
146
|
+
slugs = set(entries)
|
|
147
|
+
names = {e["name"] for e in entries.values()}
|
|
148
|
+
root = scripts_dir()
|
|
149
|
+
if not root.is_dir():
|
|
150
|
+
return slugs, names
|
|
151
|
+
for entry_dir in root.iterdir():
|
|
152
|
+
if not entry_dir.is_dir():
|
|
153
|
+
continue
|
|
154
|
+
in_registry = entry_dir.name in entries
|
|
155
|
+
if not in_registry and not any(entry_dir.iterdir()):
|
|
156
|
+
continue # empty, unregistered leftover — nothing to protect, safe to reuse
|
|
157
|
+
slugs.add(entry_dir.name)
|
|
158
|
+
if in_registry:
|
|
159
|
+
continue # its name is already accounted for via the registry row
|
|
160
|
+
try:
|
|
161
|
+
names.add(_read_meta(entry_dir).name)
|
|
162
|
+
except _META_CORRUPTION:
|
|
163
|
+
continue # unreadable; doctor --rebuild will report it, but it can't claim a name here
|
|
164
|
+
return slugs, names
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _extract_description(script_text: str) -> str:
|
|
168
|
+
"""Take the first line of the module docstring as a suggested description (empty on failure)."""
|
|
169
|
+
import ast
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
doc = ast.get_docstring(ast.parse(script_text))
|
|
173
|
+
except SyntaxError:
|
|
174
|
+
return ""
|
|
175
|
+
if not doc:
|
|
176
|
+
return ""
|
|
177
|
+
return doc.strip().splitlines()[0].strip()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def infer_kind(path: Path, force_exe: bool = False) -> str:
|
|
181
|
+
"""What kind of entry a path should become — the tool can see the file type, so
|
|
182
|
+
don't demand a flag: ".py" -> "python", an executable file -> "exe", anything else
|
|
183
|
+
-> "unknown" (callers point at --exe / --cmd). Shared by the CLI and the TUI add
|
|
184
|
+
panel so the two paths can't drift apart."""
|
|
185
|
+
if force_exe:
|
|
186
|
+
return "exe"
|
|
187
|
+
if path.suffix.lower() == ".py":
|
|
188
|
+
return "python"
|
|
189
|
+
if path.is_file() and _is_executable_file(path):
|
|
190
|
+
return "exe"
|
|
191
|
+
return "unknown"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_executable_file(path: Path) -> bool:
|
|
195
|
+
"""Whether `path` is a program this platform would run directly.
|
|
196
|
+
|
|
197
|
+
POSIX has an execute bit, so os.access(X_OK) is the right question there. Windows has none —
|
|
198
|
+
os.access(X_OK) is True for *every* readable file, which would misclassify a plain `notes.txt`
|
|
199
|
+
as an executable — so on Windows a file counts as executable only when its extension is one the
|
|
200
|
+
OS itself treats as runnable, i.e. a member of PATHEXT (.exe/.bat/.cmd/…)."""
|
|
201
|
+
if sys.platform == "win32":
|
|
202
|
+
# PATHEXT is a Windows env var, always ';'-delimited (independent of os.pathsep), listing
|
|
203
|
+
# the extensions the shell will execute; fall back to the conventional default when unset.
|
|
204
|
+
pathext = os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
|
|
205
|
+
runnable = {ext.lower() for ext in pathext.split(";") if ext}
|
|
206
|
+
return path.suffix.lower() in runnable
|
|
207
|
+
return os.access(path, os.X_OK)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def suggest_description(script_text: str) -> str:
|
|
211
|
+
"""Public: the description skit would auto-derive from a script (its docstring's first line, or
|
|
212
|
+
empty). Used by the interactive `add` prompt to prefill a suggested description."""
|
|
213
|
+
return _extract_description(script_text)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def add_python(
|
|
217
|
+
source: Path,
|
|
218
|
+
*,
|
|
219
|
+
name: str | None = None,
|
|
220
|
+
mode: Mode = "copy",
|
|
221
|
+
description: str | None = None,
|
|
222
|
+
workdir: str | None = None,
|
|
223
|
+
dependencies: list[str] | None = None,
|
|
224
|
+
requires_python: str = "",
|
|
225
|
+
) -> Entry:
|
|
226
|
+
source = source.expanduser().resolve()
|
|
227
|
+
if not source.is_file():
|
|
228
|
+
raise StoreError(gettext("File not found: %(path)s") % {"path": str(source)})
|
|
229
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
230
|
+
final_name = name or source.stem
|
|
231
|
+
desc = description if description is not None else _extract_description(text)
|
|
232
|
+
# copy mode: dependency completion is written into the copy's PEP 723 block (comment-only, A5
|
|
233
|
+
# compliant), so the copy is portable — but only when the source is strict-UTF-8: re-encoding a
|
|
234
|
+
# lossy `errors="replace"` decode back to disk would corrupt any non-UTF-8 byte in the copy
|
|
235
|
+
# (store.py:130). A source that doesn't decode cleanly falls back to recording the deps in meta
|
|
236
|
+
# instead (same as reference mode) and leaves the copy byte-exact.
|
|
237
|
+
try:
|
|
238
|
+
strict_text: str | None = source.read_bytes().decode("utf-8")
|
|
239
|
+
except UnicodeDecodeError:
|
|
240
|
+
strict_text = None
|
|
241
|
+
# reference mode: never touch the original; record in meta, and launcher passes it via
|
|
242
|
+
# --with/--python.
|
|
243
|
+
after_copy: Callable[[Path], None] | None = None
|
|
244
|
+
deps_injected = False
|
|
245
|
+
if (
|
|
246
|
+
mode == "copy"
|
|
247
|
+
and (dependencies or requires_python)
|
|
248
|
+
and strict_text is not None
|
|
249
|
+
and not pep723.has_block(strict_text)
|
|
250
|
+
):
|
|
251
|
+
injected_text = pep723.inject_block(strict_text, dependencies or [], requires_python)
|
|
252
|
+
|
|
253
|
+
def _write_injected(entry_dir: Path) -> None:
|
|
254
|
+
(entry_dir / "script.py").write_text(injected_text, encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
after_copy = _write_injected
|
|
257
|
+
deps_injected = True
|
|
258
|
+
if mode == "reference":
|
|
259
|
+
resolved_workdir = "origin"
|
|
260
|
+
elif workdir is not None:
|
|
261
|
+
resolved_workdir = workdir
|
|
262
|
+
else:
|
|
263
|
+
# Copy mode exists specifically to decouple the entry from its original location, so its
|
|
264
|
+
# default workdir must not depend on that location either (the gap: a copy-mode script
|
|
265
|
+
# could never run again once its source directory was gone, even though the store copy was
|
|
266
|
+
# intact). "invoke" (the caller's cwd at run time) always exists and mirrors add_command's
|
|
267
|
+
# existing default for the same reason (store.py add_command); "store" (entry.dir) holds
|
|
268
|
+
# only script.py + meta.toml, with no reason to assume a script's relative file operations
|
|
269
|
+
# target it.
|
|
270
|
+
resolved_workdir = "invoke"
|
|
271
|
+
meta = ScriptMeta(
|
|
272
|
+
name=final_name,
|
|
273
|
+
kind="python",
|
|
274
|
+
mode=mode,
|
|
275
|
+
source=str(source),
|
|
276
|
+
source_hash=_hash_file(source),
|
|
277
|
+
added_at=now_iso(),
|
|
278
|
+
workdir=resolved_workdir,
|
|
279
|
+
description=desc,
|
|
280
|
+
dependencies=None if deps_injected else (dependencies or None),
|
|
281
|
+
requires_python="" if deps_injected else requires_python,
|
|
282
|
+
)
|
|
283
|
+
return _add_entry(meta, payload=source if mode == "copy" else None, after_copy=after_copy)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def add_exe(source: Path, *, name: str | None = None, description: str = "") -> Entry:
|
|
287
|
+
source = source.expanduser().resolve()
|
|
288
|
+
if not source.exists():
|
|
289
|
+
raise StoreError(gettext("File not found: %(path)s") % {"path": str(source)})
|
|
290
|
+
meta = ScriptMeta(
|
|
291
|
+
name=name or source.stem,
|
|
292
|
+
kind="exe",
|
|
293
|
+
mode="reference", # exe is always reference; we never copy the binary
|
|
294
|
+
source=str(source),
|
|
295
|
+
source_hash=_hash_file(source) if source.is_file() else "",
|
|
296
|
+
added_at=now_iso(),
|
|
297
|
+
description=description,
|
|
298
|
+
)
|
|
299
|
+
meta.workdir = "origin" # pragma: no mutate — explicit default, self-describing call site
|
|
300
|
+
return _add_entry(meta, payload=None)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def extract_placeholders(template: str) -> list[str]:
|
|
304
|
+
"""Extract {name} placeholders (deduped by order of appearance; {{ }} is an escape, ignored)."""
|
|
305
|
+
import re
|
|
306
|
+
|
|
307
|
+
seen: list[str] = []
|
|
308
|
+
for m in re.finditer(r"(?<!\{)\{([a-zA-Z_][a-zA-Z0-9_]*)\}(?!\})", template):
|
|
309
|
+
if m.group(1) not in seen:
|
|
310
|
+
seen.append(m.group(1))
|
|
311
|
+
return seen
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def add_command(template: str, *, name: str, description: str = "") -> Entry:
|
|
315
|
+
if not template.strip():
|
|
316
|
+
raise StoreError(gettext("Command template must not be empty"))
|
|
317
|
+
placeholders = extract_placeholders(template)
|
|
318
|
+
meta = ScriptMeta(
|
|
319
|
+
name=name,
|
|
320
|
+
kind="command",
|
|
321
|
+
mode="reference",
|
|
322
|
+
added_at=now_iso(),
|
|
323
|
+
workdir="invoke",
|
|
324
|
+
description=description,
|
|
325
|
+
template=template,
|
|
326
|
+
params=placeholders or None,
|
|
327
|
+
)
|
|
328
|
+
meta.source = "" # pragma: no mutate — explicit default, self-describing call site
|
|
329
|
+
return _add_entry(meta, payload=None)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _add_entry(
|
|
333
|
+
meta: ScriptMeta,
|
|
334
|
+
*,
|
|
335
|
+
payload: Path | None,
|
|
336
|
+
after_copy: Callable[[Path], None] | None = None,
|
|
337
|
+
) -> Entry:
|
|
338
|
+
with _registry_lock():
|
|
339
|
+
entries = _load_registry()
|
|
340
|
+
existing_slugs, existing_names = _fs_truth(entries)
|
|
341
|
+
if meta.name in existing_names:
|
|
342
|
+
raise NameConflictError(
|
|
343
|
+
gettext("The name %(name)s is already taken (use --name to pick another)")
|
|
344
|
+
% {"name": meta.name}
|
|
345
|
+
)
|
|
346
|
+
slug = _unique_slug(slugify(meta.name), existing_slugs)
|
|
347
|
+
entry_dir = scripts_dir() / slug
|
|
348
|
+
if entry_dir.exists() and any(entry_dir.iterdir()):
|
|
349
|
+
# Defense in depth: _fs_truth already excludes any non-empty existing directory from
|
|
350
|
+
# the slug candidates above, so this should be unreachable — but never silently reuse
|
|
351
|
+
# (and overwrite) a directory that actually holds a stored script (store.py:187).
|
|
352
|
+
raise StoreError(
|
|
353
|
+
gettext("Refusing to reuse the existing, non-empty entry directory: %(path)s")
|
|
354
|
+
% {"path": str(entry_dir)}
|
|
355
|
+
)
|
|
356
|
+
entry_dir.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
try:
|
|
358
|
+
if payload is not None:
|
|
359
|
+
# copy mode: copy the original verbatim (A5: never land a processed script)
|
|
360
|
+
shutil.copy2(payload, entry_dir / "script.py")
|
|
361
|
+
_write_meta(entry_dir, meta)
|
|
362
|
+
if after_copy is not None:
|
|
363
|
+
after_copy(entry_dir)
|
|
364
|
+
except BaseException:
|
|
365
|
+
shutil.rmtree(entry_dir, ignore_errors=True)
|
|
366
|
+
raise
|
|
367
|
+
entries[slug] = {"name": meta.name, "kind": meta.kind, "description": meta.description}
|
|
368
|
+
_save_registry(entries)
|
|
369
|
+
return Entry(slug=slug, meta=meta, dir=entry_dir)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def list_entries() -> list[Entry]:
|
|
373
|
+
entries = _load_registry()
|
|
374
|
+
out: list[Entry] = []
|
|
375
|
+
for slug in sorted(entries):
|
|
376
|
+
entry_dir = scripts_dir() / slug
|
|
377
|
+
try:
|
|
378
|
+
meta = _read_meta(entry_dir)
|
|
379
|
+
except _META_CORRUPTION:
|
|
380
|
+
continue # leave corrupt entries for doctor to handle
|
|
381
|
+
out.append(Entry(slug=slug, meta=meta, dir=entry_dir))
|
|
382
|
+
return out
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def resolve(name_or_slug: str) -> Entry:
|
|
386
|
+
entries = _load_registry()
|
|
387
|
+
slug = None
|
|
388
|
+
if name_or_slug in entries:
|
|
389
|
+
slug = name_or_slug
|
|
390
|
+
else:
|
|
391
|
+
matches = [s for s, e in entries.items() if e["name"] == name_or_slug]
|
|
392
|
+
if len(matches) == 1:
|
|
393
|
+
slug = matches[0]
|
|
394
|
+
if slug is None:
|
|
395
|
+
raise NotFoundError(gettext("Script not found: %(name)s") % {"name": name_or_slug})
|
|
396
|
+
entry_dir = scripts_dir() / slug
|
|
397
|
+
try:
|
|
398
|
+
meta = _read_meta(entry_dir)
|
|
399
|
+
except _META_CORRUPTION as exc:
|
|
400
|
+
raise NotFoundError(
|
|
401
|
+
gettext("%(name)s: metadata is corrupt (%(error)s); run skit doctor --rebuild")
|
|
402
|
+
% {"name": name_or_slug, "error": str(exc)}
|
|
403
|
+
) from exc
|
|
404
|
+
return Entry(slug=slug, meta=meta, dir=entry_dir)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def remove(name_or_slug: str) -> str:
|
|
408
|
+
entry = resolve(name_or_slug)
|
|
409
|
+
with _registry_lock():
|
|
410
|
+
entries = _load_registry()
|
|
411
|
+
entries.pop(entry.slug, None) # pragma: no mutate — TOCTOU defense, kept deliberately
|
|
412
|
+
_save_registry(entries)
|
|
413
|
+
shutil.rmtree(entry.dir, ignore_errors=True)
|
|
414
|
+
argstate.forget(entry.slug) # drop the last-used values too
|
|
415
|
+
return entry.meta.name
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def update_dependencies(
|
|
419
|
+
name_or_slug: str,
|
|
420
|
+
dependencies: list[str],
|
|
421
|
+
requires_python: str | None = None,
|
|
422
|
+
) -> Entry:
|
|
423
|
+
"""Update an entry's dependency record (meta.toml). In copy mode, also sync the copy's PEP 723
|
|
424
|
+
block; in reference mode, only touch meta (the original can't be written, A7) and pass it via
|
|
425
|
+
--with at run time."""
|
|
426
|
+
entry = resolve(name_or_slug)
|
|
427
|
+
meta = entry.meta
|
|
428
|
+
meta.dependencies = dependencies or None
|
|
429
|
+
if requires_python is not None:
|
|
430
|
+
meta.requires_python = requires_python or ""
|
|
431
|
+
_write_meta(entry.dir, meta)
|
|
432
|
+
if meta.kind == "python" and meta.mode == "copy": # pragma: no mutate — and/or equivalent
|
|
433
|
+
from . import pep723
|
|
434
|
+
|
|
435
|
+
script = entry.dir / "script.py"
|
|
436
|
+
if script.exists():
|
|
437
|
+
text = script.read_text(encoding="utf-8", errors="replace")
|
|
438
|
+
script.write_text(
|
|
439
|
+
pep723.set_dependencies(
|
|
440
|
+
text, dependencies, requires_python=meta.requires_python or ""
|
|
441
|
+
),
|
|
442
|
+
encoding="utf-8",
|
|
443
|
+
)
|
|
444
|
+
return Entry(slug=entry.slug, meta=meta, dir=entry.dir)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def rename(name_or_slug: str, new_name: str) -> Entry:
|
|
448
|
+
"""Rename an entry's display name. The slug is immutable after add — it keys the
|
|
449
|
+
entry directory and the argstate values file, so keeping it means nothing moves on
|
|
450
|
+
disk and remembered values/presets survive the rename."""
|
|
451
|
+
entry = resolve(name_or_slug)
|
|
452
|
+
new_name = new_name.strip()
|
|
453
|
+
if not new_name:
|
|
454
|
+
raise StoreError(gettext("A name is required."))
|
|
455
|
+
try:
|
|
456
|
+
other = resolve(new_name)
|
|
457
|
+
except NotFoundError:
|
|
458
|
+
other = None
|
|
459
|
+
if other is not None and other.slug != entry.slug:
|
|
460
|
+
raise StoreError(gettext("The name %(name)s is already taken.") % {"name": new_name})
|
|
461
|
+
meta = entry.meta
|
|
462
|
+
meta.name = new_name
|
|
463
|
+
_write_meta(entry.dir, meta)
|
|
464
|
+
with _registry_lock():
|
|
465
|
+
entries = _load_registry()
|
|
466
|
+
row = entries.get(entry.slug)
|
|
467
|
+
if row is not None:
|
|
468
|
+
row["name"] = new_name
|
|
469
|
+
_save_registry(entries)
|
|
470
|
+
return Entry(slug=entry.slug, meta=meta, dir=entry.dir)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def update_description(name_or_slug: str, description: str) -> Entry:
|
|
474
|
+
"""Update an entry's description (meta.toml is the truth; the registry index row is
|
|
475
|
+
refreshed too so `list` doesn't need a rebuild to show it)."""
|
|
476
|
+
entry = resolve(name_or_slug)
|
|
477
|
+
meta = entry.meta
|
|
478
|
+
meta.description = description
|
|
479
|
+
_write_meta(entry.dir, meta)
|
|
480
|
+
with _registry_lock():
|
|
481
|
+
entries = _load_registry()
|
|
482
|
+
row = entries.get(entry.slug)
|
|
483
|
+
if row is not None:
|
|
484
|
+
row["description"] = description
|
|
485
|
+
_save_registry(entries)
|
|
486
|
+
return Entry(slug=entry.slug, meta=meta, dir=entry.dir)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def doctor_rebuild() -> tuple[int, list[str]]:
|
|
490
|
+
"""Rebuild the registry from each scripts/<slug>/meta.toml.
|
|
491
|
+
|
|
492
|
+
Returns (count rebuilt, problems).
|
|
493
|
+
"""
|
|
494
|
+
problems: list[str] = []
|
|
495
|
+
entries: dict[str, dict[str, Any]] = {}
|
|
496
|
+
with _registry_lock():
|
|
497
|
+
root = scripts_dir()
|
|
498
|
+
if root.exists():
|
|
499
|
+
for entry_dir in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
500
|
+
try:
|
|
501
|
+
meta = _read_meta(entry_dir)
|
|
502
|
+
except FileNotFoundError:
|
|
503
|
+
problems.append(
|
|
504
|
+
gettext("%(slug)s: meta.toml is missing; skipped")
|
|
505
|
+
% {"slug": entry_dir.name}
|
|
506
|
+
)
|
|
507
|
+
continue
|
|
508
|
+
except _META_CORRUPTION as exc:
|
|
509
|
+
problems.append(
|
|
510
|
+
gettext("%(slug)s: meta.toml is corrupt (%(error)s); skipped")
|
|
511
|
+
% {"slug": entry_dir.name, "error": str(exc)}
|
|
512
|
+
)
|
|
513
|
+
continue
|
|
514
|
+
if meta.mode == "reference" and meta.source and not Path(meta.source).exists():
|
|
515
|
+
problems.append(
|
|
516
|
+
gettext("%(slug)s: the referenced source file is gone: %(path)s")
|
|
517
|
+
% {"slug": entry_dir.name, "path": meta.source}
|
|
518
|
+
)
|
|
519
|
+
entries[entry_dir.name] = {
|
|
520
|
+
"name": meta.name,
|
|
521
|
+
"kind": meta.kind,
|
|
522
|
+
"description": meta.description,
|
|
523
|
+
}
|
|
524
|
+
_save_registry(entries)
|
|
525
|
+
return len(entries), problems
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# Type re-exports, so callers upstream only need to import store.
|
|
529
|
+
def dir_size(path: Path) -> int:
|
|
530
|
+
"""Total bytes of the files under a directory (0 if it doesn't exist). The library
|
|
531
|
+
disk-usage figure the health check shows — shared by `skit doctor` and the TUI."""
|
|
532
|
+
total = 0
|
|
533
|
+
if path.is_dir():
|
|
534
|
+
for p in path.rglob("*"):
|
|
535
|
+
if p.is_file():
|
|
536
|
+
total += p.stat().st_size
|
|
537
|
+
return total
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def human_size(size: int) -> str:
|
|
541
|
+
"""Bytes as a compact human string (B/KB/MB/GB)."""
|
|
542
|
+
value = float(size)
|
|
543
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
544
|
+
if value < 1024 or unit == "GB":
|
|
545
|
+
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
|
|
546
|
+
value /= 1024
|
|
547
|
+
return f"{value:.1f} GB" # pragma: no cover — loop always returns
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
__all__ = [
|
|
551
|
+
"Entry",
|
|
552
|
+
"Kind",
|
|
553
|
+
"NameConflictError",
|
|
554
|
+
"NotFoundError",
|
|
555
|
+
"ScriptMeta",
|
|
556
|
+
"StoreError",
|
|
557
|
+
"add_command",
|
|
558
|
+
"add_exe",
|
|
559
|
+
"add_python",
|
|
560
|
+
"dir_size",
|
|
561
|
+
"doctor_rebuild",
|
|
562
|
+
"human_size",
|
|
563
|
+
"list_entries",
|
|
564
|
+
"remove",
|
|
565
|
+
"resolve",
|
|
566
|
+
]
|
skit/theme.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The skit Textual theme: btop-flavored, terminal-native.
|
|
2
|
+
|
|
3
|
+
Design contract:
|
|
4
|
+
- The canvas stays `ansi_default` (the user's terminal background — transparency and
|
|
5
|
+
all — shows through, like btop with theme_background off), but everything skit draws
|
|
6
|
+
ON that canvas uses a controlled btop-style palette: rounded panel borders in muted
|
|
7
|
+
per-panel tints, near-white titles, a dark warm selection bar, and one warm accent
|
|
8
|
+
(terracotta) for keys and focus.
|
|
9
|
+
- `ansi=True` disables Textual's ANSI→truecolor filter so default colors pass through
|
|
10
|
+
natively — BUT the ansi color system also hard-defaults links, scrollbars and table
|
|
11
|
+
headers to ANSI blue, which is unreadable noise on a dark terminal. Every one of
|
|
12
|
+
those is overridden below (variables) or in CHROME_CSS (component styles).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from textual.theme import Theme
|
|
18
|
+
|
|
19
|
+
# Anthropic terracotta — the one warm accent (close kin to btop's hi_fg red).
|
|
20
|
+
ACCENT = "#D97757"
|
|
21
|
+
ACCENT_DIM = "#B05C3F"
|
|
22
|
+
|
|
23
|
+
# Selection: btop highlights the current row with a dark tinted bar + bright text,
|
|
24
|
+
# never a solid accent bar (accent-on-black text stays readable, the bar stays calm).
|
|
25
|
+
SELECT_BG = "#5A2D1E"
|
|
26
|
+
SELECT_FG = "#EEEEEE"
|
|
27
|
+
|
|
28
|
+
# btop's signature: each panel wears its own muted border tint (cpu green, mem olive,
|
|
29
|
+
# net indigo, proc maroon). skit maps them to its own surfaces.
|
|
30
|
+
BOX_GREEN = "#3D7B46" # Library list
|
|
31
|
+
BOX_OLIVE = "#8A882E" # add flow
|
|
32
|
+
BOX_INDIGO = "#4B44B0" # detail pane, settings, preferences
|
|
33
|
+
BOX_MAROON = "#923535" # run form, destructive confirms
|
|
34
|
+
BOX_DIM = "#3A3A3A" # idle input borders / dividers
|
|
35
|
+
|
|
36
|
+
# Exposed to screen CSS as $skit-box-*. Apps must merge these into get_css_variables():
|
|
37
|
+
# the first stylesheet parse happens BEFORE on_mount activates the skit theme, and an
|
|
38
|
+
# unresolved variable is a startup crash, not a fallback.
|
|
39
|
+
BOX_VARIABLES = {
|
|
40
|
+
"skit-box-green": BOX_GREEN,
|
|
41
|
+
"skit-box-olive": BOX_OLIVE,
|
|
42
|
+
"skit-box-indigo": BOX_INDIGO,
|
|
43
|
+
"skit-box-maroon": BOX_MAROON,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
CLAUDE_THEME = Theme(
|
|
47
|
+
name="skit-claude",
|
|
48
|
+
primary=ACCENT,
|
|
49
|
+
secondary=ACCENT_DIM,
|
|
50
|
+
accent=ACCENT,
|
|
51
|
+
warning="ansi_yellow",
|
|
52
|
+
error="ansi_red",
|
|
53
|
+
success="ansi_green",
|
|
54
|
+
foreground="ansi_default",
|
|
55
|
+
background="ansi_default",
|
|
56
|
+
surface="ansi_default",
|
|
57
|
+
panel="ansi_default",
|
|
58
|
+
boost="ansi_default",
|
|
59
|
+
dark=True,
|
|
60
|
+
variables={
|
|
61
|
+
# The full variable set an ansi theme must provide (Screen's builtin CSS reads
|
|
62
|
+
# ansi-background/-foreground; the rest mirror the builtin ansi-dark theme).
|
|
63
|
+
"ansi-background": "ansi_black",
|
|
64
|
+
"ansi-foreground": "ansi_white",
|
|
65
|
+
"border-blurred": BOX_DIM,
|
|
66
|
+
# $border is what every :focus border falls back to (the ansi system would say
|
|
67
|
+
# magenta); focus always speaks accent.
|
|
68
|
+
"border": ACCENT,
|
|
69
|
+
# Selection bar: dark terracotta + bright text (btop's selected_bg pattern) —
|
|
70
|
+
# the old accent-background bar drowned the row it was meant to point at.
|
|
71
|
+
"block-cursor-foreground": SELECT_FG,
|
|
72
|
+
"block-cursor-background": SELECT_BG,
|
|
73
|
+
"block-cursor-text-style": "bold",
|
|
74
|
+
"block-cursor-blurred-foreground": "ansi_default",
|
|
75
|
+
"block-cursor-blurred-background": "#33231C",
|
|
76
|
+
"block-hover-background": "#33231C",
|
|
77
|
+
"input-cursor-background": "ansi_black",
|
|
78
|
+
"input-cursor-foreground": "ansi_bright_white",
|
|
79
|
+
"input-cursor-text-style": "none",
|
|
80
|
+
"input-selection-background": ACCENT_DIM,
|
|
81
|
+
"input-selection-foreground": "ansi_black",
|
|
82
|
+
"screen-selection-background": ACCENT_DIM,
|
|
83
|
+
"screen-selection-foreground": "ansi_black",
|
|
84
|
+
"footer-key-foreground": ACCENT,
|
|
85
|
+
# Action links (footer chips, ▾insert, modal buttons): the ansi system paints
|
|
86
|
+
# them underlined blue, which reads as a 1998 hyperlink. Textual applies link-color
|
|
87
|
+
# uniformly to the whole @click span, overriding any inline color — so an inline
|
|
88
|
+
# [$accent] on the chip's key can't win. Set link-color TO the accent: the key
|
|
89
|
+
# (which keeps its `bold`) reads as bold-terracotta, the label as plain terracotta,
|
|
90
|
+
# and the whole pill stays one clickable button. This also matches the ▾insert link,
|
|
91
|
+
# which already intends accent. Hover brightens the whole pill.
|
|
92
|
+
"link-color": ACCENT,
|
|
93
|
+
"link-style": "none",
|
|
94
|
+
"link-color-hover": "ansi_bright_white",
|
|
95
|
+
"link-background-hover": "#4A2A1D",
|
|
96
|
+
"link-style-hover": "none",
|
|
97
|
+
# Panel tints, exposed to screen CSS as $skit-box-*.
|
|
98
|
+
**BOX_VARIABLES,
|
|
99
|
+
# Scrollbars: warm dark rail instead of ansi blue.
|
|
100
|
+
"scrollbar": "#4A413C",
|
|
101
|
+
"scrollbar-hover": ACCENT_DIM,
|
|
102
|
+
"scrollbar-active": ACCENT,
|
|
103
|
+
"scrollbar-background": "ansi_default",
|
|
104
|
+
"scrollbar-background-hover": "ansi_default",
|
|
105
|
+
"scrollbar-background-active": "ansi_default",
|
|
106
|
+
},
|
|
107
|
+
ansi=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Component chrome no theme variable reaches, shared by every skit App (the workbench
|
|
111
|
+
# and the CLI's inline form). btop grammar: rounded borders, titles ON the border,
|
|
112
|
+
# bold near-white table headers on the bare canvas — never a colored header bar.
|
|
113
|
+
CHROME_CSS = """
|
|
114
|
+
Screen { background: ansi_default; }
|
|
115
|
+
/* The ansi color system pins the table header to ansi_bright_blue (unreadable and
|
|
116
|
+
loud on a dark terminal); btop headers are just bold bright text on the canvas. */
|
|
117
|
+
DataTable:ansi > .datatable--header,
|
|
118
|
+
DataTable > .datatable--header { background: ansi_default; color: ansi_bright_white; text-style: bold; }
|
|
119
|
+
Input { border: round $border-blurred; }
|
|
120
|
+
Input:focus { border: round $accent; }
|
|
121
|
+
Select > SelectCurrent { border: round $border-blurred; }
|
|
122
|
+
Select:focus > SelectCurrent { border: round $accent; }
|
|
123
|
+
"""
|