hueman-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hueman/__init__.py +11 -0
- hueman/__main__.py +3 -0
- hueman/cli.py +353 -0
- hueman/data/hueman.config.example.yaml +111 -0
- hueman/engine/__init__.py +0 -0
- hueman/engine/colors.py +30 -0
- hueman/engine/config.py +186 -0
- hueman/engine/fsutil.py +41 -0
- hueman/engine/palettes.py +66 -0
- hueman/engine/reload.py +60 -0
- hueman/engine/render.py +243 -0
- hueman/engine/statesource.py +370 -0
- hueman/engine/targets.py +95 -0
- hueman/palettes/arctic-ice.yaml +102 -0
- hueman/palettes/autumn.yaml +102 -0
- hueman/palettes/catppuccin-latte.yaml +102 -0
- hueman/palettes/catppuccin-mocha.yaml +102 -0
- hueman/palettes/copperwave.yaml +103 -0
- hueman/palettes/crimson-vivid.yaml +111 -0
- hueman/palettes/dracula.yaml +102 -0
- hueman/palettes/ember-red.yaml +111 -0
- hueman/palettes/everforest-dark.yaml +102 -0
- hueman/palettes/galaxy.yaml +102 -0
- hueman/palettes/gruvbox-light.yaml +102 -0
- hueman/palettes/gruvbox.yaml +102 -0
- hueman/palettes/kanagawa-dragon.yaml +102 -0
- hueman/palettes/matrix.yaml +102 -0
- hueman/palettes/mono-contrast.yaml +102 -0
- hueman/palettes/nord.yaml +102 -0
- hueman/palettes/ocean-deep.yaml +102 -0
- hueman/palettes/one-light.yaml +102 -0
- hueman/palettes/onyx-gold.yaml +103 -0
- hueman/palettes/rose-pine-dawn.yaml +102 -0
- hueman/palettes/solarized-light.yaml +102 -0
- hueman/palettes/synthwave.yaml +102 -0
- hueman/palettes/tokyonight.yaml +106 -0
- hueman/palettes/tuxedo.yaml +103 -0
- hueman/palettes/vaporwave.yaml +102 -0
- hueman/resources.py +18 -0
- hueman/templates/atuin_theme.toml.j2 +19 -0
- hueman/templates/bottom_theme.toml.j2 +43 -0
- hueman/templates/bspwm_colors.conf.j2 +5 -0
- hueman/templates/claude_statusline.sh.j2 +216 -0
- hueman/templates/dunst_colors.conf.j2 +15 -0
- hueman/templates/fzf_opts.j2 +9 -0
- hueman/templates/fzf_theme.j2 +17 -0
- hueman/templates/herdr_theme_custom.toml.j2 +11 -0
- hueman/templates/k9s_theme.yaml.j2 +83 -0
- hueman/templates/kitty.conf.j2 +51 -0
- hueman/templates/lazydocker_theme.yml.j2 +19 -0
- hueman/templates/leaf_theme.toml.j2 +89 -0
- hueman/templates/newt_colors.j2 +32 -0
- hueman/templates/nvim_nightfox_theme.lua.j2 +70 -0
- hueman/templates/omp.omp.json.j2 +107 -0
- hueman/templates/opencode_theme.json.j2 +71 -0
- hueman/templates/pi_theme.json.j2 +65 -0
- hueman/templates/polybar_colors.ini.j2 +11 -0
- hueman/templates/posting_theme.yaml.j2 +47 -0
- hueman/templates/qutebrowser_github.user.js.j2 +121 -0
- hueman/templates/qutebrowser_google.user.js.j2 +132 -0
- hueman/templates/qutebrowser_hteam.user.js.j2 +962 -0
- hueman/templates/qutebrowser_theme.py.j2 +78 -0
- hueman/templates/rofi_theme.rasi.j2 +132 -0
- hueman/templates/sshub_theme.toml.j2 +49 -0
- hueman/templates/starship_block.toml.j2 +11 -0
- hueman/templates/termux_colors.properties.j2 +20 -0
- hueman/templates/tmux.conf.j2 +33 -0
- hueman/templates/tuicr_theme.toml.j2 +51 -0
- hueman/templates/wezterm_colors.lua.j2 +14 -0
- hueman/templates/windhawk_taskbar.json.j2 +4 -0
- hueman/templates/yazi_theme.toml.j2 +130 -0
- hueman/templates/zen_theme.json.j2 +11 -0
- hueman_cli-0.2.0.dist-info/METADATA +119 -0
- hueman_cli-0.2.0.dist-info/RECORD +78 -0
- hueman_cli-0.2.0.dist-info/WHEEL +5 -0
- hueman_cli-0.2.0.dist-info/entry_points.txt +2 -0
- hueman_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- hueman_cli-0.2.0.dist-info/top_level.txt +1 -0
hueman/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""hueman: gestor centralizado de temas basado en paletas YAML y plantillas Jinja2."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Nombre de la distribución en PyPI (único lugar a cambiar junto con pyproject.toml).
|
|
7
|
+
__version__ = version("hueman-cli")
|
|
8
|
+
except PackageNotFoundError: # checkout sin instalar
|
|
9
|
+
__version__ = "0+unknown"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
hueman/__main__.py
ADDED
hueman/cli.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Gestor centralizado de temas (standalone): una paleta por nombre en
|
|
2
|
+
palettes/*.yaml (empaquetadas o custom) alimenta templates Jinja2 que
|
|
3
|
+
generan la config real de cada herramienta declarada en un
|
|
4
|
+
hueman.config.yaml propio del usuario. No depende de ningún gestor de
|
|
5
|
+
dotfiles externo — todo el mapeo de rutas es explícito en ese config.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import fnmatch
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .engine.config import VAR_NAME_RE, ConfigError, HuemanConfig
|
|
16
|
+
from .engine.fsutil import atomic_write_text
|
|
17
|
+
from .engine.palettes import PaletteLoader
|
|
18
|
+
from .engine.reload import PlaceholderError, expand_command, run_post_apply, run_reload
|
|
19
|
+
from .engine.statesource import ReadResult, State, build_source
|
|
20
|
+
from .engine.render import TargetRenderError, make_jinja_env, render_target
|
|
21
|
+
from .resources import packaged_example_config, packaged_palettes_dir, packaged_templates_dir
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnknownTargetError(ValueError):
|
|
25
|
+
"""Un patron de --only no coincide con ningun target."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Hueman:
|
|
29
|
+
def __init__(self, config: HuemanConfig):
|
|
30
|
+
self.config = config
|
|
31
|
+
self.palettes = PaletteLoader(packaged_palettes_dir(), config.palette_dirs, config.logger)
|
|
32
|
+
self._jinja_envs = {}
|
|
33
|
+
|
|
34
|
+
def _jinja_env(self, delimiters):
|
|
35
|
+
key = delimiters or ()
|
|
36
|
+
if key not in self._jinja_envs:
|
|
37
|
+
self._jinja_envs[key] = make_jinja_env(
|
|
38
|
+
self.config.template_dirs, packaged_templates_dir(), delimiters
|
|
39
|
+
)
|
|
40
|
+
return self._jinja_envs[key]
|
|
41
|
+
|
|
42
|
+
def list_palettes(self) -> list:
|
|
43
|
+
return self.palettes.list_palettes()
|
|
44
|
+
|
|
45
|
+
def current_theme(self):
|
|
46
|
+
return self.config.state_file.read_text().strip() if self.config.state_file.exists() else None
|
|
47
|
+
|
|
48
|
+
def _select_targets(self, only):
|
|
49
|
+
targets = self.config.targets
|
|
50
|
+
if not only:
|
|
51
|
+
return targets
|
|
52
|
+
names = [t.name for t in targets]
|
|
53
|
+
missing = [p for p in only if not fnmatch.filter(names, p)]
|
|
54
|
+
if missing:
|
|
55
|
+
raise UnknownTargetError(
|
|
56
|
+
f"--only sin coincidencias: {', '.join(missing)}. "
|
|
57
|
+
f"Targets validos: {', '.join(names)}"
|
|
58
|
+
)
|
|
59
|
+
return [t for t in targets if any(fnmatch.fnmatchcase(t.name, p) for p in only)]
|
|
60
|
+
|
|
61
|
+
def set_theme(self, name: str, dry_run: bool = False, only=None, publish: bool = True) -> list:
|
|
62
|
+
"""Con `only` (patrones fnmatch) se renderizan solo los targets que
|
|
63
|
+
coinciden y se dispara solo su reload; NO se escribe el state file ni
|
|
64
|
+
corre post_apply (es una re-renderizacion parcial, no un cambio de tema).
|
|
65
|
+
|
|
66
|
+
Un target cuyo render falla por una clave ausente en la paleta se
|
|
67
|
+
omite con un warning y el resto continua; devuelve los nombres de los
|
|
68
|
+
targets fallidos (el CLI sale con codigo 1 si hay alguno).
|
|
69
|
+
|
|
70
|
+
`publish=False` (usado por `sync`) aplica sin publicar estado nuevo."""
|
|
71
|
+
selected = self._select_targets(only)
|
|
72
|
+
palette = self.palettes.load(name)
|
|
73
|
+
# Validar placeholders de reload/post_apply antes de escribir nada.
|
|
74
|
+
for target in selected:
|
|
75
|
+
if target.reload:
|
|
76
|
+
expand_command(target.reload, name, palette)
|
|
77
|
+
post_apply = None
|
|
78
|
+
if not only:
|
|
79
|
+
post_apply = [expand_command(c, name, palette) for c in self.config.post_apply]
|
|
80
|
+
failed = []
|
|
81
|
+
for target in selected:
|
|
82
|
+
env = self._jinja_env(target.jinja_delimiters)
|
|
83
|
+
try:
|
|
84
|
+
wrote = render_target(env, target, name, palette, dry_run, self.config.logger)
|
|
85
|
+
except TargetRenderError as exc:
|
|
86
|
+
self.config.logger.warning(f"{exc}; target omitido")
|
|
87
|
+
failed.append(target.name)
|
|
88
|
+
continue
|
|
89
|
+
if not dry_run and wrote and target.reload:
|
|
90
|
+
cmd = expand_command(target.reload, name, palette)
|
|
91
|
+
rc = run_reload(cmd, self.config.logger)
|
|
92
|
+
if rc:
|
|
93
|
+
policy = target.reload_failure or self.config.reload_failure
|
|
94
|
+
self.config.logger.log(
|
|
95
|
+
logging.ERROR if policy == "error" else logging.WARNING,
|
|
96
|
+
f"{target.name}: reload fallo (exit {rc}): {cmd}",
|
|
97
|
+
)
|
|
98
|
+
if policy == "error":
|
|
99
|
+
failed.append(target.name)
|
|
100
|
+
|
|
101
|
+
if only:
|
|
102
|
+
return failed
|
|
103
|
+
|
|
104
|
+
if dry_run:
|
|
105
|
+
for cmd in post_apply:
|
|
106
|
+
print(f"[DRY RUN] post_apply: {cmd}")
|
|
107
|
+
return failed
|
|
108
|
+
|
|
109
|
+
self.config.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
atomic_write_text(self.config.state_file, name + "\n")
|
|
111
|
+
self.config.logger.info(f"Tema activo: {name}")
|
|
112
|
+
post_failed = run_post_apply(post_apply, self.config.logger) or []
|
|
113
|
+
for cmd, rc in post_failed:
|
|
114
|
+
error = self.config.reload_failure == "error"
|
|
115
|
+
self.config.logger.log(
|
|
116
|
+
logging.ERROR if error else logging.WARNING,
|
|
117
|
+
f"post_apply fallo (exit {rc}): {cmd}",
|
|
118
|
+
)
|
|
119
|
+
if post_failed and self.config.reload_failure == "error":
|
|
120
|
+
failed.append("post_apply")
|
|
121
|
+
if publish:
|
|
122
|
+
self._publish_state(name)
|
|
123
|
+
return failed
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def revision_file(self) -> Path:
|
|
127
|
+
return self.config.state_file.with_name(self.config.state_file.name + ".revision")
|
|
128
|
+
|
|
129
|
+
def local_revision(self):
|
|
130
|
+
"""Revision of the shared state this node last applied or published.
|
|
131
|
+
|
|
132
|
+
Kept beside the plain state file (which only holds the palette name)
|
|
133
|
+
so the legacy format is untouched. None when absent or unreadable."""
|
|
134
|
+
try:
|
|
135
|
+
value = int(self.revision_file.read_text().strip())
|
|
136
|
+
except (OSError, ValueError):
|
|
137
|
+
return None
|
|
138
|
+
return value if value >= 0 else None
|
|
139
|
+
|
|
140
|
+
def _save_revision(self, revision: int) -> None:
|
|
141
|
+
atomic_write_text(self.revision_file, f"{revision}\n")
|
|
142
|
+
|
|
143
|
+
def _publish_state(self, name: str) -> None:
|
|
144
|
+
"""Best-effort: share the active palette; never raises or fails `set`."""
|
|
145
|
+
settings = self.config.state_sync
|
|
146
|
+
if settings is None:
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
source = build_source(settings, self.config.logger)
|
|
150
|
+
current = source.read()
|
|
151
|
+
base = max(current.revision if current else 0, self.local_revision() or 0)
|
|
152
|
+
revision = base + 1
|
|
153
|
+
if source.write(State(name, revision)):
|
|
154
|
+
self._save_revision(revision)
|
|
155
|
+
else:
|
|
156
|
+
self.config.logger.warning("estado compartido no publicado; paleta aplicada solo en local")
|
|
157
|
+
except Exception as exc: # noqa: BLE001 - sync must never break `set`
|
|
158
|
+
self.config.logger.warning(f"estado compartido no publicado: {exc}")
|
|
159
|
+
|
|
160
|
+
def sync(self):
|
|
161
|
+
"""Pull the shared state and apply it only if its revision is newer.
|
|
162
|
+
|
|
163
|
+
Returns (exit_code, outcome). Safe for shell startup: no change means
|
|
164
|
+
no writes and no reload; an unreachable remote is not an error (exit 0)
|
|
165
|
+
and cached data is never applied."""
|
|
166
|
+
settings = self.config.state_sync
|
|
167
|
+
if settings is None:
|
|
168
|
+
self.config.logger.warning("sync: 'state_sync' no configurado; nada que hacer")
|
|
169
|
+
return 0, "disabled"
|
|
170
|
+
try:
|
|
171
|
+
res = build_source(settings, self.config.logger).read_result()
|
|
172
|
+
except Exception as exc: # noqa: BLE001 - sync must not crash shell startup
|
|
173
|
+
self.config.logger.warning(f"sync: estado compartido ilegible: {exc}")
|
|
174
|
+
return 0, "unreachable"
|
|
175
|
+
if not res.remote:
|
|
176
|
+
return 0, "unreachable"
|
|
177
|
+
remote = res.state
|
|
178
|
+
if remote is None:
|
|
179
|
+
return 0, "no-remote-state"
|
|
180
|
+
local = self.local_revision() or 0
|
|
181
|
+
if remote.revision == local:
|
|
182
|
+
return 0, "in-sync"
|
|
183
|
+
if remote.revision < local:
|
|
184
|
+
return 0, "ahead"
|
|
185
|
+
self.config.logger.info(f"sync: r{local} -> r{remote.revision} ({remote.palette})")
|
|
186
|
+
try:
|
|
187
|
+
failed = self.set_theme(remote.palette, publish=False)
|
|
188
|
+
except PlaceholderError as e:
|
|
189
|
+
# set_theme valida antes de escribir: no hay nada aplicado ni revision que guardar
|
|
190
|
+
print(f"Config invalida: {e}", file=sys.stderr)
|
|
191
|
+
return 1, "failed"
|
|
192
|
+
except SystemExit:
|
|
193
|
+
# load() ya logueo el motivo; la paleta remota no existe aqui (o es invalida).
|
|
194
|
+
# Sync no debe matar el arranque del shell ni avanzar la revision.
|
|
195
|
+
self.config.logger.warning(
|
|
196
|
+
f"sync: la paleta remota '{remote.palette}' no esta disponible localmente; "
|
|
197
|
+
"se omite")
|
|
198
|
+
return 0, "unknown-palette"
|
|
199
|
+
if failed:
|
|
200
|
+
print(f"Targets con errores: {', '.join(failed)}", file=sys.stderr)
|
|
201
|
+
return 1, "failed"
|
|
202
|
+
self._save_revision(remote.revision)
|
|
203
|
+
return 0, "applied"
|
|
204
|
+
|
|
205
|
+
def _drift_lines(self):
|
|
206
|
+
settings = self.config.state_sync
|
|
207
|
+
local = self.local_revision()
|
|
208
|
+
lines = [f"Revision local: {'r%d' % local if local is not None else '(ninguna)'}"]
|
|
209
|
+
try:
|
|
210
|
+
res = build_source(settings, self.config.logger).read_result()
|
|
211
|
+
except Exception: # noqa: BLE001
|
|
212
|
+
res = ReadResult(None, False)
|
|
213
|
+
remote = res.state
|
|
214
|
+
if remote is None:
|
|
215
|
+
lines.append("Revision remota: (desconocida)" if not res.remote else "Revision remota: (vacia)")
|
|
216
|
+
else:
|
|
217
|
+
origin = "remote" if res.remote else "local cache - remote unreachable"
|
|
218
|
+
lines.append(f"Revision remota: r{remote.revision} ({origin}, paleta {remote.palette})")
|
|
219
|
+
if not res.remote:
|
|
220
|
+
drift = "remote unreachable"
|
|
221
|
+
elif remote is None:
|
|
222
|
+
drift = "no remote state"
|
|
223
|
+
elif (local or 0) < remote.revision:
|
|
224
|
+
drift = "behind"
|
|
225
|
+
elif (local or 0) > remote.revision:
|
|
226
|
+
drift = "ahead"
|
|
227
|
+
else:
|
|
228
|
+
drift = "in sync"
|
|
229
|
+
lines.append(f"Drift: {drift}")
|
|
230
|
+
return lines
|
|
231
|
+
|
|
232
|
+
def status(self) -> None:
|
|
233
|
+
print(f"Tema activo: {self.current_theme() or '(ninguno)'}")
|
|
234
|
+
if self.config.state_sync is not None:
|
|
235
|
+
print("\n".join(self._drift_lines()))
|
|
236
|
+
print(f"Paletas disponibles: {', '.join(self.list_palettes())}")
|
|
237
|
+
total = sum(len(t.outputs) for t in self.config.targets)
|
|
238
|
+
existing = sum(1 for t in self.config.targets for o in t.outputs if o.exists())
|
|
239
|
+
print(f"Targets configurados: {len(self.config.targets)} ({existing}/{total} outputs existentes en disco)")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
DOCS_URL = "https://hueman.cuevaneander.tech"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def init_config(path: Path, force: bool) -> int:
|
|
246
|
+
"""Copy the packaged example config to `path`; returns the exit code."""
|
|
247
|
+
if path.exists() and not force:
|
|
248
|
+
print(f"Ya existe: {path} (usa --force para sobrescribir)", file=sys.stderr)
|
|
249
|
+
return 1
|
|
250
|
+
try:
|
|
251
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
atomic_write_text(path, packaged_example_config().read_text())
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
print(f"No se pudo escribir {path}: {exc}", file=sys.stderr)
|
|
255
|
+
return 1
|
|
256
|
+
print(f"Config de ejemplo escrito en {path}")
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _var_arg(text: str):
|
|
261
|
+
"""argparse type for --var NAME=VALUE; returns (name, value)."""
|
|
262
|
+
name, sep, value = text.partition("=")
|
|
263
|
+
if not sep or not VAR_NAME_RE.fullmatch(name):
|
|
264
|
+
raise argparse.ArgumentTypeError(
|
|
265
|
+
f"invalid form {text!r}: expected NAME=VALUE with NAME matching [A-Za-z_][A-Za-z0-9_]*"
|
|
266
|
+
)
|
|
267
|
+
return name, value
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main():
|
|
271
|
+
parser = argparse.ArgumentParser(description="Gestor centralizado de temas (standalone)")
|
|
272
|
+
parser.add_argument("--version", action="version", version=f"hueman {__version__}")
|
|
273
|
+
parser.add_argument("--config", help="Ruta al config (por defecto: $HUEMAN_CONFIG o ~/.config/hueman/config.yaml)")
|
|
274
|
+
parser.add_argument(
|
|
275
|
+
"--var", action="append", type=_var_arg, default=[], metavar="NAME=VALUE",
|
|
276
|
+
help="Define o sobreescribe una variable de 'vars:' para ${NAME} en las rutas "
|
|
277
|
+
"del config (repetible).",
|
|
278
|
+
)
|
|
279
|
+
sub = parser.add_subparsers(dest="command")
|
|
280
|
+
|
|
281
|
+
set_p = sub.add_parser("set", help="Aplicar un tema")
|
|
282
|
+
set_p.add_argument("name")
|
|
283
|
+
set_p.add_argument("--dry-run", action="store_true")
|
|
284
|
+
set_p.add_argument(
|
|
285
|
+
"--only", action="append", metavar="NAME[,NAME...]",
|
|
286
|
+
help="Limitar a targets por nombre o glob (repetible). No actualiza "
|
|
287
|
+
"el state file ni corre post_apply.",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
sync_p = sub.add_parser(
|
|
291
|
+
"sync", help="Aplicar el estado compartido si es mas nuevo (seguro en el arranque del shell)")
|
|
292
|
+
sync_p.add_argument("-q", "--quiet", action="store_true",
|
|
293
|
+
help="Solo errores reales (sin avisos de red o config)")
|
|
294
|
+
|
|
295
|
+
init_p = sub.add_parser(
|
|
296
|
+
"init", help="Escribir un config de ejemplo en la ruta de config por defecto")
|
|
297
|
+
init_p.add_argument("--force", action="store_true",
|
|
298
|
+
help="Sobrescribir el config si ya existe")
|
|
299
|
+
|
|
300
|
+
sub.add_parser("list", help="Listar paletas disponibles")
|
|
301
|
+
sub.add_parser("status", help="Mostrar tema activo y estado de los targets")
|
|
302
|
+
|
|
303
|
+
args = parser.parse_args()
|
|
304
|
+
if not args.command:
|
|
305
|
+
parser.print_help()
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
config_path = HuemanConfig.resolve_path(args.config)
|
|
309
|
+
if args.command == "init":
|
|
310
|
+
sys.exit(init_config(config_path, args.force))
|
|
311
|
+
if not config_path.exists():
|
|
312
|
+
if args.command == "list":
|
|
313
|
+
# Las paletas empaquetadas no dependen del config del usuario.
|
|
314
|
+
loader = PaletteLoader(packaged_palettes_dir(), [], logging.getLogger("hueman"))
|
|
315
|
+
print("\n".join(loader.list_palettes()))
|
|
316
|
+
return
|
|
317
|
+
print(
|
|
318
|
+
f"Config no encontrada: {config_path}\n"
|
|
319
|
+
f"Crea uno con `hueman init`, o usa --config / HUEMAN_CONFIG (docs: {DOCS_URL})",
|
|
320
|
+
file=sys.stderr,
|
|
321
|
+
)
|
|
322
|
+
sys.exit(1)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
tc = Hueman(HuemanConfig(config_path, dict(args.var)))
|
|
326
|
+
except ConfigError as e:
|
|
327
|
+
print(f"Config invalida: {e}", file=sys.stderr)
|
|
328
|
+
sys.exit(1)
|
|
329
|
+
if args.command == "set":
|
|
330
|
+
only = [p.strip() for v in args.only or [] for p in v.split(",") if p.strip()] or None
|
|
331
|
+
try:
|
|
332
|
+
failed = tc.set_theme(args.name, args.dry_run, only=only)
|
|
333
|
+
except UnknownTargetError as e:
|
|
334
|
+
print(str(e), file=sys.stderr)
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
except PlaceholderError as e:
|
|
337
|
+
print(f"Config invalida: {e}", file=sys.stderr)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
if failed:
|
|
340
|
+
print(f"Targets con errores: {', '.join(failed)}", file=sys.stderr)
|
|
341
|
+
sys.exit(1)
|
|
342
|
+
elif args.command == "sync":
|
|
343
|
+
if args.quiet:
|
|
344
|
+
tc.config.logger.setLevel(logging.ERROR)
|
|
345
|
+
sys.exit(tc.sync()[0])
|
|
346
|
+
elif args.command == "list":
|
|
347
|
+
print("\n".join(tc.list_palettes()))
|
|
348
|
+
elif args.command == "status":
|
|
349
|
+
tc.status()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
main()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Ejemplo mínimo de configuración de hueman.
|
|
2
|
+
#
|
|
3
|
+
# Este archivo NO vive dentro de hueman/ — cada usuario/máquina trae el
|
|
4
|
+
# suyo (por convención, junto al repo de dotfiles que va a themear, ej.
|
|
5
|
+
# "~/dotfiles/theme/hueman.config.yaml"), y se lo pasa a la herramienta
|
|
6
|
+
# con `hueman --config <ruta>` (o vía la variable HUEMAN_CONFIG, o
|
|
7
|
+
# dejándolo en ~/.config/hueman/config.yaml).
|
|
8
|
+
#
|
|
9
|
+
# No hay concepto de "entorno" ni convención de carpetas: cada target
|
|
10
|
+
# declara su lista explícita de archivos de salida (`outputs`). Si hay
|
|
11
|
+
# varias máquinas/perfiles que comparten el mismo tema, basta con listar
|
|
12
|
+
# todas las rutas ahí.
|
|
13
|
+
|
|
14
|
+
# Opcional: variables para no repetir rutas. `${repo}` se sustituye solo en
|
|
15
|
+
# `outputs` y `skip_unless_path_exists` de cada target, y en `state_file`,
|
|
16
|
+
# `palette_dirs` y `template_dirs` (nunca en reload, post_apply, patch ni
|
|
17
|
+
# templates). Se pueden sobreescribir con `hueman --var repo=<ruta> ...`.
|
|
18
|
+
# vars:
|
|
19
|
+
# repo: "~/dotfiles"
|
|
20
|
+
# Uso: outputs: ["${repo}/kitty/theme.conf"]
|
|
21
|
+
|
|
22
|
+
# Dónde persistir el nombre de la paleta activa.
|
|
23
|
+
state_file: "~/.config/hueman/current"
|
|
24
|
+
|
|
25
|
+
# Paletas propias, además de las 25 empaquetadas dentro de hueman/palettes/.
|
|
26
|
+
# Si una paleta custom tiene el mismo nombre que una empaquetada, gana la custom.
|
|
27
|
+
palette_dirs:
|
|
28
|
+
- "~/dotfiles/theme/palettes.custom"
|
|
29
|
+
|
|
30
|
+
# Templates propios, para targets que la herramienta no trae por defecto.
|
|
31
|
+
template_dirs:
|
|
32
|
+
- "~/dotfiles/theme/templates.custom"
|
|
33
|
+
|
|
34
|
+
# Politica cuando un comando `reload` o `post_apply` termina con codigo != 0:
|
|
35
|
+
# warn (default) - se registra un warning y `set` sigue; exit 0.
|
|
36
|
+
# error - se registra un error, se siguen ejecutando los demas
|
|
37
|
+
# pasos (best-effort) y `set` termina con exit 1.
|
|
38
|
+
# Cada target puede sobreescribirla con su propio `reload_failure`.
|
|
39
|
+
# El codigo de salida de cada comando siempre se registra en el log.
|
|
40
|
+
reload_failure: warn
|
|
41
|
+
|
|
42
|
+
# Opcional (opt-in): publica la paleta activa en la rama huerfana `hueman-state`
|
|
43
|
+
# del remoto de tu repo de dotfiles ({palette, revision} en hueman.state.yaml,
|
|
44
|
+
# un unico commit reescrito con force-with-lease). hueman usa un repo propio
|
|
45
|
+
# (por defecto bajo $XDG_STATE_HOME/hueman/state-sync/) y nunca toca tu working
|
|
46
|
+
# tree. Sin remoto, sin red o sin git, `set` aplica en local y avisa.
|
|
47
|
+
# `set --dry-run` y `set --only` nunca escriben ni empujan estado.
|
|
48
|
+
# state_sync:
|
|
49
|
+
# type: git # git (default) | file
|
|
50
|
+
# repo: "~/dotfiles" # repo del que se toma la URL del remoto
|
|
51
|
+
# remote: origin # default
|
|
52
|
+
# branch: hueman-state # default
|
|
53
|
+
# # workdir: "~/.local/state/hueman/state-sync/x.git" # opcional
|
|
54
|
+
# # timeout: 15 # segundos por llamada git (default 15)
|
|
55
|
+
#
|
|
56
|
+
# Alternativa sin git: un archivo de estado en una carpeta que ya sincroniza
|
|
57
|
+
# otra herramienta (Syncthing, Dropbox...). Escritura atomica; gana la mayor
|
|
58
|
+
# revision. Si la carpeta no existe se considera inalcanzable (no se crea).
|
|
59
|
+
# state_sync:
|
|
60
|
+
# type: file
|
|
61
|
+
# path: "~/Sync/hueman.state.yaml"
|
|
62
|
+
|
|
63
|
+
targets:
|
|
64
|
+
# --- Ejemplo "overwrite": reemplaza el archivo entero.
|
|
65
|
+
kitty:
|
|
66
|
+
template: kitty.conf.j2
|
|
67
|
+
palette_key: kitty
|
|
68
|
+
mode: overwrite
|
|
69
|
+
outputs:
|
|
70
|
+
- "~/.config/kitty/theme.conf"
|
|
71
|
+
reload: "pgrep -x kitty >/dev/null 2>&1 && pkill -USR1 -x kitty || true"
|
|
72
|
+
# reload_failure: error # override opcional de la politica global
|
|
73
|
+
|
|
74
|
+
# --- Ejemplo "patch": splice regex sobre un archivo ya existente, sin
|
|
75
|
+
# tocar el resto de su contenido (útil para configs escritas a mano,
|
|
76
|
+
# como el prompt de starship).
|
|
77
|
+
starship:
|
|
78
|
+
template: starship_block.toml.j2
|
|
79
|
+
palette_key: polybar
|
|
80
|
+
mode: patch
|
|
81
|
+
patch:
|
|
82
|
+
marker_regex: '# --- Bloque gestionado por (?:theme_manager\.py|hueman).*?\[palettes\.dotfiles\][^\[]*'
|
|
83
|
+
replacements:
|
|
84
|
+
- ['style = "bold green"', 'style = "bold success"']
|
|
85
|
+
ensure_after:
|
|
86
|
+
- if_missing_regex: '(?m)^palette\s*='
|
|
87
|
+
anchor: "add_newline = false\n"
|
|
88
|
+
insert: "palette = \"dotfiles\"\n"
|
|
89
|
+
outputs:
|
|
90
|
+
- "~/.config/starship.toml"
|
|
91
|
+
|
|
92
|
+
# --- Ejemplo de guardas opcionales: `skip_if_missing_parent` omite una
|
|
93
|
+
# salida cuyo directorio padre no existe; `skip_unless_path_exists` (ruta o
|
|
94
|
+
# lista) omite TODO el target (sin escribir ni reload) salvo que existan
|
|
95
|
+
# todas esas rutas, util para "solo si esta instalada la herramienta X".
|
|
96
|
+
# windhawk_taskbar:
|
|
97
|
+
# template: windhawk_taskbar.json.j2
|
|
98
|
+
# palette_key: polybar
|
|
99
|
+
# skip_if_missing_parent: true
|
|
100
|
+
# skip_unless_path_exists: "/mnt/c/Windows/System32/schtasks.exe"
|
|
101
|
+
# outputs: ["/mnt/c/Users/ME/AppData/Local/DotfilesTheme/taskbar-colors.json"]
|
|
102
|
+
|
|
103
|
+
# Comandos que corren una sola vez al final de `set`, no por target.
|
|
104
|
+
# No corren cuando se usa `set --only ...` (re-render parcial).
|
|
105
|
+
# Los comandos de `reload` y `post_apply` admiten placeholders: `{name}`
|
|
106
|
+
# (o `{palette}`) y `{seccion.clave}` (lookup en la paleta activa, ej.
|
|
107
|
+
# `{kitty.color4}`); se sustituyen ya shell-quoted. `${VAR}` no se toca y
|
|
108
|
+
# una clave con punto inexistente es un error.
|
|
109
|
+
post_apply:
|
|
110
|
+
# - "python3 ~/x.py --name {name} --color {kitty.color4}"
|
|
111
|
+
- "command -v oh-my-posh >/dev/null 2>&1 && oh-my-posh enable reload || true"
|
|
File without changes
|
hueman/engine/colors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Helpers de color: contraste WCAG y clasificación oscuro/claro.
|
|
2
|
+
|
|
3
|
+
Genéricos por diseño — no dependen de ningún target ni paleta en particular,
|
|
4
|
+
se exponen como filtros Jinja (`contrast`, `is_dark`) a todos los templates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _relative_luminance(hex_color: str) -> float:
|
|
9
|
+
hex_color = hex_color.lstrip("#")
|
|
10
|
+
r, g, b = (int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
|
11
|
+
|
|
12
|
+
def lin(c):
|
|
13
|
+
c /= 255
|
|
14
|
+
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
15
|
+
|
|
16
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def contrast_text(hex_color: str) -> str:
|
|
20
|
+
"""Devuelve texto casi-negro o casi-blanco según la luminancia relativa
|
|
21
|
+
(WCAG) del color de fondo dado, para que el contraste siempre sea legible
|
|
22
|
+
sin importar qué tan clara/oscura sea la paleta del tema."""
|
|
23
|
+
return "#1a1a1a" if _relative_luminance(hex_color) > 0.45 else "#f5f5f5"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_dark(hex_color: str) -> bool:
|
|
27
|
+
"""Mismo criterio de luminancia que contrast_text(), pero como booleano —
|
|
28
|
+
para declarar `dark:` en temas que lo necesiten (ej. posting), ya que hay
|
|
29
|
+
paletas claras (catppuccin-latte, one-light, etc.) además de las oscuras."""
|
|
30
|
+
return _relative_luminance(hex_color) <= 0.45
|