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/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
+ """