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.
Files changed (78) hide show
  1. hueman/__init__.py +11 -0
  2. hueman/__main__.py +3 -0
  3. hueman/cli.py +353 -0
  4. hueman/data/hueman.config.example.yaml +111 -0
  5. hueman/engine/__init__.py +0 -0
  6. hueman/engine/colors.py +30 -0
  7. hueman/engine/config.py +186 -0
  8. hueman/engine/fsutil.py +41 -0
  9. hueman/engine/palettes.py +66 -0
  10. hueman/engine/reload.py +60 -0
  11. hueman/engine/render.py +243 -0
  12. hueman/engine/statesource.py +370 -0
  13. hueman/engine/targets.py +95 -0
  14. hueman/palettes/arctic-ice.yaml +102 -0
  15. hueman/palettes/autumn.yaml +102 -0
  16. hueman/palettes/catppuccin-latte.yaml +102 -0
  17. hueman/palettes/catppuccin-mocha.yaml +102 -0
  18. hueman/palettes/copperwave.yaml +103 -0
  19. hueman/palettes/crimson-vivid.yaml +111 -0
  20. hueman/palettes/dracula.yaml +102 -0
  21. hueman/palettes/ember-red.yaml +111 -0
  22. hueman/palettes/everforest-dark.yaml +102 -0
  23. hueman/palettes/galaxy.yaml +102 -0
  24. hueman/palettes/gruvbox-light.yaml +102 -0
  25. hueman/palettes/gruvbox.yaml +102 -0
  26. hueman/palettes/kanagawa-dragon.yaml +102 -0
  27. hueman/palettes/matrix.yaml +102 -0
  28. hueman/palettes/mono-contrast.yaml +102 -0
  29. hueman/palettes/nord.yaml +102 -0
  30. hueman/palettes/ocean-deep.yaml +102 -0
  31. hueman/palettes/one-light.yaml +102 -0
  32. hueman/palettes/onyx-gold.yaml +103 -0
  33. hueman/palettes/rose-pine-dawn.yaml +102 -0
  34. hueman/palettes/solarized-light.yaml +102 -0
  35. hueman/palettes/synthwave.yaml +102 -0
  36. hueman/palettes/tokyonight.yaml +106 -0
  37. hueman/palettes/tuxedo.yaml +103 -0
  38. hueman/palettes/vaporwave.yaml +102 -0
  39. hueman/resources.py +18 -0
  40. hueman/templates/atuin_theme.toml.j2 +19 -0
  41. hueman/templates/bottom_theme.toml.j2 +43 -0
  42. hueman/templates/bspwm_colors.conf.j2 +5 -0
  43. hueman/templates/claude_statusline.sh.j2 +216 -0
  44. hueman/templates/dunst_colors.conf.j2 +15 -0
  45. hueman/templates/fzf_opts.j2 +9 -0
  46. hueman/templates/fzf_theme.j2 +17 -0
  47. hueman/templates/herdr_theme_custom.toml.j2 +11 -0
  48. hueman/templates/k9s_theme.yaml.j2 +83 -0
  49. hueman/templates/kitty.conf.j2 +51 -0
  50. hueman/templates/lazydocker_theme.yml.j2 +19 -0
  51. hueman/templates/leaf_theme.toml.j2 +89 -0
  52. hueman/templates/newt_colors.j2 +32 -0
  53. hueman/templates/nvim_nightfox_theme.lua.j2 +70 -0
  54. hueman/templates/omp.omp.json.j2 +107 -0
  55. hueman/templates/opencode_theme.json.j2 +71 -0
  56. hueman/templates/pi_theme.json.j2 +65 -0
  57. hueman/templates/polybar_colors.ini.j2 +11 -0
  58. hueman/templates/posting_theme.yaml.j2 +47 -0
  59. hueman/templates/qutebrowser_github.user.js.j2 +121 -0
  60. hueman/templates/qutebrowser_google.user.js.j2 +132 -0
  61. hueman/templates/qutebrowser_hteam.user.js.j2 +962 -0
  62. hueman/templates/qutebrowser_theme.py.j2 +78 -0
  63. hueman/templates/rofi_theme.rasi.j2 +132 -0
  64. hueman/templates/sshub_theme.toml.j2 +49 -0
  65. hueman/templates/starship_block.toml.j2 +11 -0
  66. hueman/templates/termux_colors.properties.j2 +20 -0
  67. hueman/templates/tmux.conf.j2 +33 -0
  68. hueman/templates/tuicr_theme.toml.j2 +51 -0
  69. hueman/templates/wezterm_colors.lua.j2 +14 -0
  70. hueman/templates/windhawk_taskbar.json.j2 +4 -0
  71. hueman/templates/yazi_theme.toml.j2 +130 -0
  72. hueman/templates/zen_theme.json.j2 +11 -0
  73. hueman_cli-0.2.0.dist-info/METADATA +119 -0
  74. hueman_cli-0.2.0.dist-info/RECORD +78 -0
  75. hueman_cli-0.2.0.dist-info/WHEEL +5 -0
  76. hueman_cli-0.2.0.dist-info/entry_points.txt +2 -0
  77. hueman_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  78. 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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
@@ -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