@liebstoeckel/cli 0.3.8 → 0.3.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liebstoeckel/cli",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "The liebstoeckel CLI to scaffold, develop, build, and present code-first decks.",
5
5
  "keywords": [
6
6
  "liebstoeckel",
@@ -45,12 +45,12 @@
45
45
  "./new": "./src/new.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@liebstoeckel/engine": "^0.3.6",
49
- "@liebstoeckel/live-server": "^0.3.6",
50
- "@liebstoeckel/present-relay": "^0.3.6",
48
+ "@liebstoeckel/engine": "^0.3.7",
49
+ "@liebstoeckel/live-server": "^0.3.7",
50
+ "@liebstoeckel/present-relay": "^0.3.7",
51
51
  "@liebstoeckel/registry": "^0.3.4",
52
52
  "@liebstoeckel/theme": "^0.3.3",
53
- "@liebstoeckel/thumbnails": "^0.3.6",
53
+ "@liebstoeckel/thumbnails": "^0.3.7",
54
54
  "citty": "^0.2.2"
55
55
  },
56
56
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -101,6 +101,22 @@ is untrusted and `build`/`build --check` on it fails with an `untrusted deck` er
101
101
  trust question to the user and only proceed once they explicitly confirm. See the trust
102
102
  note in `references/editing.md`.
103
103
 
104
+ ## Brand a deck (custom theme / fonts)
105
+
106
+ Pick a built-in brand with `liebstoeckel new <name> --brand <liebstoeckel|nocturne|acme|sunset>`,
107
+ or create a **custom** one. A brand is a typed token object (colors, fonts, chart
108
+ palette); define it with `defineTheme` in `brands/<name>.ts` and wire it via
109
+ `<Present brands={["<name>"]} brandThemes={[brand]}>` + `<body data-brand="<name>">`.
110
+ **Don't hand-write a raw `[data-brand]` CSS block, and don't hardcode hex in slides**
111
+ — use role tokens (`text-primary`, `bg-surface`, `font-heading`).
112
+
113
+ Fonts are the one real trap: a brand names a font, but glyphs only ship if a matching
114
+ `@font-face` is **bundled**. Naming a `"… Variable"` font with no bundled face
115
+ **silently falls back to a system font** (build prints `⚠ brand font won't render`).
116
+ Use a house family (already bundled) or add one latin variable `@font-face` — never
117
+ `import "@fontsource-variable/<id>/index.css"`. Full recipe + verification in
118
+ `references/brands.md`.
119
+
104
120
  ## Make a deck interactive (live plugins)
105
121
 
106
122
  Plugins add live, synced audience interaction; offline the deck still builds and shows
@@ -136,6 +152,7 @@ plugin, see `references/build-plugins.md`.
136
152
 
137
153
  - `references/components.md` — component types in the registry, using scaffolded components/charts, data shapes, the `add` workflow.
138
154
  - `references/authoring.md` — slide file conventions (MDX/TSX, notes, steps, layout, brands).
155
+ - `references/brands.md` — create a custom brand (typed `defineTheme` + `brandThemes`) and bundle fonts correctly (the silent-fallback trap).
139
156
  - `references/editing.md` — editing decks: add/replace slides, swap charts, re-theme, eject.
140
157
  - `references/plugins.md` — add live plugins (poll/qa/reactions): register, place, present live.
141
158
  - `references/build-plugins.md` — author a custom plugin (`definePlugin`, state, surfaces, server).
