From 661e9813d2bbce2a4ebfc3f37027ec62a558c421 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Thu, 23 Jul 2026 12:53:11 +0200 Subject: [PATCH] feat(herdr): install themes safely --- .changeset/calm-herdr-installs.md | 5 + AGENTS.md | 10 +- README.md | 22 ++ flake.nix | 29 ++ package.json | 1 + packages/cli/package.json | 4 + packages/cli/src/cli/herdr-install.test.ts | 228 ++++++++++++++ packages/cli/src/cli/herdr-install.ts | 326 +++++++++++++++++++++ packages/cli/src/cli/index.ts | 65 ++++ packages/cli/src/generators/herdr.ts | 49 ++-- pnpm-lock.yaml | 47 +++ share/herdr/senzu-cold.toml | 30 ++ 12 files changed, 793 insertions(+), 23 deletions(-) create mode 100644 .changeset/calm-herdr-installs.md create mode 100644 packages/cli/src/cli/herdr-install.test.ts create mode 100644 packages/cli/src/cli/herdr-install.ts create mode 100644 share/herdr/senzu-cold.toml diff --git a/.changeset/calm-herdr-installs.md b/.changeset/calm-herdr-installs.md new file mode 100644 index 0000000..b34ec7d --- /dev/null +++ b/.changeset/calm-herdr-installs.md @@ -0,0 +1,5 @@ +--- +"@senzu/cli": minor +--- + +Safely install an explicit Senzu variant into Herdr's existing config with comment-preserving TOML updates, timestamped backups, dry runs, and guarded symlink handling. Expose Herdr theme attrsets through the flake library for declarative Nix configuration. diff --git a/AGENTS.md b/AGENTS.md index df44388..1e80d7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,11 +28,11 @@ - `nix/home-manager.nix` — Nix home-manager module for installing themes and the probe - `flake.nix` — Nix flake: themes package, appearance probe, devshell with git-hooks -- **Generator contract**: Each generator implements `emit(palettes[]): OutputFile[]`. Per-palette generators (ghostty, wezterm, wterm, tmux, neovim, pi, textmate, bat, fzf) produce one file per variant. Family generators (zed) produce a single file from all palettes. +- **Generator contract**: Each generator implements `emit(palettes[]): OutputFile[]`. Per-palette generators (ghostty, wezterm, wterm, tmux, herdr, neovim, pi, textmate, bat, fzf) produce one file per variant. Family generators (zed) produce a single file from all palettes. - **Variants**: 9 total — `senzu` (default dark), `senzu-mono`, `senzu-light`, `senzu-mono-light`, `senzu-muted`, `senzu-muted-light`, `senzu-hc`, `senzu-hc-light`, `senzu-warm`. -- **Targets**: `ghostty`, `wezterm` (TOML only — the canonical format for `~/.config/wezterm/colors/`), `wterm`, `tmux`, `neovim`, `zed` (theme family JSON), `pi` (Pi coding agent theme JSON with var references), `textmate` (Shiki/VS Code JSON), `bat` (TextMate `.tmTheme` for bat/delta), `fzf` (`--color` snippets sourced into `FZF_DEFAULT_OPTS`). +- **Targets**: `ghostty`, `wezterm` (TOML only — the canonical format for `~/.config/wezterm/colors/`), `wterm`, `tmux`, `herdr` (TOML config snippets plus safe config patching), `neovim`, `zed` (theme family JSON), `pi` (Pi coding agent theme JSON with var references), `textmate` (Shiki/VS Code JSON), `bat` (TextMate `.tmTheme` for bat/delta), `fzf` (`--color` snippets sourced into `FZF_DEFAULT_OPTS`). - **Versioning**: The version in `package.json` is the single source. The flake reads it for the themes package, and `native/Cargo.toml` must match it because the probe binary is published under the release tag (`just sync-version`). Use changesets to bump. @@ -58,8 +58,8 @@ ## Adding a new generator -1. Create `src/generators/.ts` extending `PerPaletteGenerator` (one file per variant) or `FamilyGenerator` (one file from all variants). +1. Create `packages/cli/src/generators/.ts` extending `PerPaletteGenerator` (one file per variant) or `FamilyGenerator` (one file from all variants). 2. Implement `generate()` returning the file contents string. -3. Register the generator in `src/generators/index.ts`. +3. Register the generator in `packages/cli/src/generators/index.ts`. 4. Run `pnpm generate ` to test. -5. Add the install logic to `nix/home-manager.nix` if it needs home-manager integration. +5. Add install logic only where the target supports standalone theme files. For config-owning tools such as Herdr, expose composable Nix data instead of creating a competing config file. diff --git a/README.md b/README.md index 4060cc8..a36cf9c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,28 @@ Custom themes for editors and tools, inspired by [github.com/WTFox/jellybeans.nvim](https://github.com/WTFox/jellybeans.nvim). +## Herdr + +Install one explicit variant into a regular Herdr config file: + +```sh +senzu install herdr senzu-muted +``` + +Senzu preserves unrelated TOML settings and comments, creates a timestamped +backup, and prints the command for reloading Herdr. Use `--dry-run` to preview +the updated config. Senzu refuses to modify symlinked configs unless `--force` +is set. + +For a Nix-managed Herdr config, consume the theme attrset instead of modifying +the generated config file: + +```nix +ad.programs.herdr.settings.theme = inputs.senzu.lib.herdrTheme "senzu-muted"; +``` + +All variants are also available under `inputs.senzu.lib.herdrThemes`. + ## Theme previews diff --git a/flake.nix b/flake.nix index 0471efb..41f5d52 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,36 @@ cliPackageJson = builtins.fromJSON (builtins.readFile ./packages/cli/package.json); version = cliPackageJson.version; + herdrThemeDirectory = builtins.readDir ./share/herdr; + herdrThemeFiles = builtins.filter ( + name: + herdrThemeDirectory.${name} == "regular" + && builtins.match ".*[.]toml" name != null + ) (builtins.attrNames herdrThemeDirectory); + herdrThemes = builtins.listToAttrs ( + map ( + fileName: + let + variant = builtins.substring 0 (builtins.stringLength fileName - 5) fileName; + document = builtins.fromTOML (builtins.readFile (./share/herdr + "/${fileName}")); + in + { + name = variant; + value = document.theme; + } + ) herdrThemeFiles + ); + systemIndependent = { + lib = { + inherit herdrThemes; + herdrTheme = variant: + herdrThemes.${variant} or (throw '' + Unknown Senzu Herdr variant "${variant}". + Available variants: ${builtins.concatStringsSep ", " (builtins.attrNames herdrThemes)} + ''); + }; + homeManagerModules = { default = import ./nix/home-manager.nix; senzu = import ./nix/home-manager.nix; diff --git a/package.json b/package.json index 187e939..3da5a43 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "pnpm -C packages/cli build", "lint": "biome check", "format": "biome check --write", + "test": "pnpm -C packages/cli test", "typecheck": "pnpm -C packages/cli typecheck", "changeset": "changeset" }, diff --git a/packages/cli/package.json b/packages/cli/package.json index f8954ae..5694808 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,13 +12,17 @@ "list": "tsx src/cli/index.ts list", "install:themes": "tsx src/cli/index.ts install", "build": "tsdown", + "test": "tsx --test src/cli/*.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { + "@decimalturn/toml-patch": "^2.1.0", + "proper-lockfile": "^4.1.2", "zod": "^3.25.0" }, "devDependencies": { "@types/node": "^24.0.0", + "@types/proper-lockfile": "^4.1.4", "tsdown": "^0.22.0", "typescript": "^5.9.0" }, diff --git a/packages/cli/src/cli/herdr-install.test.ts b/packages/cli/src/cli/herdr-install.test.ts new file mode 100644 index 0000000..580adbf --- /dev/null +++ b/packages/cli/src/cli/herdr-install.test.ts @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { parse } from "@decimalturn/toml-patch"; +import type { HerdrThemeConfig } from "../generators/herdr"; +import { installHerdrTheme, mergeHerdrConfig } from "./herdr-install"; + +const theme: HerdrThemeConfig = { + name: "catppuccin", + auto_switch: false, + custom: { + accent: "#8fbfdc", + text: "#e8e8d3", + red: "#d74545", + }, +}; + +function temporaryDirectory(): string { + return mkdtempSync(join(tmpdir(), "senzu-herdr-test-")); +} + +test("mergeHerdrConfig preserves unrelated settings and theme keys", () => { + const source = `# keep this comment +onboarding = false + +[theme] +# keep this theme comment +name = "dracula" +auto_switch = true +future_option = "preserved" + +[theme.custom] +accent = "#ffffff" +stale_color = "#000000" + +[ui] +sidebar_width = 42 +`; + + const output = mergeHerdrConfig(source, "config.toml", theme); + const parsed = parse(output, { integersAsBigInt: false }); + + assert.match(output, /# keep this comment/); + assert.match(output, /# keep this theme comment/); + assert.equal(parsed.onboarding, false); + assert.equal(parsed.ui.sidebar_width, 42); + assert.equal(parsed.theme.future_option, "preserved"); + assert.deepEqual({ ...parsed.theme.custom }, theme.custom); + assert.doesNotMatch(output, /stale_color/); +}); + +test("installHerdrTheme creates a timestamped backup and is idempotent", () => { + const root = temporaryDirectory(); + const configPath = join(root, "config.toml"); + const source = "[ui]\nsidebar_width = 32\n"; + writeFileSync(configPath, source); + + try { + const first = installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: false, + now: new Date("2026-07-23T08:15:00.000Z"), + }); + + assert.equal(first.status, "updated"); + assert.equal( + first.backupPath, + `${configPath}.senzu-backup-20260723T081500Z`, + ); + assert.equal(readFileSync(first.backupPath, "utf8"), source); + + const second = installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: false, + }); + assert.equal(second.status, "unchanged"); + assert.equal(second.backupPath, undefined); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installHerdrTheme creates a missing config without a backup", () => { + const root = temporaryDirectory(); + const configPath = join(root, "nested", "config.toml"); + + try { + const result = installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: false, + }); + + assert.equal(result.status, "updated"); + assert.equal(result.backupPath, undefined); + assert.equal( + parse(readFileSync(configPath, "utf8")).theme.name, + theme.name, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installHerdrTheme refuses symlinks unless forced", () => { + const root = temporaryDirectory(); + const targetPath = join(root, "managed-config.toml"); + const configPath = join(root, "config.toml"); + const source = '[theme]\nname = "nord"\n'; + writeFileSync(targetPath, source); + symlinkSync(targetPath, configPath); + + try { + assert.throws( + () => + installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: false, + }), + /Refusing to update symlink/, + ); + assert.equal(readFileSync(targetPath, "utf8"), source); + + const dryRun = installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: true, + }); + assert.equal(dryRun.status, "dry-run"); + assert.equal(readFileSync(targetPath, "utf8"), source); + + const forced = installHerdrTheme({ + configPath, + theme, + force: true, + dryRun: false, + now: new Date("2026-07-23T08:15:00.000Z"), + }); + assert.equal(forced.status, "updated"); + assert.equal(lstatSync(configPath).isSymbolicLink(), true); + assert.equal( + parse(readFileSync(targetPath, "utf8")).theme.name, + theme.name, + ); + assert.ok(forced.backupPath); + assert.equal(existsSync(forced.backupPath), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installHerdrTheme rejects invalid TOML without writing", () => { + const root = temporaryDirectory(); + const configPath = join(root, "config.toml"); + const source = "[theme\nname = broken\n"; + writeFileSync(configPath, source); + + try { + assert.throws( + () => + installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: false, + }), + /Cannot update invalid TOML/, + ); + assert.equal(readFileSync(configPath, "utf8"), source); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installHerdrTheme respects an existing update lock", () => { + const root = temporaryDirectory(); + const configPath = join(root, "config.toml"); + const lockPath = `${configPath}.senzu-lock`; + writeFileSync(configPath, '[theme]\nname = "nord"\n'); + writeFileSync(lockPath, "1234\n"); + + try { + assert.throws( + () => + installHerdrTheme({ + configPath, + theme, + force: false, + dryRun: false, + }), + /Another Senzu process is updating/, + ); + assert.equal(readFileSync(lockPath, "utf8"), "1234\n"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installHerdrTheme refuses direct Nix store paths", () => { + assert.throws( + () => + installHerdrTheme({ + configPath: "/nix/store/example-herdr-config.toml", + theme, + force: true, + dryRun: false, + }), + /Cannot update Nix store path/, + ); +}); diff --git a/packages/cli/src/cli/herdr-install.ts b/packages/cli/src/cli/herdr-install.ts new file mode 100644 index 0000000..2ce5fc5 --- /dev/null +++ b/packages/cli/src/cli/herdr-install.ts @@ -0,0 +1,326 @@ +import { randomUUID } from "node:crypto"; +import { + accessSync, + closeSync, + constants, + copyFileSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { parse, patch, stringify } from "@decimalturn/toml-patch"; +import * as lockfile from "proper-lockfile"; +import type { HerdrThemeConfig } from "../generators/herdr"; + +type TomlObject = Record; + +export interface InstallHerdrOptions { + configPath: string; + theme: HerdrThemeConfig; + force: boolean; + dryRun: boolean; + now?: Date; +} + +export interface InstallHerdrResult { + status: "updated" | "unchanged" | "dry-run"; + requestedPath: string; + effectivePath: string; + symlink: boolean; + backupPath?: string; + contents: string; +} + +function isTomlObject(value: unknown): value is TomlObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseConfig(source: string, path: string): TomlObject { + if (source.trim() === "") return {}; + + try { + const parsed = parse(source, { integersAsBigInt: false }); + if (!isTomlObject(parsed)) throw new Error("root must be a table"); + return parsed; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot update invalid TOML at ${path}: ${message}`); + } +} + +export function mergeHerdrConfig( + source: string, + path: string, + theme: HerdrThemeConfig, +): string { + const config = parseConfig(source, path); + const currentTheme = config.theme; + + if (currentTheme !== undefined && !isTomlObject(currentTheme)) { + throw new Error(`Cannot update ${path}: theme must be a TOML table`); + } + + const updated: TomlObject = { + ...config, + theme: { + ...(currentTheme ?? {}), + name: theme.name, + auto_switch: theme.auto_switch, + custom: { ...theme.custom }, + }, + }; + + const newLine = source.includes("\r\n") ? "\r\n" : "\n"; + const format = { inlineTableStart: 2, newLine }; + let output: string; + if (currentTheme === undefined) { + const themeDocument = stringify({ theme: updated.theme }, format); + output = + source.trim() === "" + ? themeDocument + : `${source.replace(/\s*$/, "")}${newLine}${newLine}${themeDocument}`; + } else { + output = patch(source, updated, format); + } + const verified = parseConfig(output, path); + const verifiedTheme = verified.theme; + + if (!isTomlObject(verifiedTheme)) { + throw new Error(`Generated invalid Herdr theme for ${path}`); + } + if ( + verifiedTheme.name !== theme.name || + verifiedTheme.auto_switch !== theme.auto_switch || + !isTomlObject(verifiedTheme.custom) + ) { + throw new Error(`Generated Herdr theme failed validation for ${path}`); + } + if ( + Object.keys(verifiedTheme.custom).length !== + Object.keys(theme.custom).length + ) { + throw new Error( + `Generated Herdr custom colors failed validation for ${path}`, + ); + } + for (const [name, color] of Object.entries(theme.custom)) { + if (verifiedTheme.custom[name] !== color) { + throw new Error(`Generated Herdr color ${name} failed validation`); + } + } + + return output; +} + +export function resolveHerdrConfigPath(): string { + const configured = process.env.HERDR_CONFIG_PATH; + if (configured) return resolve(configured); + + const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); + return resolve(xdgConfig, "herdr", "config.toml"); +} + +function timestamp(date: Date): string { + return date + .toISOString() + .replaceAll("-", "") + .replaceAll(":", "") + .replace(/\.\d{3}Z$/, "Z"); +} + +function nextBackupPath(path: string, now: Date): string { + const base = `${path}.senzu-backup-${timestamp(now)}`; + if (!existsSync(base)) return base; + + for (let suffix = 2; ; suffix++) { + const candidate = `${base}-${suffix}`; + if (!existsSync(candidate)) return candidate; + } +} + +function assertSourceUnchanged( + path: string, + existed: boolean, + source: string, +): void { + if (!existed) { + if (existsSync(path)) { + throw new Error(`Herdr config changed while updating: ${path}`); + } + return; + } + + if (!existsSync(path) || readFileSync(path, "utf8") !== source) { + throw new Error(`Herdr config changed while updating: ${path}`); + } +} + +function atomicWrite( + path: string, + contents: string, + mode: number, + beforeRename: () => void, +): void { + const tempPath = join( + dirname(path), + `.${basename(path)}.senzu-tmp-${process.pid}-${randomUUID()}`, + ); + let descriptor: number | undefined; + + try { + descriptor = openSync( + tempPath, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + mode, + ); + writeFileSync(descriptor, contents, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + beforeRename(); + renameSync(tempPath, path); + } catch (error) { + if (descriptor !== undefined) closeSync(descriptor); + if (existsSync(tempPath)) unlinkSync(tempPath); + throw error; + } +} + +function acquireUpdateLock(path: string): () => void { + const lockPath = `${path}.senzu-lock`; + + try { + return lockfile.lockSync(path, { + lockfilePath: lockPath, + realpath: false, + stale: 30_000, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ELOCKED") { + throw new Error( + `Another Senzu process is updating ${path}. Retry after it finishes.`, + ); + } + throw error; + } +} + +export function installHerdrTheme( + options: InstallHerdrOptions, +): InstallHerdrResult { + const requestedPath = resolve(options.configPath); + let effectivePath = requestedPath; + let symlink = false; + + try { + symlink = lstatSync(requestedPath).isSymbolicLink(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") throw error; + } + + if (symlink) { + effectivePath = realpathSync(requestedPath); + if (!options.dryRun && !options.force) { + throw new Error( + `Refusing to update symlink ${requestedPath} -> ${effectivePath}. ` + + "Run again with --force to update its target.", + ); + } + if (!options.dryRun && options.force) { + try { + accessSync(dirname(effectivePath), constants.W_OK); + } catch { + throw new Error( + `Symlink target directory is not writable: ${dirname(effectivePath)}`, + ); + } + } + } + + if ( + !options.dryRun && + (effectivePath === "/nix/store" || effectivePath.startsWith("/nix/store/")) + ) { + throw new Error( + `Cannot update Nix store path ${effectivePath}. ` + + 'Use inputs.senzu.lib.herdrTheme "" in your Nix config.', + ); + } + + if (!options.dryRun && existsSync(effectivePath)) { + try { + accessSync(effectivePath, constants.W_OK); + } catch { + throw new Error(`Herdr config is not writable: ${effectivePath}`); + } + } + + if (options.dryRun) { + const source = existsSync(effectivePath) + ? readFileSync(effectivePath, "utf8") + : ""; + const contents = mergeHerdrConfig(source, effectivePath, options.theme); + return { + status: contents === source ? "unchanged" : "dry-run", + requestedPath, + effectivePath, + symlink, + contents, + }; + } + + mkdirSync(dirname(effectivePath), { recursive: true }); + const releaseLock = acquireUpdateLock(effectivePath); + + try { + const existed = existsSync(effectivePath); + const source = existed ? readFileSync(effectivePath, "utf8") : ""; + const contents = mergeHerdrConfig(source, effectivePath, options.theme); + + if (contents === source) { + return { + status: "unchanged", + requestedPath, + effectivePath, + symlink, + contents, + }; + } + + assertSourceUnchanged(effectivePath, existed, source); + + let backupPath: string | undefined; + if (existed) { + backupPath = nextBackupPath(effectivePath, options.now ?? new Date()); + copyFileSync(effectivePath, backupPath, constants.COPYFILE_EXCL); + } + + const mode = existed ? statSync(effectivePath).mode & 0o777 : 0o600; + assertSourceUnchanged(effectivePath, existed, source); + atomicWrite(effectivePath, contents, mode, () => + assertSourceUnchanged(effectivePath, existed, source), + ); + + return { + status: "updated", + requestedPath, + effectivePath, + symlink, + backupPath, + contents, + }; + } finally { + releaseLock(); + } +} diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 550ebed..60891bf 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -16,11 +16,15 @@ import { getGenerator, getGeneratorNames, } from "../generators"; +import { buildHerdrTheme } from "../generators/herdr"; +import { installHerdrTheme, resolveHerdrConfigPath } from "./herdr-install"; import { runPreview } from "./preview"; interface Options { config: string; output: string; + force: boolean; + dryRun: boolean; } /** @@ -56,6 +60,8 @@ async function main(): Promise { version: { type: "boolean", short: "v" }, config: { type: "string", short: "c", default: "./config.json" }, output: { type: "string", short: "o", default: "." }, + force: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, }, allowPositionals: true, }); @@ -74,6 +80,8 @@ async function main(): Promise { const options: Options = { config: values.config as string, output: values.output as string, + force: values.force as boolean, + dryRun: values["dry-run"] as boolean, }; try { @@ -182,6 +190,12 @@ function install(args: string[], options: Options): void { console.error(`Targets: ${availableTargets}`); process.exit(1); } + + if (target === "herdr") { + installHerdr(args[1], options); + return; + } + const variant = args[1] ?? "all"; const outputFlag = args.indexOf("--output"); const outputPath = @@ -240,6 +254,54 @@ function install(args: string[], options: Options): void { console.log(`\nInstalled ${files.length} file(s).`); } +function installHerdr(variant: string | undefined, options: Options): void { + if (!variant || variant === "all") { + throw new Error( + "Herdr requires an explicit variant: senzu install herdr ", + ); + } + + const configPath = resolve(options.config); + const config = loadConfig(configPath); + const palette = getPalettes(config).find( + (candidate) => + candidate.name.toLowerCase().replace(/\s+/g, "-") === variant, + ); + if (!palette) { + throw new Error(`No variant found matching: ${variant}`); + } + + const result = installHerdrTheme({ + configPath: resolveHerdrConfigPath(), + theme: buildHerdrTheme(palette), + force: options.force, + dryRun: options.dryRun, + }); + + if (result.symlink) { + console.warn( + `Warning: Herdr config is a symlink to ${result.effectivePath}`, + ); + } + + switch (result.status) { + case "unchanged": + console.log(`Herdr already uses ${variant} in ${result.requestedPath}`); + break; + case "dry-run": + console.log(`Would update ${result.effectivePath} with ${variant}:\n`); + console.log(result.contents); + break; + case "updated": + console.log(`Updated ${result.effectivePath} with ${variant}.`); + if (result.backupPath) { + console.log(`Backup: ${result.backupPath}`); + } + console.log("Reload with: herdr server reload-config"); + break; + } +} + function getDefaultInstallDir(target: string): string { const home = homedir(); const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(home, ".config"); @@ -297,6 +359,8 @@ Commands: Options: -c, --config Config file path (default: ./config.json) -o, --output Output directory (default: .) + --dry-run Preview a Herdr config update without writing + --force Follow and update a Herdr config symlink target -h, --help Show this help -v, --version Show version @@ -309,6 +373,7 @@ Examples: senzu list List palettes and targets senzu install ghostty Install all Ghostty variants to ~/.config/ghostty/themes/ senzu install wezterm senzu-mono Install one variant to ~/.config/wezterm/colors/ + senzu install herdr senzu-muted Update Herdr config after creating a backup senzu preview Interactively browse and preview palettes senzu preview senzu-hc-light Preview a specific palette `); diff --git a/packages/cli/src/generators/herdr.ts b/packages/cli/src/generators/herdr.ts index 07d29e7..c19d3ff 100644 --- a/packages/cli/src/generators/herdr.ts +++ b/packages/cli/src/generators/herdr.ts @@ -2,23 +2,27 @@ import type { Palette } from "../core/types"; import { elevatedSurface, textMuted } from "../core/ui"; import { PerPaletteGenerator } from "./base"; -export class HerdrGenerator extends PerPaletteGenerator { - name = "herdr"; - description = "Herdr agent multiplexer config snippet"; - fileExtension = ".toml"; +export interface HerdrThemeConfig { + name: string; + auto_switch: boolean; + custom: Record; +} - generate(palette: Palette): string { - const baseTheme = - palette.appearance === "light" ? "catppuccin-latte" : "catppuccin"; - const muted = palette.colors.scorpion ?? textMuted(palette); - const overlay = palette.colors.grey ?? textMuted(palette); +export function buildHerdrTheme(palette: Palette): HerdrThemeConfig { + const baseTheme = + palette.appearance === "light" ? "catppuccin-latte" : "catppuccin"; + const muted = palette.colors.scorpion ?? textMuted(palette); + const overlay = palette.colors.grey ?? textMuted(palette); - // Higher contrast means lighter text on dark palettes and darker text on - // light palettes. - const overlay0 = palette.appearance === "light" ? overlay : muted; - const overlay1 = palette.appearance === "light" ? muted : overlay; + // Higher contrast means lighter text on dark palettes and darker text on + // light palettes. + const overlay0 = palette.appearance === "light" ? overlay : muted; + const overlay1 = palette.appearance === "light" ? muted : overlay; - const colors = { + return { + name: baseTheme, + auto_switch: false, + custom: { accent: palette.ui["panel.focused_border"] ?? palette.cursor, panel_bg: palette.ui["panel.background"] ?? palette.background, surface0: @@ -38,9 +42,18 @@ export class HerdrGenerator extends PerPaletteGenerator { blue: palette.ansi.blue, teal: palette.ansi.cyan, peach: palette.colors.raw_sienna ?? palette.semantic.warning, - }; + }, + }; +} - const customColors = Object.entries(colors) +export class HerdrGenerator extends PerPaletteGenerator { + name = "herdr"; + description = "Herdr agent multiplexer config snippet"; + fileExtension = ".toml"; + + generate(palette: Palette): string { + const theme = buildHerdrTheme(palette); + const customColors = Object.entries(theme.custom) .map(([name, color]) => `${name} = "${color}"`) .join("\n"); @@ -54,8 +67,8 @@ export class HerdrGenerator extends PerPaletteGenerator { # cannot switch automatically. Copy one Senzu variant at a time. [theme] -name = "${baseTheme}" -auto_switch = false +name = "${theme.name}" +auto_switch = ${theme.auto_switch} [theme.custom] ${customColors} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afe7c1b..114a569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: packages/cli: dependencies: + '@decimalturn/toml-patch': + specifier: ^2.1.0 + version: 2.1.0 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 zod: specifier: ^3.25.0 version: 3.25.76 @@ -33,6 +39,9 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.13.2 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 tsdown: specifier: ^0.22.0 version: 0.22.3(tsx@4.23.0)(typescript@5.9.3) @@ -184,6 +193,10 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@decimalturn/toml-patch@2.1.0': + resolution: {integrity: sha512-IICXlX7hcD9lGN7k+vDXcVnOfcbOEqMpeQHRnrhnmm7tZ8HluFrSgBKLNC72fQpNXynbJTC7pjtfNCaKrKlGyA==} + engines: {node: '>=14'} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -514,6 +527,12 @@ packages: '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -800,6 +819,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -820,6 +842,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -867,6 +893,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1182,6 +1211,8 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@decimalturn/toml-patch@2.1.0': {} + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1404,6 +1435,12 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -1657,6 +1694,12 @@ snapshots: prettier@2.8.8: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + quansync@0.2.11: {} quansync@1.0.0: {} @@ -1674,6 +1717,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + reusify@1.1.0: {} rolldown-plugin-dts@0.26.0(rolldown@1.1.4)(typescript@5.9.3): @@ -1727,6 +1772,8 @@ snapshots: shebang-regex@3.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} slash@3.0.0: {} diff --git a/share/herdr/senzu-cold.toml b/share/herdr/senzu-cold.toml new file mode 100644 index 0000000..505fd35 --- /dev/null +++ b/share/herdr/senzu-cold.toml @@ -0,0 +1,30 @@ +# Senzu Theme for Herdr: Senzu Cold +# Generated by senzu +# +# Merge these tables into ~/.config/herdr/config.toml, replacing any existing +# [theme] and [theme.custom] tables, then run: herdr server reload-config +# +# Herdr uses one shared set of custom colors, so custom light and dark palettes +# cannot switch automatically. Copy one Senzu variant at a time. + +[theme] +name = "catppuccin" +auto_switch = false + +[theme.custom] +accent = "#8fbfdc" +panel_bg = "#10161c" +surface0 = "#192835" +surface1 = "#283a4a" +surface_dim = "#101a24" +overlay0 = "#505b66" +overlay1 = "#707e8a" +text = "#b8d4ed" +subtext0 = "#8090a0" +mauve = "#c6b6ee" +green = "#99ad6a" +yellow = "#fad07a" +red = "#d74545" +blue = "#8197bf" +teal = "#668799" +peach = "#d98870" -- 2.51.2