diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 07fcf522..6a949a49 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,7 +14,7 @@ "userConfig": { "theme": { "type": "string", "title": "Theme", "description": "Theme name: glowup, aurora, dusk, classic, cyberpunk, vaporwave, high-contrast, or one in ~/.claude/glowup/themes", "default": "classic" }, "reducedMotion": { "type": "boolean", "title": "Reduced motion", "description": "No shimmer or spinner animation from glowup", "default": false }, - "pack": { "type": "string", "title": "Pack", "description": "Pack name: classic, crt, cozy, arcade, or one in ~/.claude/glowup/packs", "default": "classic" }, + "pack": { "type": "string", "title": "Pack", "description": "Pack name: classic, crt, cozy, arcade, one in ~/.claude/glowup/packs, or an official pack", "default": "classic" }, "spinner": { "type": "string", "title": "Spinner", "description": "Spinner name: stock, comet, eyes, orb-states, clawd, shimmer, scanline, ring, glitch, signal, or pack for the pack's own", "default": "pack" }, "pet": { "type": "string", "title": "Pet", "description": "clawd, robot, off, or a pet in ~/.claude/glowup/pets", "default": "clawd" }, "bubbles": { "type": "string", "title": "Speech bubbles", "description": "on (template lines), off, or haiku (Claude Haiku writes some lines; small paid calls on your account)", "default": "on" }, diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 25773150..a52d359a 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -113,9 +113,14 @@ {"answers": ["q7"], "author": "claude-code", "branch": "main", "cost_if_wrong": "The Konami egg is built on a key path that does not reach the mod", "depends_on": [], "evidence": [], "id": "c13", "kind": "claim", "migrated_from": "c108", "pinned": false, "rationale": "tmux spike with a throwaway mod (scratchpad/keyspike): after an SGR mouse click on the Client, ↑↑↓↓←→←→BA arrived as all ten keys; without the click, b and a landed in the prompt", "revisit": "A Claude Code release changes Client focus or onKey", "schema": 3, "scope": [], "session": "", "state": "accepted", "supersedes": [], "supports": [], "text": "On Claude Code 2.1.291 a Client's surface.onKey receives arrow keys (up, down, left, right) and letters once the person clicks the Client; before a click the keys go to the prompt, and Escape returns the focus", "ts": "2026-10-07T06:40:08+00:00"} {"alternatives": ["Crack overlays on one sheet", "Typing konami at the prompt to open a code dialog"], "answers": [], "author": "claude-code", "branch": "main", "choice": "Click Clawd's pet Client (it hops), type up up down down left right left right b a; the Client matches locally and posts once; the egg is a hidden built-in pet with four hand-drawn sheets, one per crack stage, cracking every 10 passing runs to stage 3", "cost_if_wrong": "Four sheets of art for one egg, and the code needs a terminal with mouse events", "decided_by": "owner", "depends_on": [], "evidence": [], "id": "d85", "kind": "decision", "migrated_from": "d109", "pinned": false, "rationale": "Owner picked 3 stages of 10 runs, full sheets per stage so cracks tilt with the wobble, and a hop on click with a quiet code", "revisit": "", "schema": 3, "scope": ["hooks/client/pet.tsx", "hooks/eggs.ts"], "session": "", "state": "adopted", "supersedes": [], "supports": [["c13"]], "text": "The Konami egg unlocks from a click on Clawd and the code typed into his Client", "ts": "2026-10-07T07:02:03+00:00"} {"answers": [], "author": "claude-code", "branch": "feat/konami-egg", "cost_if_wrong": "", "depends_on": [], "evidence": [], "id": "q12", "kind": "question", "migrated_from": "q110", "pinned": false, "rationale": "", "revisit": "", "schema": 3, "scope": ["hooks/eggs.ts"], "session": "", "state": "open", "supersedes": [], "supports": [], "text": "What does the Konami egg hatch into, and when?", "ts": "2026-10-07T08:54:10+00:00"} -{"alternatives":["Hints in the config pane's Pet row","Haiku-written hint lines"],"answers":[],"author":"claude-code","branch":"feat/egg-hints","choice":"pet list shows ○ ??? after the built-ins; the first done bubble of each 24 h with the pane shown says one of two fixed lines, taking turns by a stored count; hint turns never ask Haiku; the config Pet row is unchanged","cost_if_wrong":"A done bubble a day is replaced by a hint until the person finds the egg","decided_by":"owner","depends_on":[],"evidence":[],"id":"d86","kind":"decision","migrated_from":"d111","pinned":false,"rationale":"Owner wanted a nudge toward the code without giving it; Haiku could improvise or spoil the code","revisit":"","schema":3,"scope":["hooks/eggs.ts","hooks/register.tsx","hooks/command.ts"],"session":"","state":"adopted","supersedes":[],"supports":[],"text":"While the egg is locked, glowup hints at it with ??? in /glowup pet list and a fixed bubble line at most once a day","ts":"2026-10-07T11:56:39+00:00"} -{"alternatives":[],"answers":[],"author":"claude-code","branch":"feat/egg-hints","choice":"eggHint runs inside say() after its gates, so bubbles off, pet off, pane hidden and done-mood off all suppress it","cost_if_wrong":"Someone with done bubbles off never sees a hint","decided_by":"","depends_on":[],"evidence":[],"id":"d87","kind":"decision","migrated_from":"d112","pinned":false,"rationale":"Reuses say()'s gates instead of a second path","revisit":"","schema":3,"scope":["hooks/register.tsx"],"session":"","state":"adopted","supersedes":[],"supports":[],"text":"A finished turn's done bubble while the hint is due shows the hint; the setup turning off done bubbles also turns off hints","ts":"2026-10-07T11:56:40+00:00"} -{"schema":3,"kind":"decision","id":"d88","text":"A file drops out of the Changes tab when git no longer reports it and tracks it.","state":"adopted","ts":"2026-10-07T13:17:02+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/changes.ts","hooks/model.ts"],"rationale":"An uncommitted new file is also absent from git diff, so absence alone cannot tell a commit from an untracked file","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"After the numstat diff, ask git ls-files which unreported files are tracked and drop those; untracked and ignored files keep their own counts","alternatives":["Drop every unreported file","Drop files only on a HEAD rebase"],"decided_by":""} -{"schema":3,"kind":"decision","id":"d89","text":"A refresh that finds new counts for a file touches it again.","state":"adopted","ts":"2026-10-07T13:17:02+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/changes.ts","hooks/model.ts"],"rationale":"The brief makes at the sort key for Changes and Diff, and a git-only edit has to reorder; max keeps an edit that landed while git ran","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"refreshCounts stamps the refresh time when counts change; mergeCounts keeps the later of the two times","alternatives":["Keep the first-seen time for refreshed files"],"decided_by":""} -{"schema":3,"kind":"decision","id":"d90","text":"The Diff tab reads hunks only while it is the visible tab.","state":"adopted","ts":"2026-10-07T13:17:02+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/diff.ts","hooks/register.tsx","hooks/pane.tsx"],"rationale":"git diff is the costly call, and the render hook can only read published state","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Hunks live in the published pane view as a plain record, are read on the refresh queue while the tab shows and on switching to it, and untracked files are read with head -c 64 KB","alternatives":["Read the diff on every refresh","Keep hunks in module state outside the view"],"decided_by":""} -{"schema":3,"kind":"decision","id":"d91","text":"In the glowup pane, a person's scroll move of at least the engine's bodyRows pages by the open tab's window; wheel ticks and plugin scrolls move their own distance","state":"adopted","ts":"2026-10-07T13:19:24+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/register.tsx","hooks/pane.tsx"],"rationale":"The engine sizes page keys by bodyRows and Home/End by contentRows; the pane fills its body so the two are equal and indistinguishable, and paging by bodyRows skipped rows because the tab window is shorter","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"Claude Code tells the scroll keys apart in UiScrollInput","cost_if_wrong":"Home and End page instead of jumping to an end","pinned":false,"choice":"ui.scroll hook: origin person and |by| >= bodyRows → sign(by) × the tab window height from the last render; otherwise by as given, clamped to the tab's last offset","alternatives":["Treat |by| >= contentRows as Home/End and jump to an end"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d86","text":"Official packs are listed in one hosted index that glowup fetches and caches.","state":"adopted","ts":"2026-10-07T13:03:53+00:00","author":"claude-code","session":"","branch":"feat/pack-catalog","scope":["hooks/catalog.ts","docs/web/public/packs.json"],"rationale":"Adding a pack needs no glowup release, and one committed file is the review point for what is official.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"https://glowup.khimani.dev/packs.json, committed at docs/web/public/packs.json, cached at ${configDir}/glowup/catalog.json","alternatives":["Packs bundled in each glowup release","A GitHub search for glowup packs"],"decided_by":"owner"} +{"schema":3,"kind":"decision","id":"d87","text":"A catalog pack downloads only when the person picks it; browsing never fetches pack files.","state":"adopted","ts":"2026-10-07T13:03:53+00:00","author":"claude-code","session":"","branch":"feat/pack-catalog","scope":["hooks/catalog.ts","hooks/command.ts","hooks/configpane.tsx","hooks/register.tsx"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Install on pick: /glowup pack , or the config pane's Install button; cycling looks in the pane never downloads","alternatives":["Prefetch every catalog pack at session start"],"decided_by":"owner"} +{"schema":3,"kind":"decision","id":"d88","text":"Every downloaded catalog file passes the same checks as /glowup pack and /glowup theme add before anything is written.","state":"adopted","ts":"2026-10-07T13:03:53+00:00","author":"claude-code","session":"","branch":"feat/pack-catalog","scope":["hooks/catalog.ts","hooks/userpacks.ts","hooks/userthemes.ts"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Shared checkPackText/checkThemeText run on all files first; a refused install writes nothing","alternatives":[],"decided_by":""} +{"schema":3,"kind":"decision","id":"d89","text":"The studio does not show catalog packs in this release.","state":"adopted","ts":"2026-10-07T13:03:53+00:00","author":"claude-code","session":"","branch":"feat/pack-catalog","scope":["docs/web"],"rationale":"","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Studio support deferred","alternatives":[],"decided_by":"owner"} +{"schema":3,"kind":"decision","id":"d90","text":"feat/pack-catalog branches from feat/spinners-slab, so it ships after PR #30.","state":"adopted","ts":"2026-10-07T13:03:53+00:00","author":"claude-code","session":"","branch":"feat/pack-catalog","scope":[],"rationale":"Catalog packs such as Nous need slab and scanline, which only exist on that branch.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"A rebase if PR #30 changes or is dropped.","pinned":false,"choice":"Base on feat/spinners-slab f62f6cf; the PR targets main and shows #30's commits until #30 merges","alternatives":["Base on main and rebase once #30 merges"],"decided_by":""} +{"alternatives":["Hints in the config pane's Pet row","Haiku-written hint lines"],"answers":[],"author":"claude-code","branch":"feat/egg-hints","choice":"pet list shows ○ ??? after the built-ins; the first done bubble of each 24 h with the pane shown says one of two fixed lines, taking turns by a stored count; hint turns never ask Haiku; the config Pet row is unchanged","cost_if_wrong":"A done bubble a day is replaced by a hint until the person finds the egg","decided_by":"owner","depends_on":[],"evidence":[],"id":"d91","kind":"decision","migrated_from":"d111","pinned":false,"rationale":"Owner wanted a nudge toward the code without giving it; Haiku could improvise or spoil the code","revisit":"","schema":3,"scope":["hooks/eggs.ts","hooks/register.tsx","hooks/command.ts"],"session":"","state":"adopted","supersedes":[],"supports":[],"text":"While the egg is locked, glowup hints at it with ??? in /glowup pet list and a fixed bubble line at most once a day","ts":"2026-10-07T11:56:39+00:00"} +{"alternatives":[],"answers":[],"author":"claude-code","branch":"feat/egg-hints","choice":"eggHint runs inside say() after its gates, so bubbles off, pet off, pane hidden and done-mood off all suppress it","cost_if_wrong":"Someone with done bubbles off never sees a hint","decided_by":"","depends_on":[],"evidence":[],"id":"d92","kind":"decision","migrated_from":"d112","pinned":false,"rationale":"Reuses say()'s gates instead of a second path","revisit":"","schema":3,"scope":["hooks/register.tsx"],"session":"","state":"adopted","supersedes":[],"supports":[],"text":"A finished turn's done bubble while the hint is due shows the hint; the setup turning off done bubbles also turns off hints","ts":"2026-10-07T11:56:40+00:00"} +{"schema":3,"kind":"decision","id":"d93","text":"A file drops out of the Changes tab when git no longer reports it and tracks it.","state":"adopted","ts":"2026-10-07T13:17:02+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/changes.ts","hooks/model.ts"],"rationale":"An uncommitted new file is also absent from git diff, so absence alone cannot tell a commit from an untracked file","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"After the numstat diff, ask git ls-files which unreported files are tracked and drop those; untracked and ignored files keep their own counts","alternatives":["Drop every unreported file","Drop files only on a HEAD rebase"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d94","text":"A refresh that finds new counts for a file touches it again.","state":"adopted","ts":"2026-10-07T13:17:02+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/changes.ts","hooks/model.ts"],"rationale":"The brief makes at the sort key for Changes and Diff, and a git-only edit has to reorder; max keeps an edit that landed while git ran","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"refreshCounts stamps the refresh time when counts change; mergeCounts keeps the later of the two times","alternatives":["Keep the first-seen time for refreshed files"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d95","text":"The Diff tab reads hunks only while it is the visible tab.","state":"adopted","ts":"2026-10-07T13:17:02+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/diff.ts","hooks/register.tsx","hooks/pane.tsx"],"rationale":"git diff is the costly call, and the render hook can only read published state","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"","pinned":false,"choice":"Hunks live in the published pane view as a plain record, are read on the refresh queue while the tab shows and on switching to it, and untracked files are read with head -c 64 KB","alternatives":["Read the diff on every refresh","Keep hunks in module state outside the view"],"decided_by":""} +{"schema":3,"kind":"decision","id":"d96","text":"In the glowup pane, a person's scroll move of at least the engine's bodyRows pages by the open tab's window; wheel ticks and plugin scrolls move their own distance","state":"adopted","ts":"2026-10-07T13:19:24+00:00","author":"claude-code","session":"","branch":"feat/pane-and-hints","scope":["hooks/register.tsx","hooks/pane.tsx"],"rationale":"The engine sizes page keys by bodyRows and Home/End by contentRows; the pane fills its body so the two are equal and indistinguishable, and paging by bodyRows skipped rows because the tab window is shorter","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"Claude Code tells the scroll keys apart in UiScrollInput","cost_if_wrong":"Home and End page instead of jumping to an end","pinned":false,"choice":"ui.scroll hook: origin person and |by| >= bodyRows → sign(by) × the tab window height from the last render; otherwise by as given, clamped to the tab's last offset","alternatives":["Treat |by| >= contentRows as Home/End and jump to an end"],"decided_by":""} diff --git a/CHANGELOG.md b/CHANGELOG.md index dca5b391..67b617ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Official packs: `/glowup pack oxide` installs a pack from glowup.khimani.dev/packs.json on first use. The config pane and the installer list them, and `/glowup pack update` fetches them again without touching files you changed. + ## [0.11.0] - 2026-10-07 ### Added diff --git a/docs/commands.md b/docs/commands.md index 107c03df..d97e3019 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,8 +53,10 @@ Each refusal prints a message naming the failed check. Adding a theme whose name | Command | What it does | | --- | --- | -| `/glowup pack ` | Apply a pack to both layers. Prints `Pack: `. | -| `/glowup pack list` | List built-in and installed packs. A filled dot marks the active one. If the look is a mix of packs or has a theme or spinner on top, a last line starts `custom mix:` and names each layer. | +| `/glowup pack ` | Apply a pack to both layers. Prints `Pack: `. An [official pack](packs.md#official-packs) is installed on first use. | +| `/glowup pack --force` | The same, but an official pack replaces an installed pack of that name. | +| `/glowup pack update [name]` | Fetch the named official pack, or every one you installed, again. A pack file you changed is skipped. | +| `/glowup pack list` | List built-in and installed packs, then official packs you have not installed. A filled dot marks the active one. If the look is a mix of packs or has a theme or spinner on top, a last line starts `custom mix:` and names each layer. | | `/glowup pack save ` | Save the current look as a self-contained pack file in `~/.claude/glowup/packs`. Prints `Saved pack "" to .` | | `/glowup pack ` | Download, check and install a pack from an `https://` URL, then apply it. | | `/glowup pack --force` | The same, replacing an installed pack of that name. | @@ -135,7 +137,7 @@ Your layout and behavior settings. They are yours alone: installing someone's pa `/glowup config` opens a pane with a row for each choice, a preview of the look under them, and Done, Copy studio link and Reset at the bottom. Up and down move between rows. -The rows are Pack, Spinner, one for each color role, Band, Tabs, Meter, Pet, Sleeps, Bubbles, Bubble, Moods, Motion and Status. A row shown as ‹ value › cycles to its next value when you press Enter and applies it at once. The other rows are fields you type into and submit with Enter. A field you have not edited does nothing on Enter. +The rows are Pack, Spinner, one for each color role, Band, Tabs, Meter, Pet, Sleeps, Bubbles, Bubble, Moods, Motion and Status. A row shown as ‹ value › cycles to its next value when you press Enter and applies it at once, except that an official pack on the Pack row applies only when you press the Install button. The other rows are fields you type into and submit with Enter. A field you have not edited does nothing on Enter. A color field takes a hex such as `#8ecbff` or `8ecbff`, and an empty one clears that role's override. Band, Tabs, Moods and Status take a list separated by commas or spaces, and the order you type is the order glowup draws them. An empty Band or Moods field means none, an empty Status field means the default fields, and Tabs needs at least one. The pane says what it did, or why it refused, on the line under the buttons. diff --git a/docs/install.md b/docs/install.md index 402d94eb..be524026 100644 --- a/docs/install.md +++ b/docs/install.md @@ -15,6 +15,8 @@ The script downloads the installer for your platform from the latest GitHub rele The installer previews each pack as you move through them, then asks whether to take the pack as it is or customize it. Customizing lets you swap in any theme for the pack's colors and any spinner for its own. After that come the pet, speech bubbles and reduced motion. On a terminal at least 102 columns wide, Clawd stands next to the form and comments on the current pick. +After the built-in packs, the picker lists the [official packs](packs.md#official-packs). It shows a name and description instead of colors, and an official pack downloads in your first session. If glowup is already installed and is too old to download official packs, the installer leaves the current pack setting alone, says it can't set the pack yet, and tells you to run `/glowup pack ` after glowup updates. + When you finish, the installer runs `claude plugin marketplace add NovusEdge/glowup` and `claude plugin install glowup@glowup` with your choices, and you restart Claude Code if it was already running. If glowup is already installed, the installer only updates settings. Your theme and spinner change only if you set them under Customize or with `--theme` or `--spinner`, and with `--yes` only the options you pass are changed. A later `/glowup` command replaces what the installer set. @@ -34,7 +36,7 @@ curl -fsSL https://glowup.khimani.dev/install.sh | sh -s -- --yes --pack crt | Option | What it does | | --- | --- | | `--yes` | Install without the picker, with the defaults or the options below. The script adds it when there is no terminal. | -| `--pack NAME` | `classic`, `crt`, `cozy` or `arcade`. Default `classic`. | +| `--pack NAME` | `classic`, `crt`, `cozy`, `arcade` or an official pack name. Default `classic`. | | `--theme NAME` | A theme on top of the pack: `classic`, `glowup`, `aurora`, `dusk`, `cyberpunk`, `vaporwave` or `high-contrast`. `classic` keeps the pack's own colors. Default: not set. | | `--spinner NAME` | `stock`, `comet`, `eyes`, `orb-states`, `clawd`, `shimmer`, `scanline`, `ring`, `glitch`, `signal`, or `pack` for the pack's own. Default `pack`. | | `--pet clawd\|off` | Default `clawd`. | diff --git a/docs/packs.md b/docs/packs.md index 96446c35..f2b2f523 100644 --- a/docs/packs.md +++ b/docs/packs.md @@ -25,6 +25,22 @@ A pack decides how glowup looks: the colors, how your prompts and Claude's repli ![The arcade pack](assets/pack-arcade.png) +## Official packs + +More packs are listed at [glowup.khimani.dev/packs.json](https://glowup.khimani.dev/packs.json). They are not bundled with glowup, so a new one needs no release. `/glowup pack ` downloads one the first time you use it and applies it: + +```text title="claude code" +/glowup pack oxide +``` + +`/glowup pack list` shows the official packs you have not installed yet, marked `not installed`. A pack that needs a newer glowup than yours is marked `needs glowup ` and is never installed. + +- Every file passes the same checks as a pack from a URL, and nothing is written unless all of them pass. +- Packs do not update by themselves. `/glowup pack update [name]` fetches them again. It skips a pack file you changed or replaced, and says so. +- If you made a pack with the same name, yours wins and nothing is downloaded. `/glowup pack --force` replaces it with the official one. +- glowup refreshes its copy of the list at most once a day, in interactive sessions only. Without a network it uses the copy it has. +- The [config pane](commands.md#config) lists official packs you have not installed. Cycling onto one downloads nothing; press its Install button to install and apply it. + ## Mixing and adjusting A pack has two parts that can be changed separately: its colors (palette, row style, borders) and its motion (the spinner and its shimmer). You can keep one pack and change pieces of it: diff --git a/docs/web/public/packs.json b/docs/web/public/packs.json new file mode 100644 index 00000000..2adf0058 --- /dev/null +++ b/docs/web/public/packs.json @@ -0,0 +1,19 @@ +{ + "format": 1, + "packs": [ + { + "name": "oxide", + "description": "bone text and red oxide on warm ink", + "pack": "https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/oxide.json", + "themes": ["https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/themes/oxide.json"], + "minGlowup": "0.8.1" + }, + { + "name": "oxide-paper", + "description": "red oxide and ink on bone, with a dithered orb", + "pack": "https://raw.githubusercontent.com/NovusEdge/glowup-oxide/main/oxide-paper.json", + "themes": [], + "minGlowup": "0.8.1" + } + ] +} diff --git a/hooks/catalog.ts b/hooks/catalog.ts new file mode 100644 index 00000000..64c713aa --- /dev/null +++ b/hooks/catalog.ts @@ -0,0 +1,140 @@ +// JSX-free: the docs site may import it. +import type { Host } from './host.ts' +import { parseJsonc, isUnsafe, shown } from './themes.ts' +import { packNameProblem } from './packs.ts' +import { checkThemeText, THEME_DIR } from './userthemes.ts' +import { checkPackText, PACK_DIR } from './userpacks.ts' + +export type CatalogEntry = { name: string; description: string; pack: string; themes: string[]; minGlowup: string } + +export const CATALOG_URL = 'https://glowup.khimani.dev/packs.json' +export const CATALOG_FILE = (configDir: string) => `${configDir}/glowup/catalog.json` + +// The pack subcommands: a pack with one of these names could never be picked by name. +const RESERVED = ['list', 'save', 'update'] +const MAX_ENTRIES = 100 +const VERSION = /^\d+(\.\d+)*$/ +const isHttps = (v: unknown): v is string => typeof v === 'string' && v.startsWith('https://') +const printable = (s: string) => [...s].every(c => !isUnsafe(c.codePointAt(0)!)) + +function entryOf(v: unknown): CatalogEntry | string { + if (typeof v !== 'object' || v === null || Array.isArray(v)) return 'an entry is not an object' + const e = v as Record + const name = typeof e.name === 'string' ? e.name : '' + const label = `entry "${shown(name)}"` + const problem = packNameProblem(name) ?? (RESERVED.includes(name) ? `"${name}" is a /glowup pack subcommand` : undefined) + if (problem) return `${label}: ${problem}` + if (typeof e.description !== 'string' || e.description.length > 80 || !printable(e.description)) return `${label}: description must be printable text of at most 80 characters` + if (!isHttps(e.pack)) return `${label}: pack must be an https:// URL` + const themes = e.themes ?? [] + if (!Array.isArray(themes) || !themes.every(isHttps)) return `${label}: themes must be https:// URLs` + if (typeof e.minGlowup !== 'string' || !VERSION.test(e.minGlowup)) return `${label}: minGlowup must be a version such as 0.10.0` + return { name, description: e.description, pack: e.pack, themes, minGlowup: e.minGlowup } +} + +export function parseCatalog(json: unknown): { entries: CatalogEntry[]; errors: string[] } | undefined { + if (typeof json !== 'object' || json === null || Array.isArray(json)) return undefined + const { format, packs } = json as { format?: unknown; packs?: unknown } + if (format !== 1 || !Array.isArray(packs)) return undefined + const entries: CatalogEntry[] = [], errors: string[] = [] + for (const p of packs.slice(0, MAX_ENTRIES)) { + const e = entryOf(p) + if (typeof e === 'string') errors.push(e) + else if (entries.some(x => x.name === e.name)) errors.push(`entry "${e.name}" is listed twice; the first is kept`) + else entries.push(e) + } + return { entries, errors } +} + +export function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number), pb = b.split('.').map(Number) + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0) + if (d) return d + } + return 0 +} + +// A glowup that cannot read its own version runs no catalog pack: it may be older than any of them. +export const canRun = (entry: CatalogEntry, own: string | undefined) => + own !== undefined && VERSION.test(own) && compareVersions(own, entry.minGlowup) >= 0 + +export async function loadCatalog(host: Host): Promise { + try { return parseCatalog(parseJsonc(await host.readFile(CATALOG_FILE(host.configDir))))?.entries ?? [] } catch { return [] } +} + +export async function refreshCatalog(host: Host): Promise { + let r: Awaited> + try { r = await host.fetchText(CATALOG_URL) } catch { return undefined } + if (!r.ok) return undefined + let parsed: ReturnType + try { parsed = parseCatalog(parseJsonc(r.text)) } catch { return undefined } + if (!parsed) return undefined + await host.writeFile(CATALOG_FILE(host.configDir), r.text) + return parsed.entries +} + +export const RECORD_FILE = (configDir: string) => `${configDir}/glowup/catalog-installed.json` +export type CatalogRecord = { packs: Record; themes: Record } + +// FNV-1a: detects "the file is still what glowup wrote", not tampering. +export function textHash(text: string): string { + let h = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193) } + return (h >>> 0).toString(16).padStart(8, '0') +} + +export async function loadRecord(host: Host): Promise { + const out: CatalogRecord = { packs: {}, themes: {} } + let v: any + try { v = parseJsonc(await host.readFile(RECORD_FILE(host.configDir))) } catch { return out } + for (const [k, p] of Object.entries(v?.packs ?? {})) if (typeof (p as any)?.url === 'string' && typeof (p as any)?.hash === 'string') out.packs[k] = { url: (p as any).url, hash: (p as any).hash } + for (const [k, t] of Object.entries(v?.themes ?? {})) if (typeof (t as any)?.hash === 'string') out.themes[k] = { hash: (t as any).hash } + return out +} + +async function download(host: Host, url: string, what: string): Promise { + try { + const r = await host.fetchText(url) + return r.ok ? r.text : { message: `Could not download ${what} (HTTP ${r.status}).` } + } catch (err) { return { message: `Could not download ${what}: ${err instanceof Error ? err.message : String(err)}` } } +} + +// Unchanged since glowup wrote it: the only state in which glowup may replace a file. +async function untouched(host: Host, path: string, hash: string | undefined) { + if (hash === undefined) return false + try { return textHash(await host.readFile(path)) === hash } catch { return false } +} + +// Everything is downloaded and checked before the first write, so a refused install leaves no file behind. +export async function installEntry(host: Host, entry: CatalogEntry, opts: { force: boolean }): Promise<{ name?: string; message: string }> { + const packText = await download(host, entry.pack, entry.name) + if (typeof packText !== 'string') return packText + let named: unknown + try { named = (parseJsonc(packText) as { name?: unknown } | null)?.name } catch { named = undefined } + if (typeof named !== 'string') return { message: `The catalog's ${entry.name} downloads something that is not a pack file; nothing was installed.` } + if (named.toLowerCase() !== entry.name) return { message: `The catalog's ${entry.name} downloads a pack named "${shown(String(named))}"; nothing was installed.` } + const record = await loadRecord(host) + const themes: { name: string; text: string; file: unknown }[] = [] + for (const url of entry.themes) { + const text = await download(host, url, `${entry.name}'s theme`) + if (typeof text !== 'string') return text + const checked = await checkThemeText(host, text, Object.fromEntries(themes.map(t => [t.name, t.file]))) + if ('error' in checked) return { message: `${entry.name}: ${checked.error}` } + themes.push({ name: checked.name, text, file: checked.file }) + } + const pack = await checkPackText(host, packText, opts.force, Object.fromEntries(themes.map(t => [t.name, t.file]))) + if ('message' in pack) return pack + const dir = THEME_DIR(host.configDir) + for (const t of themes) { + const path = `${dir}/${t.name}.json` + const own = Object.hasOwn(record.themes, t.name) ? record.themes[t.name]!.hash : undefined + if (await host.exists(path) && !(opts.force && await untouched(host, path, own))) continue + await host.writeFile(path, t.text) + record.themes[t.name] = { hash: textHash(t.text) } + } + await host.writeFile(`${PACK_DIR(host.configDir)}/${pack.name}.json`, packText) + record.packs[pack.name] = { url: entry.pack, hash: textHash(packText) } + await host.writeFile(RECORD_FILE(host.configDir), JSON.stringify(record, null, 2) + '\n') + return { name: pack.name, message: `Installed pack "${pack.name}".` } +} diff --git a/hooks/command.ts b/hooks/command.ts index 5a3eb64f..0422f081 100644 --- a/hooks/command.ts +++ b/hooks/command.ts @@ -5,7 +5,8 @@ import { takeOver, restore } from './statusline.ts' import { resolveLook, exportMix, normalizeHex, cleanOverrides, SPINNER_IDS, type Mix } from './packs.ts' import { PACKS } from './packpresets.ts' import { decodeLink, isStudioLink } from './link.ts' -import { loadUserPacks, addPack, savePack, installPackText, SAFE_NAME } from './userpacks.ts' +import { loadUserPacks, addPack, savePack, installPackText, SAFE_NAME, PACK_DIR } from './userpacks.ts' +import { loadCatalog, canRun, installEntry, loadRecord, textHash } from './catalog.ts' import { parseScheme } from './schemes.ts' import { konsoleScheme } from './konsole.ts' import { eggSheet, type PetSetting, type PetSheet } from './pets.ts' @@ -64,6 +65,9 @@ async function packList(host: Host, ctl: Ctl): Promise { const { look } = resolveLook(m, user, await loadUserThemes(host)) const whole = m.colors === m.motion && m.theme === undefined && m.spinner === undefined && look.colorsFrom === m.colors && look.motionFrom === m.motion const lines = names.map(n => `${whole && n === m.colors ? '●' : '○'} ${n}`) + for (const e of await loadCatalog(host)) { + if (!names.includes(e.name)) lines.push(`○ ${e.name} (${canRun(e, host.version) ? 'not installed' : `needs glowup ${e.minGlowup}`}) ${e.description}`) + } if (!whole) lines.push(`custom mix: colors ${m.colors}, motion ${m.motion}${m.theme ? `, theme ${m.theme}` : ''}${m.spinner ? `, spinner ${m.spinner}` : ''}`) return lines.join('\n') } @@ -103,15 +107,45 @@ async function resetColor(host: Host, ctl: Ctl, role: string | undefined): Promi return `Color ${role}: back to the look's own.` } -async function usePack(host: Host, ctl: Ctl, name: string): Promise { +async function usePack(host: Host, ctl: Ctl, name: string, force = false): Promise { + const user = await loadUserPacks(host) + const local = Object.hasOwn(PACKS, name) || Object.hasOwn(user, name) + const entry = !Object.hasOwn(PACKS, name) && (force || !local) ? (await loadCatalog(host)).find(e => e.name === name) : undefined + if (entry) { + if (!canRun(entry, host.version)) return `${name} needs glowup ${entry.minGlowup}; this is ${host.version ?? 'an unknown version'}.` + const r = await installEntry(host, entry, { force }) + if (!r.name) return r.message + } const keep = ctl.mix().overrides const mix: Mix = { colors: name, motion: name, ...(keep && { overrides: keep }) } const { errors } = resolveLook(mix, await loadUserPacks(host), await loadUserThemes(host)) - if (errors.length) return errors.join('\n') + if (errors.length) { + const official = local || entry ? [] : (await loadCatalog(host)).map(e => e.name) + return [...errors, ...(official.length ? [`Official packs: ${official.join(', ')}`] : [])].join('\n') + } await applyMix(host, ctl, mix) return `Pack: ${name}` } +export async function updatePacks(host: Host, only: string | undefined): Promise { + const record = await loadRecord(host), catalog = await loadCatalog(host) + if (only !== undefined && !Object.hasOwn(record.packs, only)) return `${only} was not installed from the catalog.` + const names = only !== undefined ? [only] : Object.keys(record.packs) + if (!names.length) return 'No packs were installed from the catalog.' + const lines: string[] = [] + for (const n of names) { + const e = catalog.find(x => x.name === n) + if (!e) { lines.push(`${n} is no longer in the catalog.`); continue } + if (!canRun(e, host.version)) { lines.push(`${n} needs glowup ${e.minGlowup}; not updated.`); continue } + let current: string + try { current = await host.readFile(`${PACK_DIR(host.configDir)}/${n}.json`) } catch { lines.push(`${n} is not installed; use /glowup pack ${n}.`); continue } + if (textHash(current) !== record.packs[n]!.hash) { lines.push(`${n} changed since glowup installed it; not updated. Use /glowup pack ${n} --force to replace it.`); continue } + const r = await installEntry(host, e, { force: true }) + lines.push(r.name ? `Updated ${n}.` : r.message) + } + return lines.join('\n') +} + async function importScheme(host: Host, ctl: Ctl, rawPath: string, force: boolean): Promise { const read = await readLocal(host, rawPath, MAX_SCHEME_BYTES) if ('error' in read) return read.error @@ -229,7 +263,8 @@ export async function runCommand(host: Host, args: string, ctl: Ctl): Promise `${n === look.motion.spinner ? '●' : '○'} ${n}`).join('\n') diff --git a/hooks/configpane.tsx b/hooks/configpane.tsx index de290f40..dfc63765 100644 --- a/hooks/configpane.tsx +++ b/hooks/configpane.tsx @@ -4,7 +4,7 @@ import { fit, hearts, renderSegs, type Seg } from './layout.tsx' import { cycleValue, inputValue, type ConfigState, type CycleId, type InputId, type Role } from './configrows.ts' export type ConfigNote = { text: string; tone: 'ok' | 'error' } -export type ConfigHandlers = { cycle(id: CycleId): void; input(id: InputId, text: string): void; done(): void; reset(): void; copyLink(): void } +export type ConfigHandlers = { cycle(id: CycleId): void; install(name: string): void; input(id: InputId, text: string): void; done(): void; reset(): void; copyLink(): void } type Els = { Box: any; Text: any; Button: any; Input?: any; Link?: any } const LABEL = 11 @@ -57,7 +57,9 @@ export function renderConfig(els: Els, s: ConfigState, look: Look, width: number return ( {title('LOOK')} - {cycle('pack', 'Pack', true)} + {s.pick + ? {cycle('pack', 'Pack', true)}