@@ -74,6 +74,9 @@ import { Step } from "@liebstoeckel/engine";
74
74
  --brand <brand>` sets the default. Re-theming a deck = change the brand; every
75
75
  component and token follows. Don't hard-code hex colors — use tokens / `useBrandColors`.
76
76
 
77
+ To **create a custom brand** (typed `defineTheme` in `brands/<name>.ts` wired via
78
+ `brandThemes`) and bundle its fonts, see `references/brands.md`.
79
+
77
80
  ## MDX slides
78
81
 
79
82
  A slide can be `.mdx` instead of `.tsx` for prose-heavy content; it still
@@ -0,0 +1,141 @@
1
+ # Brands (custom themes & fonts)
2
+
3
+ A **brand** is one typed token object — colors, fonts, a chart palette — and the
4
+ whole deck derives its look from it. Slides use role tokens (`text-primary`,
5
+ `bg-surface`, `text-accent`, `font-heading`), never literal hex/hue, so the same
6
+ slide is correct under any brand.
7
+
8
+ Do **not** hand-roll a raw `[data-brand="…"] { --brand-*: … }` CSS block. That
9
+ re-implements, by hand, what `defineTheme` + `brandThemes` generate for you (and
10
+ is how a past session drifted). Use the typed path below.
11
+
12
+ ## Pick a brand
13
+
14
+ - **Built-in** — `liebstoeckel`, `nocturne`, `acme`, `sunset`. Scaffold with one:
15
+ ```bash
16
+ liebstoeckel new <name> --dir presentations --brand nocturne
17
+ ```
18
+ `liebstoeckel` and `nocturne` bundle their fonts — zero font work. `acme` and
19
+ `sunset` use `"Inter"` / `"JetBrains Mono"`, which are **not** bundled: they
20
+ render only where those fonts are installed and otherwise fall back to
21
+ `system-ui` / `monospace` (e.g. PDF export under headless Chromium). To bundle a
22
+ font for a brand, see **Fonts** below.
23
+
24
+ - **Custom** — define your own typed brand as owned source in the deck (below).
25
+ `--brand <id>` alone does **not** create the brand; an unknown id silently
26
+ renders with the default tokens. To brand a deck in something custom, add a
27
+ `brands/<name>.ts` and wire it in `main.tsx` as shown next.
28
+
29
+ ## Create a custom brand
30
+
31
+ 1. **Write `brands/<name>.ts`** with `defineTheme` (full token list in the table):
32
+ ```ts
33
+ import { defineTheme } from "@liebstoeckel/theme";
34
+
35
+ export default defineTheme({
36
+ name: "acme", // must match data-brand + the brands={[…]} entry
37
+ colors: {
38
+ bg: "#0b0e14", surface: "#141925", border: "#222a3a",
39
+ text: "#e6eaf2", muted: "#8a93a6",
40
+ primary: "#3b82f6", // brand
41
+ accent: "#22d3ee", // accent
42
+ accent2: "#f0abfc", // optional secondary accent
43
+ onPrimary: "#ffffff", // text/icons on a primary fill
44
+ },
45
+ fonts: {
46
+ // House families (already bundled, zero font work): "Schibsted Grotesk Variable",
47
+ // "Hanken Grotesk Variable", "Fraunces Variable", "JetBrains Mono Variable".
48
+ heading: '"Schibsted Grotesk Variable", system-ui, sans-serif',
49
+ body: '"Schibsted Grotesk Variable", system-ui, sans-serif',
50
+ mono: '"JetBrains Mono Variable", ui-monospace, monospace',
51
+ },
52
+ viz: ["#3b82f6", "#22d3ee", "#f0abfc", "#a3e635", "#fbbf24"], // optional chart palette
53
+ glow: { a: "#10233f", b: "#0b2530" }, // optional atmosphere gradient
54
+ });
55
+ ```
56
+
57
+ 2. **Wire it in `main.tsx`** — import the theme, pass it via `brandThemes`, and
58
+ name it the active brand:
59
+ ```tsx
60
+ import "@liebstoeckel/theme/styles.css";
61
+ import { Present } from "@liebstoeckel/engine";
62
+ import acme from "./brands/acme";
63
+
64
+ <Present title="…" brands={["acme"]} brandThemes={[acme]} slides={[…]} />
65
+ ```
66
+
67
+ 3. **Match `index.html`** — `<body data-brand="acme">`.
68
+
69
+ 4. Run the `build --check` loop as always.
70
+
71
+ ### Tokens
72
+
73
+ | Token (`--brand-*`) | Tailwind | Meaning | Required |
74
+ | --- | --- | --- | --- |
75
+ | `bg` / `surface` | `bg-bg` / `bg-surface` | page + panel backgrounds | ✓ |
76
+ | `text` / `muted` | `text-text` / `text-muted` | primary + secondary text | ✓ |
77
+ | `primary` / `accent` | `text-primary` / `text-accent` | brand + accent | ✓ |
78
+ | `on-primary` | `text-on-primary` | content on a primary fill | ✓ |
79
+ | `font-heading/body/mono` | `font-heading/body/mono` | typefaces | ✓ |
80
+ | `border` | `border-border` | hairlines | optional |
81
+ | `accent2` | `text-accent2` | secondary accent | optional |
82
+ | `viz-0…n` | (read by charts) | chart-series palette (`viz`) | optional |
83
+ | `glow-a/b` | (none) | atmosphere gradient stops (`glow`) | optional |
84
+
85
+ Charts read `viz` via `useBrandColors()` — set it for on-brand series instead of
86
+ hardcoding colors in the chart.
87
+
88
+ ## Fonts — bundle them or they silently fall back
89
+
90
+ A brand's `fonts.*` values are just `font-family` **strings**. The glyphs only
91
+ ship if a matching `@font-face` is **bundled** (the build inlines its woff2 into
92
+ the single file). Naming a font in the brand does **not** load it.
93
+
94
+ The trap: if you name a `"… Variable"` family that nothing bundles, the browser
95
+ **silently** falls back to a system font — no error, and the deck won't match what
96
+ you saw in dev (a past session shipped Noto Sans this way). The build now warns:
97
+
98
+ ```
99
+ ⚠ brand font won't render (text will fall back to a system font):
100
+ named by the brand but no @font-face bundles them: "Nunito Sans Variable"
101
+ ```
102
+
103
+ Treat that warning as **must-fix**. Two correct options:
104
+
105
+ - **Use a house family** (no font work): `"Schibsted Grotesk Variable"`,
106
+ `"Hanken Grotesk Variable"`, `"Fraunces Variable"`, `"JetBrains Mono Variable"`
107
+ are already bundled by `@liebstoeckel/theme/styles.css`.
108
+
109
+ - **Add another font** — install its Fontsource package and bundle **one latin
110
+ variable face**, mirroring `@liebstoeckel/theme`'s `fonts.css`:
111
+ ```bash
112
+ bun add @fontsource-variable/<id> # e.g. nunito-sans, inter, manrope
113
+ ```
114
+ Create `brands/<name>.css`:
115
+ ```css
116
+ @font-face {
117
+ font-family: "Nunito Sans Variable"; /* exact family used in the brand, incl. "Variable" */
118
+ src: url("@fontsource-variable/nunito-sans/files/nunito-sans-latin-wght-normal.woff2") format("woff2");
119
+ font-weight: 200 1000; /* the package's variable range */
120
+ font-style: normal;
121
+ font-display: swap;
122
+ }
123
+ ```
124
+ Import it in `main.tsx` (above the slides): `import "./brands/<name>.css";`. Bun
125
+ resolves the bare `@fontsource-variable/…` `url()` and base64-inlines the woff2 —
126
+ no CDN, no `<link>`, still one self-contained file.
127
+
128
+ **Do not** `import "@fontsource-variable/<id>"` or its `/index.css`. That pulls
129
+ ~5 `unicode-range`-subset faces that do **not** survive the build's inlining and
130
+ silently fall back. Reference the single `…-latin-wght-normal.woff2` directly.
131
+
132
+ ### Gotchas
133
+
134
+ - **Family name must match exactly**, including a `Variable` suffix. `"Nunito
135
+ Sans Variable"` ≠ the system `"Nunito Sans"`; a mismatch falls back.
136
+ - **Verify** after building, especially before exporting a PDF:
137
+ ```bash
138
+ liebstoeckel export <deck> --format pdf -o /tmp/d.pdf
139
+ pdffonts /tmp/d.pdf # the brand family should appear; no Noto/system fallback
140
+ ```
141
+ - Fonts/PNG/PDF export need a Chromium — see `references/troubleshooting.md`.
@@ -37,3 +37,19 @@ export a slide to PNG:
37
37
  ```bash
38
38
  liebstoeckel export <deck> --slides 1 -o ./out # PNG of slide 1 at 2560×1440
39
39
  ```
40
+
41
+ ## No Chromium (thumbnails / PNG / PDF export)
42
+
43
+ PNG/PDF export and overview thumbnails need a Chrome/Chromium. `build` skips
44
+ thumbnails gracefully without one, but `export`/`thumbs` fail. Diagnose and fix
45
+ with `doctor` (it auto-detects an existing system/Puppeteer Chrome, so often
46
+ nothing needs installing):
47
+
48
+ ```bash
49
+ liebstoeckel doctor --json # { chromium: { path, ok }, bun: {…} }
50
+ liebstoeckel doctor --install-chromium # if ok:false — installs + remembers it
51
+ ```
52
+
53
+ `--install-chromium` records the path in `~/.config/liebstoeckel/config.json`, so
54
+ later builds reuse it. You can also set `LIEBSTOECKEL_CHROMIUM=<path>` to a binary
55
+ directly. Both are non-interactive — safe to run unattended.
package/src/add.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  CATEGORIES,
8
8
  type RegistryItem,
9
9
  } from "@liebstoeckel/registry/schema";
10
+ import { bunBin } from "./bun";
10
11
 
11
12
  /**
12
13
  * `liebstoeckel add`, scaffold registry items into a deck as owned source
@@ -334,7 +335,7 @@ async function runAdd(args: {
334
335
  if (deps.size && !noInstall) {
335
336
  const { $ } = await import("bun");
336
337
  // pin the interpreter; --ignore-scripts per the registry trust model (ADR 0041)
337
- const proc = $`${process.execPath} add --ignore-scripts ${dependencies}`.cwd(deckDir);
338
+ const proc = $`${bunBin} add --ignore-scripts ${dependencies}`.cwd(deckDir);
338
339
  if (json) await proc.quiet();
339
340
  else {
340
341
  console.log(`\n ✓ wrote ${writes.length} file(s)` + (writes.length < files.length ? ` (${files.length - writes.length} skipped, use --force to overwrite)` : ""));
package/src/bun.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /** The Bun binary actually running this CLI.
4
+ *
5
+ * Always resolve Bun by interrogating the running process (`process.execPath`) */
6
+ export const bunBin = process.execPath;
7
+
8
+ const PKG_JSON = fileURLToPath(new URL("../package.json", import.meta.url));
9
+
10
+ /** Fallback range if package.json can't be read; kept in sync with `engines.bun`. */
11
+ const DEFAULT_RANGE = ">=1.3";
12
+
13
+ /** The Bun range the CLI declares it needs (package.json `engines.bun`). */
14
+ export async function requiredBunRange(): Promise<string> {
15
+ try {
16
+ const pkg = (await Bun.file(PKG_JSON).json()) as { engines?: { bun?: string } };
17
+ return pkg.engines?.bun ?? DEFAULT_RANGE;
18
+ } catch {
19
+ return DEFAULT_RANGE;
20
+ }
21
+ }
22
+
23
+ /** Drop prerelease/build metadata, judging a build by its release version.
24
+ * `Bun.semver.satisfies("1.5.0-canary.X", ">=1.3")` is `false`. npm-semver only
25
+ * lets a prerelease satisfy a range that pins the *same* major.minor.patch, so a
26
+ * perfectly capable canary/nightly Bun would otherwise be rejected. */
27
+ function releaseVersion(version: string): string {
28
+ return version.split("+")[0]!.split("-")[0]!;
29
+ }
30
+
31
+ /** Pure decision core (unit-tested): the error to print if `current` doesn't
32
+ * satisfy `range`, else null. `binPath` is named in the message so the user can
33
+ * see *which* Bun is running, the one that an upgrade must actually move. */
34
+ export function bunVersionError(current: string, range: string, binPath: string): string | null {
35
+ if (Bun.semver.satisfies(releaseVersion(current), range)) return null;
36
+ return (
37
+ `liebstoeckel needs Bun ${range}, but this CLI is running on Bun ${current}.\n` +
38
+ ` binary: ${binPath}\n` +
39
+ ` fix: bun upgrade (then re-run)\n`
40
+ );
41
+ }
42
+
43
+ /** Preflight, run on every CLI invocation: confirm the Bun interpreting us (and
44
+ * therefore every `bunBin` shell-out) satisfies `engines.bun`. A too-old Bun
45
+ * otherwise fails deep inside a command with an opaque error, so fail fast here
46
+ * with an actionable message instead. */
47
+ export async function assertBunVersion(): Promise<void> {
48
+ const msg = bunVersionError(Bun.version, await requiredBunRange(), bunBin);
49
+ if (msg) {
50
+ process.stderr.write(msg);
51
+ process.exit(1);
52
+ }
53
+ }
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ const rootCommand = defineCommand({
21
21
  thumbs: () => import("@liebstoeckel/thumbnails/cli").then((m) => m.thumbsCommand),
22
22
  export: () => import("@liebstoeckel/thumbnails/cli").then((m) => m.exportCommand),
23
23
  skill: () => import("./skill").then((m) => m.skillCommand),
24
+ doctor: () => import("./doctor").then((m) => m.doctorCommand),
24
25
  // cloud (coming soon, the hosted service is not generally available yet):
25
26
  login: () => import("./cloud").then((m) => m.loginCommand),
26
27
  push: () => import("./cloud").then((m) => m.pushCommand),
@@ -48,6 +49,21 @@ const KNOWN_COMMANDS = new Set(Object.keys((rootCommand.subCommands as Record<st
48
49
  async function main() {
49
50
  const argv = process.argv.slice(2);
50
51
 
52
+ // Preflight on every run: confirm the Bun interpreting this CLI satisfies
53
+ // engines.bun. The same binary (bunBin === process.execPath) backs every
54
+ // shell-out below, so this gate covers exactly what `build` etc. will use,
55
+ // a too-old Bun otherwise fails deep inside a command with an opaque error.
56
+ const { assertBunVersion } = await import("./bun");
57
+ await assertBunVersion();
58
+
59
+ // Feed a `doctor`-recorded Chromium into the resolution order when the user
60
+ // hasn't set LIEBSTOECKEL_CHROMIUM, so a browser configured once is reused.
61
+ try {
62
+ await (await import("./config")).hydrateChromiumEnv();
63
+ } catch {
64
+ // never block a command on config
65
+ }
66
+
51
67
  // Best-effort reminders (stderr-only; off for --json/pipes/CI, see update.ts):
52
68
  // a cached "new CLI version" note and a "deck skill older than the CLI" note.
53
69
  try {
package/src/cloud.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import { defineCommand } from "citty";
7
7
  import { existsSync, readdirSync } from "node:fs";
8
8
  import { basename, dirname, join, resolve, sep } from "node:path";
9
+ import { bunBin } from "./bun";
9
10
  import { loadCreds, saveCreds } from "./creds";
10
11
 
11
12
  const CLIENT_ID = "liebstoeckel-cli";
@@ -486,7 +487,7 @@ const brandPullCommand = defineCommand({
486
487
  const { $ } = await import("bun");
487
488
  console.log(` installing fonts: bun add --ignore-scripts ${deps.join(" ")}`);
488
489
  // pin the interpreter; --ignore-scripts per the registry trust model (ADR 0041)
489
- await $`${process.execPath} add --ignore-scripts ${deps}`.cwd(deckDir);
490
+ await $`${bunBin} add --ignore-scripts ${deps}`.cwd(deckDir);
490
491
  console.log(` ✓ fonts installed\n`);
491
492
  } else if (deps.length) {
492
493
  console.log(` → install its fonts: bun add --ignore-scripts ${deps.join(" ")}\n`);
package/src/config.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Non-secret CLI preferences in the user config dir (sibling of credentials.json,
2
+ // which holds cloud tokens). Today just the resolved Chromium path, so a browser
3
+ // found or installed once via `doctor` is reused by every later build/export
4
+ // without re-detecting or re-exporting LIEBSTOECKEL_CHROMIUM.
5
+ import { mkdir } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { CONFIG_DIR } from "./creds";
8
+
9
+ export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
10
+
11
+ export interface Config {
12
+ /** Path to a Chrome/Chromium binary, recorded by `doctor` (detect or install). */
13
+ chromium?: string;
14
+ }
15
+
16
+ export async function loadConfig(): Promise<Config> {
17
+ try {
18
+ return JSON.parse(await Bun.file(CONFIG_FILE).text()) as Config;
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ export async function saveConfig(patch: Config): Promise<void> {
25
+ const next = { ...(await loadConfig()), ...patch };
26
+ await mkdir(CONFIG_DIR, { recursive: true });
27
+ await Bun.write(CONFIG_FILE, JSON.stringify(next, null, 2));
28
+ }
29
+
30
+ /** Hydrate `LIEBSTOECKEL_CHROMIUM` from saved config when the user hasn't set it,
31
+ * so a once-configured/installed browser feeds the existing resolution order
32
+ * (explicit env still wins). Best-effort; a stale path just falls through to the
33
+ * system/Playwright probes in `resolveChromium`. */
34
+ export async function hydrateChromiumEnv(): Promise<void> {
35
+ if (process.env.LIEBSTOECKEL_CHROMIUM) return;
36
+ const { chromium } = await loadConfig();
37
+ if (chromium) process.env.LIEBSTOECKEL_CHROMIUM = chromium;
38
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { defineCommand } from "citty";
2
+ import { resolveChromium, playwrightCoreVersion } from "@liebstoeckel/thumbnails";
3
+ import { bunBin, bunVersionError, requiredBunRange } from "./bun";
4
+ import { loadConfig, saveConfig, CONFIG_FILE } from "./config";
5
+
6
+ /** Resolve a Chrome/Chromium path through the same order builds use, or null. */
7
+ function findChromium(): string | null {
8
+ try {
9
+ return resolveChromium();
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export interface DoctorReport {
16
+ bun: { version: string; required: string; ok: boolean };
17
+ chromium: { path: string | null; ok: boolean };
18
+ configFile: string;
19
+ }
20
+
21
+ /** Pure: assemble the environment report from the raw probes (unit-tested). */
22
+ export function buildReport(parts: {
23
+ bunVersion: string;
24
+ bunRange: string;
25
+ chromium: string | null;
26
+ }): DoctorReport {
27
+ return {
28
+ bun: {
29
+ version: parts.bunVersion,
30
+ required: parts.bunRange,
31
+ ok: bunVersionError(parts.bunVersion, parts.bunRange, bunBin) === null,
32
+ },
33
+ chromium: { path: parts.chromium, ok: parts.chromium !== null },
34
+ configFile: CONFIG_FILE,
35
+ };
36
+ }
37
+
38
+ /** Exit code for the diagnostic path (unit-tested). Non-zero only when a hard
39
+ * requirement is unmet: Bun is hard, Chromium is optional (`build` skips
40
+ * thumbnails without it), so a missing browser reports but does not fail. */
41
+ export function diagnosticExitCode(report: DoctorReport): number {
42
+ return report.bun.ok ? 0 : 1;
43
+ }
44
+
45
+ /** The shell-out that installs Playwright's Chromium. Pinned two ways: the Bun
46
+ * interpreter is `bunBin` (the one running this CLI), and `playwright@<version>`
47
+ * matches the `playwright-core` the capturer resolves browsers through. An
48
+ * unpinned `playwright install` would fetch registry-latest and drop a revision
49
+ * `chromium.executablePath()` can't find (a "successful" install that still fails). */
50
+ export function installChromiumArgs(): string[] {
51
+ return [bunBin, "x", `playwright@${playwrightCoreVersion}`, "install", "chromium"];
52
+ }
53
+
54
+ /** Install Playwright's Chromium (the capturer launches via playwright-core).
55
+ * Streams progress; returns success. */
56
+ async function installChromium(): Promise<boolean> {
57
+ const proc = Bun.spawn(installChromiumArgs(), {
58
+ stdin: "inherit",
59
+ stdout: "inherit",
60
+ stderr: "inherit",
61
+ });
62
+ return (await proc.exited) === 0;
63
+ }
64
+
65
+ export const doctorCommand = defineCommand({
66
+ meta: { name: "doctor", description: "check the build environment (Bun, Chromium) and optionally install Chromium" },
67
+ args: {
68
+ "install-chromium": {
69
+ type: "boolean",
70
+ description: "download Playwright's Chromium and record it for future builds",
71
+ },
72
+ json: { type: "boolean", description: "machine-readable JSON output (default when piped)" },
73
+ },
74
+ async run({ args }) {
75
+ const json = !!args.json || !process.stdout.isTTY;
76
+
77
+ if (args["install-chromium"]) {
78
+ // Skip if a usable browser is already resolvable, so a re-run is a no-op.
79
+ let path = findChromium();
80
+ if (!path) {
81
+ if (!json) console.error("Installing Chromium via Playwright…");
82
+ const ok = await installChromium();
83
+ if (!ok) {
84
+ const msg = "Chromium install failed (try `bunx playwright install chromium` and check the output).";
85
+ if (json) console.log(JSON.stringify({ ok: false, error: msg }));
86
+ else console.error(msg);
87
+ process.exit(1);
88
+ }
89
+ path = findChromium();
90
+ }
91
+ if (path) await saveConfig({ chromium: path });
92
+ if (json) console.log(JSON.stringify({ ok: !!path, chromium: path, configFile: CONFIG_FILE }));
93
+ else console.error(path ? `✓ Chromium ready: ${path}\n recorded in ${CONFIG_FILE}` : "Chromium still not found after install.");
94
+ process.exit(path ? 0 : 1);
95
+ }
96
+
97
+ const report = buildReport({
98
+ bunVersion: Bun.version,
99
+ bunRange: await requiredBunRange(),
100
+ chromium: findChromium(),
101
+ });
102
+ const stored = (await loadConfig()).chromium;
103
+
104
+ if (json) {
105
+ console.log(JSON.stringify({ ...report, storedChromium: stored ?? null }));
106
+ } else {
107
+ const ok = (b: boolean) => (b ? "✓" : "✗");
108
+ console.error(`${ok(report.bun.ok)} Bun ${report.bun.version} (needs ${report.bun.required})`);
109
+ console.error(
110
+ report.chromium.ok
111
+ ? `${ok(true)} Chromium ${report.chromium.path}`
112
+ : `${ok(false)} Chromium not found, run \`liebstoeckel doctor --install-chromium\` or set LIEBSTOECKEL_CHROMIUM\n` +
113
+ ` (only \`export\`/\`thumbs\` require it; \`build\` skips thumbnails without it)`,
114
+ );
115
+ console.error(` config: ${CONFIG_FILE}`);
116
+ }
117
+
118
+ // Exit non-zero so an agent/CI can gate on the check (the umbrella's preflight
119
+ // already enforces Bun before any command, so this is belt-and-suspenders for
120
+ // direct/programmatic use).
121
+ const code = diagnosticExitCode(report);
122
+ if (code) process.exit(code);
123
+ },
124
+ });
package/src/skill.ts CHANGED
@@ -2,7 +2,8 @@ import { defineCommand } from "citty";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { existsSync, readdirSync } from "node:fs";
4
4
  import { mkdir } from "node:fs/promises";
5
- import { dirname, join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
6
7
 
7
8
  /**
8
9
  * `liebstoeckel skill install`, materialize the bundled `liebstoeckel-deck` Skill
@@ -21,7 +22,9 @@ const PKG_JSON = fileURLToPath(new URL("../package.json", import.meta.url));
21
22
  type Target = "claude" | "codex" | "cursor" | "gemini";
22
23
  const ALL_TARGETS: Target[] = ["claude", "codex", "cursor", "gemini"];
23
24
 
24
- // Where each agent looks for a skill folder, relative to the deck root.
25
+ // Where each agent looks for a skill folder, relative to the root (a project dir
26
+ // for a `project` install, the home dir for a `user` install). Agents read the
27
+ // same `.<agent>/skills/...` layout under either root.
25
28
  export const SKILL_DIR: Record<Target, string> = {
26
29
  claude: join(".claude", "skills", SKILL_NAME),
27
30
  codex: join(".agents", "skills", SKILL_NAME), // also read by Gemini's shared path
@@ -29,6 +32,45 @@ export const SKILL_DIR: Record<Target, string> = {
29
32
  gemini: join(".gemini", "skills", SKILL_NAME),
30
33
  };
31
34
 
35
+ /** Where a skill install lands: a single project/deck, or the user account (every
36
+ * project). `project` writes the per-agent dirs + the AGENTS.md fallback into the
37
+ * deck root; `user` writes the per-agent dirs under `~` (e.g. `~/.claude/skills/`). */
38
+ export type Scope = "project" | "user";
39
+
40
+ /** Decide the install scope from the flags and context, or signal that the caller
41
+ * must prompt / error. Pure (no IO), so the policy is unit-tested:
42
+ * - `--global` or `--scope user|project` is taken as given;
43
+ * - an explicit `--dir` means "this project";
44
+ * - otherwise a TTY prompts, and a non-interactive run defaults to the project
45
+ * only when the cwd is actually a deck, never silently writing skill files
46
+ * into a random directory (the home-dir pollution from the field report). */
47
+ export function resolveScope(opts: {
48
+ scopeArg?: string;
49
+ global?: boolean;
50
+ dirGiven: boolean;
51
+ interactive: boolean;
52
+ cwdIsDeck: boolean;
53
+ }): { scope: Scope } | { prompt: true } | { error: string } {
54
+ if (opts.global) return { scope: "user" };
55
+ if (opts.scopeArg) {
56
+ if (opts.scopeArg === "project" || opts.scopeArg === "user") return { scope: opts.scopeArg };
57
+ return { error: `unknown --scope "${opts.scopeArg}", use: project | user` };
58
+ }
59
+ if (opts.dirGiven) return { scope: "project" };
60
+ if (opts.interactive) return { prompt: true };
61
+ if (opts.cwdIsDeck) return { scope: "project" };
62
+ return {
63
+ error:
64
+ "not inside a deck and no scope given. Pass `--scope user` to install for your account (~), " +
65
+ "or `--dir <deck>` / `--scope project` to install into a project.",
66
+ };
67
+ }
68
+
69
+ /** A directory is a deck/project if it has a package.json (decks are npm packages). */
70
+ function isDeckDir(dir: string): boolean {
71
+ return existsSync(join(dir, "package.json"));
72
+ }
73
+
32
74
  export async function cliVersion(): Promise<string> {
33
75
  try {
34
76
  return ((await Bun.file(PKG_JSON).json()) as { version?: string }).version ?? "0.0.0";
@@ -88,14 +130,24 @@ Discover components with \`liebstoeckel registry list --json\`; validate with \`
88
130
  await Bun.write(dest, mdc);
89
131
  }
90
132
 
91
- async function applySkill(sub: "install" | "update", deckDir: string, targetArg: string | undefined): Promise<void> {
133
+ export async function applySkill(
134
+ sub: "install" | "update",
135
+ root: string,
136
+ scope: Scope,
137
+ targetArg: string | undefined,
138
+ ): Promise<void> {
139
+ // AGENTS.md is a project-root convention (the fallback an agent reads when it
140
+ // opens a deck). A `user` install skips it. A bare `~/AGENTS.md` is the home-dir
141
+ // pollution the field report flagged, and isn't a reliable global instruction path.
142
+ const writeAgents = scope === "project";
92
143
  let targets: Target[];
93
144
  if (sub === "update") {
94
- // Refresh whatever is already installed (the AGENTS.md block is always
95
- // rewritten); installing NEW agent paths stays `install`'s job.
96
- targets = ALL_TARGETS.filter((t) => existsSync(join(deckDir, SKILL_DIR[t])));
97
- if (targets.length === 0 && !existsSync(join(deckDir, "AGENTS.md"))) {
98
- console.error(`✕ no liebstoeckel skill installed in ${deckDir}, run: liebstoeckel skill install`);
145
+ // Refresh whatever is already installed (the AGENTS.md block is rewritten when
146
+ // present); installing NEW agent paths stays `install`'s job.
147
+ targets = ALL_TARGETS.filter((t) => existsSync(join(root, SKILL_DIR[t])));
148
+ const agentsPresent = writeAgents && existsSync(join(root, "AGENTS.md"));
149
+ if (targets.length === 0 && !agentsPresent) {
150
+ console.error(`✕ no liebstoeckel skill installed in ${root}, run: liebstoeckel skill install`);
99
151
  process.exit(1);
100
152
  }
101
153
  } else {
@@ -114,32 +166,77 @@ async function applySkill(sub: "install" | "update", deckDir: string, targetArg:
114
166
  // de-dupe destination dirs (some agents share a path)
115
167
  const seen = new Set<string>();
116
168
  for (const t of targets) {
117
- const destRoot = join(deckDir, SKILL_DIR[t]);
169
+ const destRoot = join(root, SKILL_DIR[t]);
118
170
  if (!seen.has(destRoot)) {
119
171
  await writeSkill(destRoot, version);
120
172
  seen.add(destRoot);
121
173
  written.push(SKILL_DIR[t] + "/");
122
174
  }
123
175
  if (t === "cursor") {
124
- await writeCursorRule(deckDir);
176
+ await writeCursorRule(root);
125
177
  written.push(join(".cursor", "rules", "liebstoeckel.mdc"));
126
178
  }
127
179
  }
128
- // AGENTS.md is the universal fallback, always write it
129
- await writeAgentsBlock(deckDir);
130
- written.push("AGENTS.md");
180
+ if (writeAgents) {
181
+ await writeAgentsBlock(root);
182
+ written.push("AGENTS.md");
183
+ }
131
184
 
132
- console.log(`\n✓ ${sub === "update" ? "updated" : "installed"} the liebstoeckel-deck skill (v${version}) ${deckDir}\n`);
185
+ const where = scope === "user" ? `your user account (${root})` : root;
186
+ console.log(`\n✓ ${sub === "update" ? "updated" : "installed"} the liebstoeckel-deck skill (v${version}) → ${where}\n`);
133
187
  for (const w of written) console.log(` ${w}`);
134
- console.log(`\n targets: ${targets.join(", ")}\n`);
188
+ console.log(`\n scope: ${scope} · targets: ${targets.join(", ")}\n`);
135
189
  } catch (e) {
136
190
  console.error(`✕ ${(e as Error).message}`);
137
191
  process.exit(1);
138
192
  }
139
193
  }
140
194
 
195
+ /** Resolve scope (prompting on a TTY when ambiguous) then run the install/update.
196
+ * `dir` is the explicit `--dir` value (undefined = none given). */
197
+ async function runSkill(
198
+ sub: "install" | "update",
199
+ args: { dir?: string; scope?: string; global?: boolean; target?: string },
200
+ ): Promise<void> {
201
+ const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY;
202
+ const decision = resolveScope({
203
+ scopeArg: args.scope,
204
+ global: args.global,
205
+ dirGiven: args.dir !== undefined,
206
+ interactive,
207
+ cwdIsDeck: isDeckDir(args.dir ?? "."),
208
+ });
209
+
210
+ let scope: Scope;
211
+ if ("error" in decision) {
212
+ console.error(`✕ ${decision.error}`);
213
+ process.exit(1);
214
+ } else if ("prompt" in decision) {
215
+ // TTY-only (resolveScope returns `prompt` only when interactive). Bun's prompt
216
+ // reads a line; default = project (the contained, safe choice).
217
+ const ans = (prompt("Install the liebstoeckel-deck skill for [P]roject (./) or [u]ser account (~)?", "p") ?? "p")
218
+ .trim()
219
+ .toLowerCase();
220
+ scope = ans.startsWith("u") ? "user" : "project";
221
+ } else {
222
+ scope = decision.scope;
223
+ }
224
+
225
+ const root = scope === "user" ? homedir() : resolve(args.dir ?? ".");
226
+ await applySkill(sub, root, scope, args.target);
227
+ }
228
+
229
+ const scopeArgs = {
230
+ scope: {
231
+ type: "string",
232
+ description: "install location: project (a deck) or user (your account ~, every project)",
233
+ valueHint: "project|user",
234
+ },
235
+ global: { type: "boolean", description: "shorthand for --scope user (install for your account, ~)" },
236
+ } as const;
237
+
141
238
  const skillInstallCommand = defineCommand({
142
- meta: { name: "install", description: "install the agent skill (SKILL.md + AGENTS.md) into a deck" },
239
+ meta: { name: "install", description: "install the agent skill into a deck (--scope project) or your account (--scope user)" },
143
240
  args: {
144
241
  target: {
145
242
  type: "string",
@@ -147,17 +244,19 @@ const skillInstallCommand = defineCommand({
147
244
  description: "agents to target: claude, codex, cursor, gemini, all (comma-separated)",
148
245
  valueHint: "claude|codex|cursor|gemini|all",
149
246
  },
150
- dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
247
+ dir: { type: "string", description: "target deck directory (project scope; default: cwd)", valueHint: "deck" },
248
+ ...scopeArgs,
151
249
  },
152
- run: ({ args }) => applySkill("install", args.dir ?? ".", args.target),
250
+ run: ({ args }) => runSkill("install", args),
153
251
  });
154
252
 
155
253
  const skillUpdateCommand = defineCommand({
156
254
  meta: { name: "update", description: "refresh the installed agent skill to the running CLI version" },
157
255
  args: {
158
- dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
256
+ dir: { type: "string", description: "target deck directory (project scope; default: cwd)", valueHint: "deck" },
257
+ ...scopeArgs,
159
258
  },
160
- run: ({ args }) => applySkill("update", args.dir ?? ".", undefined),
259
+ run: ({ args }) => runSkill("update", args),
161
260
  });
162
261
 
163
262
  /** `liebstoeckel skill install|update`, manage the bundled deck-authoring Skill. */
package/src/update.ts CHANGED
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { bunBin } from "./bun";
15
16
  import { cliVersion, SKILL_DIR } from "./skill";
16
17
 
17
18
  const PKG = "@liebstoeckel/cli";
@@ -87,7 +88,7 @@ export async function updateReminder(argv: string[]): Promise<void> {
87
88
  if (shouldRefresh(state, Date.now())) {
88
89
  // Detached child re-runs THIS file (import.meta.main → refresh()). It inherits
89
90
  // the cwd, so `bun pm view` sees the same .npmrc/bunfig the user's installs use.
90
- const child = Bun.spawn([process.execPath, fileURLToPath(import.meta.url)], {
91
+ const child = Bun.spawn([bunBin, fileURLToPath(import.meta.url)], {
91
92
  stdin: "ignore",
92
93
  stdout: "ignore",
93
94
  stderr: "ignore",
@@ -131,7 +132,7 @@ async function refresh(): Promise<void> {
131
132
  process.on("SIGHUP", () => {});
132
133
  let latest: string | null = null;
133
134
  try {
134
- const proc = Bun.spawnSync([process.execPath, "pm", "view", PKG, "dist-tags.latest"], {
135
+ const proc = Bun.spawnSync([bunBin, "pm", "view", PKG, "dist-tags.latest"], {
135
136
  stdout: "pipe",
136
137
  stderr: "ignore",
137
138
  timeout: 15_000,