@litfamily/litclaude 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +34 -92
  3. package/README_ko-KR.md +42 -95
  4. package/bin/icon-font.mjs +264 -0
  5. package/bin/litclaude-ai.js +155 -18
  6. package/docs/assets/readme/badge-version.svg +1 -1
  7. package/docs/hooks.md +131 -5
  8. package/docs/privacy.md +2 -2
  9. package/package.json +13 -3
  10. package/plugins/litclaude/.claude-plugin/plugin.json +22 -2
  11. package/plugins/litclaude/bin/litclaude-hook.js +13 -7
  12. package/plugins/litclaude/bin/litclaude-hud.js +36 -22
  13. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  14. package/plugins/litclaude/hooks/hooks.json +1 -0
  15. package/plugins/litclaude/hooks/lit-band-client.mjs +44 -0
  16. package/plugins/litclaude/hooks/lit-mod.mjs +314 -0
  17. package/plugins/litclaude/lib/auto-handoff.mjs +2 -0
  18. package/plugins/litclaude/lib/automatic-update-refresh.mjs +4 -0
  19. package/plugins/litclaude/lib/automatic-update.mjs +148 -1
  20. package/plugins/litclaude/lib/cache-measurement.mjs +62 -1
  21. package/plugins/litclaude/lib/canonical-skill-resources.mjs +9 -9
  22. package/plugins/litclaude/lib/hud-accent-palette.mjs +2 -2
  23. package/plugins/litclaude/lib/hud-ignition.mjs +7 -37
  24. package/plugins/litclaude/lib/interface-probe-gate.mjs +2 -1
  25. package/plugins/litclaude/lib/jev-skill-hint.mjs +2 -0
  26. package/plugins/litclaude/lib/lit-plan-persistence.mjs +2 -1
  27. package/plugins/litclaude/lib/litgoal/autoloop.mjs +2 -0
  28. package/plugins/litclaude/lib/litgoal/cli.mjs +2 -0
  29. package/plugins/litclaude/lib/litgoal/ledger.mjs +3 -0
  30. package/plugins/litclaude/lib/litgoal/state.mjs +4 -0
  31. package/plugins/litclaude/lib/mod/frame.mjs +454 -0
  32. package/plugins/litclaude/lib/mod/layout.mjs +53 -0
  33. package/plugins/litclaude/lib/mod/motion.mjs +101 -0
  34. package/plugins/litclaude/lib/mod/render.mjs +20 -0
  35. package/plugins/litclaude/lib/mod/settings.mjs +105 -0
  36. package/plugins/litclaude/lib/mod/skills.mjs +81 -0
  37. package/plugins/litclaude/lib/mod/state.mjs +411 -0
  38. package/plugins/litclaude/lib/mod/timeline.mjs +152 -0
  39. package/plugins/litclaude/lib/mod/view.mjs +255 -0
  40. package/plugins/litclaude/lib/motion-render-gate.mjs +2 -1
  41. package/plugins/litclaude/lib/office-runtime.mjs +6 -0
  42. package/plugins/litclaude/lib/project-state-root.mjs +34 -2
  43. package/plugins/litclaude/lib/rules/engine.mjs +2 -1
  44. package/plugins/litclaude/lib/rules/format.mjs +24 -3
  45. package/plugins/litclaude/lib/rules/session-state.mjs +2 -1
  46. package/plugins/litclaude/lib/secure-path-read.mjs +46 -0
  47. package/plugins/litclaude/lib/start-work-lifecycle.mjs +4 -0
  48. package/plugins/litclaude/lib/wikify-knowledge.mjs +2 -1
  49. package/plugins/litclaude/scripts/scaffold-plan.mjs +2 -1
  50. package/plugins/litclaude/skills/lit-team/scripts/team.mjs +2 -1
  51. package/plugins/litclaude/skills/lit-typographic-motion/scripts/motion-runtime.mjs +7 -0
@@ -0,0 +1,264 @@
1
+ // Opt-in icon font for the LIT band: the symbols-only Nerd Font (Symbols Nerd Font Mono, MIT), so the
2
+ // band can draw its rounded pill caps (U+E0B6, U+E0B4) and icons while the person keeps their own
3
+ // terminal font. The font is not bundled: install downloads one pinned official release archive,
4
+ // refuses it unless its SHA-256 matches, takes the two pinned files out of it (each checked again),
5
+ // and writes them into the per-user font folder only. A manifest of what was written lets
6
+ // uninstall remove exactly those files and leave any copy the person installed alone.
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { linkSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { inflateRawSync } from "node:zlib";
13
+
14
+ export const ICON_FONT = Object.freeze({
15
+ family: "Symbols Nerd Font Mono",
16
+ release: "v3.5.1",
17
+ url: "https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/NerdFontsSymbolsOnly.zip",
18
+ archiveSha256: "fdca3682534f6f65e1ccb2345b0362ccf67d9b8eca7c8025330946e93e2473bc",
19
+ archiveMaxBytes: 8 * 1024 * 1024,
20
+ files: Object.freeze([
21
+ Object.freeze({ entry: "SymbolsNerdFontMono-Regular.ttf", name: "SymbolsNerdFontMono-Regular.ttf", sha256: "fe471e538392f51910faab985fa8e192a39dd3426125edd15b71b3680df0e749", kind: "font" }),
22
+ Object.freeze({ entry: "LICENSE", name: "SymbolsNerdFontMono-LICENSE.txt", sha256: "84a7a98c82140fb12c37fe42b93805baa16024cb3e5acc599b7ffe612c55d847", kind: "license" }),
23
+ ]),
24
+ });
25
+
26
+ // Font file names the LIT band treats as "the icon font is installed": this one, and the
27
+ // proportional symbols font a person may have installed some other way.
28
+ export const ICON_FONT_FILE_NAMES = Object.freeze(["SymbolsNerdFontMono-Regular.ttf", "SymbolsNerdFont-Regular.ttf"]);
29
+
30
+ export const ICON_FONT_QUESTION = "Install the icon font for LitClaude's status band? (y/N) ";
31
+
32
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
33
+
34
+ export class IconFontError extends Error {}
35
+
36
+ /** The per-user font folder, or null where installing one safely is not supported (Windows). */
37
+ export const iconFontDir = ({ platform = process.platform, env = process.env, home }) => {
38
+ if (platform === "darwin") return join(home, "Library", "Fonts");
39
+ if (platform === "win32") return null;
40
+ return join(env.XDG_DATA_HOME?.trim() || join(home, ".local", "share"), "fonts");
41
+ };
42
+
43
+ export const iconFontManifestPath = (litHome) => join(litHome, "icon-font", "manifest.json");
44
+
45
+ export const parseYesNo = (answer) => /^\s*y(?:es)?\s*$/iu.test(String(answer ?? ""));
46
+
47
+ const readU16 = (buffer, offset) => buffer.readUInt16LE(offset);
48
+ const readU32 = (buffer, offset) => buffer.readUInt32LE(offset);
49
+
50
+ /**
51
+ * Reads the named entries out of a zip archive held in memory (stored or deflated, no zip64), by
52
+ * its central directory. Returns a Map of entry name to bytes; a missing entry is simply absent.
53
+ */
54
+ export const extractZipEntries = (archive, names) => {
55
+ const wanted = new Set(names);
56
+ const found = new Map();
57
+ let end = -1;
58
+ for (let offset = archive.length - 22; offset >= Math.max(0, archive.length - 22 - 65_535); offset -= 1) {
59
+ if (readU32(archive, offset) === 0x06054b50) {
60
+ end = offset;
61
+ break;
62
+ }
63
+ }
64
+ if (end < 0) throw new IconFontError("the archive is not a zip file");
65
+ const count = readU16(archive, end + 10);
66
+ let offset = readU32(archive, end + 16);
67
+ for (let index = 0; index < count; index += 1) {
68
+ if (readU32(archive, offset) !== 0x02014b50) throw new IconFontError("the archive's directory is damaged");
69
+ const method = readU16(archive, offset + 10);
70
+ const compressedSize = readU32(archive, offset + 20);
71
+ const nameLength = readU16(archive, offset + 28);
72
+ const extraLength = readU16(archive, offset + 30);
73
+ const commentLength = readU16(archive, offset + 32);
74
+ const localOffset = readU32(archive, offset + 42);
75
+ const name = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
76
+ offset += 46 + nameLength + extraLength + commentLength;
77
+ if (!wanted.has(name)) continue;
78
+ if (readU32(archive, localOffset) !== 0x04034b50) throw new IconFontError(`the archive entry ${name} is damaged`);
79
+ const dataStart = localOffset + 30 + readU16(archive, localOffset + 26) + readU16(archive, localOffset + 28);
80
+ const data = archive.subarray(dataStart, dataStart + compressedSize);
81
+ if (method === 0) found.set(name, Buffer.from(data));
82
+ else if (method === 8) found.set(name, inflateRawSync(data));
83
+ else throw new IconFontError(`the archive entry ${name} uses an unsupported compression method`);
84
+ }
85
+ return found;
86
+ };
87
+
88
+ /**
89
+ * The pinned archive's bytes: a local copy named by LITCLAUDE_ICON_FONT_SOURCE (for offline
90
+ * installs), else the official release download. Either way it is refused unless its size is
91
+ * under the cap and its SHA-256 is the pinned one.
92
+ */
93
+ export const loadIconFontArchive = async ({ env = process.env, pins = ICON_FONT, fetchImpl = globalThis.fetch } = {}) => {
94
+ let bytes;
95
+ const local = env.LITCLAUDE_ICON_FONT_SOURCE?.trim();
96
+ if (local) {
97
+ const stat = lstatSync(local);
98
+ if (!stat.isFile() || stat.size > pins.archiveMaxBytes) throw new IconFontError(`${local} is not a regular file under ${pins.archiveMaxBytes} bytes`);
99
+ bytes = readFileSync(local);
100
+ } else {
101
+ if (typeof fetchImpl !== "function") throw new IconFontError("this Node.js has no fetch; download the archive and set LITCLAUDE_ICON_FONT_SOURCE");
102
+ const response = await fetchImpl(pins.url, { redirect: "follow", signal: AbortSignal.timeout(60_000) });
103
+ if (!response.ok) throw new IconFontError(`the download answered HTTP ${response.status}`);
104
+ const declared = Number(response.headers?.get?.("content-length") ?? 0);
105
+ if (declared > pins.archiveMaxBytes) throw new IconFontError("the download is larger than the pinned archive");
106
+ bytes = Buffer.from(await response.arrayBuffer());
107
+ }
108
+ if (bytes.length > pins.archiveMaxBytes) throw new IconFontError("the archive is larger than the pinned archive");
109
+ const actual = sha256(bytes);
110
+ if (actual !== pins.archiveSha256) throw new IconFontError(`the archive's SHA-256 is ${actual}, not the pinned ${pins.archiveSha256}; nothing was installed`);
111
+ return bytes;
112
+ };
113
+
114
+ const lstatOrNull = (path) => {
115
+ try {
116
+ return lstatSync(path);
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ // What sits at a destination now: nothing, the exact pinned file, a different file, or something
123
+ // that is not a regular file (a link, a folder), which is never written through or over.
124
+ const destinationState = (path, expectedSha) => {
125
+ const stat = lstatOrNull(path);
126
+ if (!stat) return "absent";
127
+ if (stat.isSymbolicLink() || !stat.isFile()) return "not-a-file";
128
+ return sha256(readFileSync(path)) === expectedSha ? "same" : "different";
129
+ };
130
+
131
+ export const planIconFontInstall = ({ platform = process.platform, env = process.env, home, pins = ICON_FONT }) => {
132
+ const dir = iconFontDir({ platform, env, home });
133
+ if (!dir) return { supported: false, dir: null, files: [] };
134
+ return {
135
+ supported: true,
136
+ dir,
137
+ files: pins.files.map((file) => {
138
+ const path = join(dir, file.name);
139
+ return { ...file, path, state: destinationState(path, file.sha256) };
140
+ }),
141
+ };
142
+ };
143
+
144
+ const readManifest = (litHome) => {
145
+ try {
146
+ const manifest = JSON.parse(readFileSync(iconFontManifestPath(litHome), "utf8"));
147
+ return Array.isArray(manifest?.files) ? manifest : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ };
152
+
153
+ const writeManifest = (litHome, manifest) => {
154
+ const path = iconFontManifestPath(litHome);
155
+ mkdirSync(dirname(path), { recursive: true });
156
+ const staging = `${path}.${process.pid}.tmp`;
157
+ writeFileSync(staging, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
158
+ renameSync(staging, path);
159
+ };
160
+
161
+ // Writes a new file without ever replacing one: the bytes go to a fresh temporary name in the same
162
+ // folder, then a hard link claims the destination, which fails if anything appeared there.
163
+ const writeNewFile = (path, bytes) => {
164
+ const staging = `${path}.litclaude-${process.pid}.tmp`;
165
+ writeFileSync(staging, bytes, { flag: "wx", mode: 0o644 });
166
+ try {
167
+ linkSync(staging, path);
168
+ } finally {
169
+ unlinkSync(staging);
170
+ }
171
+ };
172
+
173
+ /**
174
+ * Installs the pinned files from a verified archive into the per-user font folder. A destination
175
+ * that already holds the pinned bytes is left as it is (and recorded as LitClaude's only if an
176
+ * earlier LitClaude install wrote it); a different file or a link is never overwritten.
177
+ */
178
+ export const installIconFont = ({ archive, platform = process.platform, env = process.env, home, litHome, pins = ICON_FONT, spawn = spawnSync, now = () => new Date() }) => {
179
+ const plan = planIconFontInstall({ platform, env, home, pins });
180
+ if (!plan.supported) return { status: "unsupported", installed: [], present: [], skipped: [] };
181
+ const entries = extractZipEntries(archive, pins.files.map(({ entry }) => entry));
182
+ const previous = readManifest(litHome)?.files ?? [];
183
+ const owned = [];
184
+ const installed = [];
185
+ const present = [];
186
+ const skipped = [];
187
+ for (const file of plan.files) {
188
+ const bytes = entries.get(file.entry);
189
+ if (!bytes) throw new IconFontError(`the archive has no ${file.entry}; nothing was installed`);
190
+ if (sha256(bytes) !== file.sha256) throw new IconFontError(`${file.entry} in the archive does not match its pinned SHA-256; nothing was installed`);
191
+ }
192
+ mkdirSync(plan.dir, { recursive: true });
193
+ for (const file of plan.files) {
194
+ if (file.state === "absent") {
195
+ writeNewFile(file.path, entries.get(file.entry));
196
+ installed.push(file.path);
197
+ owned.push({ path: file.path, sha256: file.sha256, kind: file.kind });
198
+ } else if (file.state === "same") {
199
+ present.push(file.path);
200
+ if (previous.some((entry) => entry.path === file.path && entry.sha256 === file.sha256)) owned.push({ path: file.path, sha256: file.sha256, kind: file.kind });
201
+ } else {
202
+ skipped.push({ path: file.path, reason: file.state === "different" ? "a different file is already there" : "something other than a regular file is there" });
203
+ }
204
+ }
205
+ if (owned.length > 0) writeManifest(litHome, { family: pins.family, release: pins.release, installedAt: now().toISOString(), files: owned });
206
+ let fontCache = null;
207
+ if (platform !== "darwin" && installed.length > 0) {
208
+ const result = spawn("fc-cache", ["-f", plan.dir], { encoding: "utf8", timeout: 60_000 });
209
+ fontCache = result.error?.code === "ENOENT" ? "fc-cache not found" : result.status === 0 ? "refreshed" : "fc-cache failed";
210
+ }
211
+ return { status: installed.length > 0 ? "installed" : skipped.length > 0 && present.length === 0 ? "skipped" : "present", dir: plan.dir, installed, present, skipped, fontCache };
212
+ };
213
+
214
+ /** Removes only the files the manifest records, and only while they still hold the recorded bytes. */
215
+ export const uninstallIconFont = ({ litHome, dryRun = false }) => {
216
+ const manifest = readManifest(litHome);
217
+ if (!manifest) return { removed: [], kept: [] };
218
+ const removed = [];
219
+ const kept = [];
220
+ for (const file of manifest.files) {
221
+ const state = destinationState(file.path, file.sha256);
222
+ if (state === "same") {
223
+ if (!dryRun) rmSync(file.path, { force: true });
224
+ removed.push(file.path);
225
+ } else if (state !== "absent") {
226
+ kept.push({ path: file.path, reason: state === "different" ? "its bytes changed since LitClaude wrote it" : "it is no longer a regular file" });
227
+ }
228
+ }
229
+ if (!dryRun) rmSync(dirname(iconFontManifestPath(litHome)), { recursive: true, force: true });
230
+ return { removed, kept };
231
+ };
232
+
233
+ /** Whether the icon font is installed for this user, and whether LitClaude wrote it. */
234
+ export const iconFontStatus = ({ platform = process.platform, env = process.env, home, litHome }) => {
235
+ const dir = iconFontDir({ platform, env, home });
236
+ if (!dir) return { supported: false, present: false, path: null, managed: false };
237
+ const managedPaths = new Set((readManifest(litHome)?.files ?? []).map(({ path }) => path));
238
+ for (const name of ICON_FONT_FILE_NAMES) {
239
+ const path = join(dir, name);
240
+ const stat = lstatOrNull(path);
241
+ if (stat?.isFile()) return { supported: true, present: true, path, managed: managedPaths.has(path) };
242
+ }
243
+ return { supported: true, present: false, path: join(dir, ICON_FONT.files[0].name), managed: false };
244
+ };
245
+
246
+ /**
247
+ * One line on pointing the terminal at the font, for the terminal named by TERM_PROGRAM (kitty by
248
+ * TERM). It is only needed when the terminal's own fallback does not pick the font up; nothing here
249
+ * changes a terminal's settings.
250
+ */
251
+ export const terminalFontHint = (env = process.env, family = ICON_FONT.family) => {
252
+ const program = env.TERM_PROGRAM ?? "";
253
+ if (program === "iTerm.app") return `iTerm2: only if icons show as boxes, set Settings > Profiles > Text > Non-ASCII Font to ${family}.`;
254
+ if (program === "Apple_Terminal") return `Terminal.app: macOS font fallback usually finds ${family}; if icons show as boxes, pick a Nerd Font in Settings > Profiles > Text.`;
255
+ if (program === "vscode") return `VS Code: only if icons show as boxes, add it to terminal.integrated.fontFamily, for example "Menlo, '${family}'".`;
256
+ if (program === "ghostty") return `Ghostty: Nerd Font symbols are built in; if icons still show as boxes, add font-family = ${family} after your font in its config.`;
257
+ if (program === "WezTerm") return `WezTerm: Nerd Font symbols are built in; if icons still show as boxes, add '${family}' to config.font = wezterm.font_with_fallback { ... }.`;
258
+ if (env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID) return `kitty: only if icons show as boxes, add "symbol_map U+E000-U+F8FF ${family}" to kitty.conf.`;
259
+ return `Terminal: only if icons show as boxes, add ${family} as a fallback font in your terminal's font settings.`;
260
+ };
261
+
262
+ /** What to tell a Windows user, where LitClaude does not register fonts itself. */
263
+ export const windowsIconFontInstructions = (pins = ICON_FONT) =>
264
+ `Windows: LitClaude does not register fonts itself. Download ${pins.url}, check its SHA-256 is ${pins.archiveSha256}, then right-click SymbolsNerdFontMono-Regular.ttf and choose "Install for me".`;
@@ -46,6 +46,18 @@ import { resolveProjectStateRoot } from "../plugins/litclaude/lib/project-state-
46
46
  import { runUpdateNotifier } from "./update-notifier.mjs";
47
47
  import { assertInstallPath, assertOwnedTree, assertCurrentPointer, assertOwnedRegistrations, migrateLegacyVendorPaths, planModifiedLegacySkill, preserveModifiedLegacySkill, registeredPluginInstallation, writeOwnershipReceipt, removeEmptyDirectory } from "./install-ownership.mjs";
48
48
  import { banner, lockup, colorMode, supportsBlocks, terminalRows } from "./litfamily-banner.mjs";
49
+ import {
50
+ ICON_FONT,
51
+ ICON_FONT_QUESTION,
52
+ iconFontStatus,
53
+ installIconFont,
54
+ loadIconFontArchive,
55
+ parseYesNo,
56
+ planIconFontInstall,
57
+ terminalFontHint,
58
+ uninstallIconFont,
59
+ windowsIconFontInstructions,
60
+ } from "./icon-font.mjs";
49
61
 
50
62
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
51
63
  const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
@@ -105,13 +117,32 @@ Install options:
105
117
  safeguards are never weakened.
106
118
  --yolo Shorthand for --permission-mode yolo.
107
119
  --yes Accept today's shipped defaults for every question (HUD accent,
108
- output style) and run without prompts. Model selection stays
109
- host-owned: Claude Code picks its own models, so the installer
110
- never asks for a model or reasoning effort.
120
+ output style, no icon font) and run without prompts. Model
121
+ selection stays host-owned: Claude Code picks its own models, so
122
+ the installer never asks for a model or reasoning effort.
123
+ --icon-font Also install the symbols-only Nerd Font (Symbols Nerd Font Mono,
124
+ MIT) into your user font folder, so the LIT band can draw its
125
+ rounded pills and icons. Downloaded from the pinned Nerd Fonts
126
+ release and refused unless its SHA-256 matches; an offline copy
127
+ of that archive can be named with LITCLAUDE_ICON_FONT_SOURCE.
128
+ An interactive install asks instead (default No).
111
129
 
112
130
  Typographic-motion engine adapted from mexicat/pdoom-video (MIT, Giacomo Magnanini), commit \`ca251e3\`.
113
131
  `;
114
132
 
133
+ const dryRunAfterCommand = new Set(["install", "update", "uninstall", "doctor"]);
134
+ const dryRunTrueValues = new Set(["true", "1", "yes"]);
135
+
136
+ // `--dry-run` and `--dry-run=true` mean a dry run. Any other value is refused here, before any
137
+ // command runs: a mistyped dry run must never fall through to the real action.
138
+ const isDryRunFlag = (arg) => {
139
+ if (arg === "--dry-run") return true;
140
+ if (typeof arg !== "string" || !arg.startsWith("--dry-run=")) return false;
141
+ if (dryRunTrueValues.has(arg.slice("--dry-run=".length).toLowerCase())) return true;
142
+ process.stderr.write(`Unsupported value in ${arg}: use --dry-run (or --dry-run=true) for a dry run, or leave the flag out to run for real\n`);
143
+ process.exit(64);
144
+ };
145
+
115
146
  const parseArgs = (argv) => {
116
147
  const args = [];
117
148
  let noAutoUpdate = false;
@@ -128,9 +159,15 @@ const parseArgs = (argv) => {
128
159
  }
129
160
  const wantsVersion = args[0] === "--version" || args[0] === "-v";
130
161
  if (wantsVersion) return { dryRun: false, command: "version", rest: [], noAutoUpdate, invalidOption };
131
- const dryRun = args[0] === "--dry-run";
162
+ let dryRun = isDryRunFlag(args[0]);
132
163
  if (dryRun) args.shift();
133
- return { dryRun, command: args[0], rest: args.slice(1), noAutoUpdate, invalidOption };
164
+ let rest = args.slice(1);
165
+ // The managed commands also take --dry-run after the subcommand (`install --dry-run`).
166
+ if (dryRunAfterCommand.has(args[0]) && rest.some(isDryRunFlag)) {
167
+ dryRun = true;
168
+ rest = rest.filter((arg) => !isDryRunFlag(arg));
169
+ }
170
+ return { dryRun, command: args[0], rest, noAutoUpdate, invalidOption };
134
171
  };
135
172
 
136
173
  const litHome = () => resolve(process.env.LITCLAUDE_HOME ?? join(homedir(), ".litclaude"));
@@ -185,6 +222,7 @@ const parseInstallOptions = (rest, existingPermissionMode = "safe") => {
185
222
  let permissionMode = parsePermissionMode(process.env.LITCLAUDE_PERMISSION_MODE || preservedMode);
186
223
  let permissionExplicit = Boolean(process.env.LITCLAUDE_PERMISSION_MODE);
187
224
  let assumeYes = false;
225
+ let iconFont = false;
188
226
 
189
227
  for (let index = 0; index < rest.length; index += 1) {
190
228
  const arg = rest[index];
@@ -212,10 +250,14 @@ const parseInstallOptions = (rest, existingPermissionMode = "safe") => {
212
250
  assumeYes = true;
213
251
  continue;
214
252
  }
253
+ if (arg === "--icon-font") {
254
+ iconFont = true;
255
+ continue;
256
+ }
215
257
  fail(`Unknown install option: ${arg}`, 64);
216
258
  }
217
259
 
218
- return { permissionMode, permissionExplicit, assumeYes };
260
+ return { permissionMode, permissionExplicit, assumeYes, iconFont };
219
261
  };
220
262
 
221
263
  const permissionProfile = (mode) => {
@@ -257,6 +299,19 @@ const fail = (message, status = 1) => {
257
299
  process.exit(status);
258
300
  };
259
301
 
302
+ // A piped stdout is asynchronous on macOS, so process.exit() straight after a large write drops
303
+ // the queued tail (a 400 KB report arrived as 64 KiB). Exit once both streams have flushed.
304
+ const exitAfterFlush = (status) => {
305
+ process.exitCode = status;
306
+ let pending = 2;
307
+ const flushed = () => {
308
+ pending -= 1;
309
+ if (pending === 0) process.exit(status);
310
+ };
311
+ process.stdout.write("", flushed);
312
+ process.stderr.write("", flushed);
313
+ };
314
+
260
315
  const shouldAnimateInstall = () => {
261
316
  if (process.env.LITCLAUDE_SPINNER === "1") return true;
262
317
  if (process.env.LITCLAUDE_SPINNER === "0") return false;
@@ -284,6 +339,8 @@ const formatInstallStep = (label) => {
284
339
  // Shared LitFamily banner frame (plans/references/installer-choice-contract.md):
285
340
  // the canonical Ignition B mark plus the existing install plan frame.
286
341
  const FAMILY_RULE = ` ${"━".repeat(46)}`;
342
+ // The banner keeps its short tagline; package.json carries the longer npm search description.
343
+ const BANNER_TAGLINE = "Claude Code-native workflow distribution.";
287
344
  const trueColorSupported = () => /truecolor|24bit/iu.test(process.env.COLORTERM ?? "");
288
345
  const brandOrange = (text, color = shouldColorInstall()) => {
289
346
  if (!color) return text;
@@ -311,7 +368,7 @@ const familyBanner = ({ color = shouldColorInstall() } = {}) => [
311
368
  brandOrange(FAMILY_RULE, color),
312
369
  "",
313
370
  ...renderLitClaudeWordmark({ color }),
314
- ` ${packageJson.description}`,
371
+ ` ${BANNER_TAGLINE}`,
315
372
  "",
316
373
  brandOrange(FAMILY_RULE, color),
317
374
  " ╭─ INSTALL PLAN",
@@ -453,7 +510,7 @@ const hudAccentPreview = (theme) => {
453
510
  ? `\x1b[${ansi16CodeForAccent(theme.name)}m`
454
511
  : `\x1b[38;5;${theme.code}m`;
455
512
  const mark = `${accent}▌${accent ? "\x1b[0m" : ""}`;
456
- const metrics = ` | O4.8 │ ctx [${mark}░░] 18%/1000k │ 5h [▏░] 4%`;
513
+ const metrics = ` | O4.8 │ ctx [${mark}░░] 18%/1m │ 5h [▏░]4%`;
457
514
  return `${brand}${metrics}`;
458
515
  };
459
516
 
@@ -536,6 +593,53 @@ const chooseOutputStyle = async (existingChoice = "off") => {
536
593
  }
537
594
  };
538
595
 
596
+ const shouldPromptIconFont = () => {
597
+ if (process.env.LITCLAUDE_ICON_FONT_PROMPT === "1") return true;
598
+ if (process.env.LITCLAUDE_ICON_FONT_PROMPT === "0") return false;
599
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY && !Object.hasOwn(process.env, "CI"));
600
+ };
601
+
602
+ // The icon font is opt-in: the flag, or a Yes to the one question an interactive install asks.
603
+ // --yes keeps the shipped default, which is No.
604
+ const chooseIconFont = async ({ iconFont, assumeYes }) => {
605
+ if (iconFont) return true;
606
+ if (assumeYes || !shouldPromptIconFont()) return false;
607
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: shouldColorInstall() });
608
+ try {
609
+ return parseYesNo(await rl.question(ICON_FONT_QUESTION));
610
+ } finally {
611
+ rl.close();
612
+ }
613
+ };
614
+
615
+ const iconFontPaths = () => ({ platform: process.platform, env: process.env, home: homedir(), litHome: litHome() });
616
+
617
+ const runIconFontInstall = async () => {
618
+ const where = iconFontPaths();
619
+ if (!planIconFontInstall(where).supported) {
620
+ process.stdout.write(`ICON_FONT_SKIPPED: ${windowsIconFontInstructions()}\n`);
621
+ return;
622
+ }
623
+ try {
624
+ const archive = await loadIconFontArchive({ env: process.env });
625
+ const result = installIconFont({ ...where, archive });
626
+ for (const path of result.installed) process.stdout.write(`ICON_FONT_INSTALLED: ${path}\n`);
627
+ for (const path of result.present) process.stdout.write(`ICON_FONT_PRESENT: ${path}\n`);
628
+ for (const { path, reason } of result.skipped) process.stdout.write(`ICON_FONT_LEFT_ALONE: ${path} (${reason})\n`);
629
+ if (result.fontCache) process.stdout.write(`ICON_FONT_CACHE: ${result.fontCache}\n`);
630
+ process.stdout.write(`ICON_FONT_HINT: ${terminalFontHint(process.env)}\n`);
631
+ } catch (error) {
632
+ process.stderr.write(`ICON_FONT_FAILED: ${error.message}\n`);
633
+ }
634
+ };
635
+
636
+ const iconFontDoctorLine = () => {
637
+ const status = iconFontStatus(iconFontPaths());
638
+ if (!status.supported) return "ICON_FONT: not managed on this platform (optional; see litclaude-ai install --help)";
639
+ if (status.present) return `ICON_FONT: present (${status.path}, ${status.managed ? "installed by LitClaude" : "not installed by LitClaude"})`;
640
+ return "ICON_FONT: not installed (optional; litclaude-ai install --icon-font)";
641
+ };
642
+
539
643
  const hudCommandForPlugin = (pluginPath, accent = "cyan") =>
540
644
  `LITCLAUDE_HUD_ACCENT=${normalizeHudAccent(accent)} node "${join(pluginPath, "bin", "litclaude-hud.js")}"`;
541
645
 
@@ -1030,6 +1134,8 @@ const verifyInstalledPayload = ({ home, litClaudeHome, targetPlugin }) => {
1030
1134
  for (const relativePath of [
1031
1135
  ".claude-plugin/plugin.json",
1032
1136
  "hooks/hooks.json",
1137
+ "hooks/lit-mod.mjs",
1138
+ "hooks/lit-band-client.mjs",
1033
1139
  "bin/litclaude-hook.js",
1034
1140
  "lib/start-work-lifecycle.mjs",
1035
1141
  "lib/start-work-cli.mjs",
@@ -1135,7 +1241,7 @@ const install = async ({ dryRun, rest }) => {
1135
1241
  const home = claudeHome();
1136
1242
  const litClaudeHome = litHome();
1137
1243
  const existingLitClaude = readClaudeSettings(home)[litClaudeSettingsKey] ?? {};
1138
- const { permissionMode, permissionExplicit, assumeYes } = parseInstallOptions(rest, existingLitClaude.permissionMode);
1244
+ const { permissionMode, permissionExplicit, assumeYes, iconFont } = parseInstallOptions(rest, existingLitClaude.permissionMode);
1139
1245
  const existingHudAccent = normalizeHudAccent(existingLitClaude.hudAccent);
1140
1246
  const existingOutputStyle = existingLitClaude.outputStyleChoice ?? "off";
1141
1247
  const requestedHudAccent = process.env.LITCLAUDE_HUD_ACCENT
@@ -1160,6 +1266,16 @@ const install = async ({ dryRun, rest }) => {
1160
1266
  process.stdout.write(`Would mutate global Claude settings permissions.allow/deny: ${permissionMode} (${permissionSource})\n`);
1161
1267
  process.stdout.write(`Permission profile: ${permissionProfile(permissionMode).description}\n`);
1162
1268
  process.stdout.write("Would pre-warm the lit-typographic-motion runtime: litclaude-ai motion-runtime install\n");
1269
+ const fontPlan = planIconFontInstall(iconFontPaths());
1270
+ if (iconFont) {
1271
+ process.stdout.write(fontPlan.supported
1272
+ ? `Would install icon font: ${ICON_FONT.family} ${ICON_FONT.release} into ${fontPlan.dir} from ${ICON_FONT.url} (SHA-256 ${ICON_FONT.archiveSha256})\n`
1273
+ : `Would skip icon font: ${windowsIconFontInstructions()}\n`);
1274
+ } else if (!assumeYes && shouldPromptIconFont()) {
1275
+ process.stdout.write(`Would ask: ${ICON_FONT_QUESTION.trim()}\n`);
1276
+ } else {
1277
+ process.stdout.write("Icon font: not requested (pass --icon-font)\n");
1278
+ }
1163
1279
  process.stdout.write("Launch with: claude\n");
1164
1280
  return;
1165
1281
  }
@@ -1174,6 +1290,7 @@ const install = async ({ dryRun, rest }) => {
1174
1290
  const outputStyleChoice = assumeYes
1175
1291
  ? normalizeOutputStyle(process.env.LITCLAUDE_OUTPUT_STYLE ?? existingOutputStyle)
1176
1292
  : await chooseOutputStyle(existingOutputStyle);
1293
+ const wantsIconFont = await chooseIconFont({ iconFont, assumeYes });
1177
1294
  const preservation = preflightInstallOwnership({ preserveModifiedSkill: true });
1178
1295
  const preservedPath = preserveModifiedLegacySkill(preservation, litClaudeHome);
1179
1296
  const targetCompatibility = versionRoot(litClaudeHome);
@@ -1268,6 +1385,7 @@ const install = async ({ dryRun, rest }) => {
1268
1385
  process.stdout.write(hudManaged ? `HUD: LitClaude statusLine installed (${hudAccent})\n` : "HUD: user statusLine setting preserved\n");
1269
1386
  process.stdout.write(`Global Claude settings permissions.allow/deny: ${permissionMode} — ${permissionProfile(permissionMode).description}\n`);
1270
1387
  process.stdout.write(`${prewarmMotionRuntime()}\n`);
1388
+ if (wantsIconFont) await runIconFontInstall();
1271
1389
  process.stdout.write("Launch with: claude\n");
1272
1390
  if (preservedPath) process.stderr.write(`INSTALL_WARNING: PRESERVED_MODIFIED_SKILL lit-korean at ${preservedPath}\n`);
1273
1391
  };
@@ -1295,6 +1413,7 @@ const doctor = ({ dryRun }) => {
1295
1413
  process.stdout.write("Would verify exact canonical frontend corpus bytes, legal companions, no extras, and three canonical runtime closures\n");
1296
1414
  process.stdout.write(`${jevStatusLine(process.env)}\n`);
1297
1415
  writeAutoHandoffDoctorLines();
1416
+ process.stdout.write(`${iconFontDoctorLine()}\n`);
1298
1417
  return;
1299
1418
  }
1300
1419
 
@@ -1307,6 +1426,8 @@ const doctor = ({ dryRun }) => {
1307
1426
  ".mcp.json",
1308
1427
  ".lsp.json",
1309
1428
  "hooks/hooks.json",
1429
+ "hooks/lit-mod.mjs",
1430
+ "hooks/lit-band-client.mjs",
1310
1431
  "bin/litclaude-hook.js",
1311
1432
  "bin/litclaude-mcp.js",
1312
1433
  "lib/start-work-lifecycle.mjs",
@@ -1383,6 +1504,7 @@ const doctor = ({ dryRun }) => {
1383
1504
  }
1384
1505
  process.stdout.write(`${jevStatusLine(process.env)}\n`);
1385
1506
  writeAutoHandoffDoctorLines();
1507
+ process.stdout.write(`${iconFontDoctorLine()}\n`);
1386
1508
 
1387
1509
  // lit-typographic-motion: the five readiness probes, every run. Read-only and never fatal; a
1388
1510
  // missing pre-warm names the command that fixes it.
@@ -1484,7 +1606,7 @@ const runClaude = ({ dryRun, rest }) => {
1484
1606
 
1485
1607
  const result = spawnSync(command[0], command.slice(1), { stdio: "inherit" });
1486
1608
  if (result.error) fail(`failed to start claude: ${result.error.message}`);
1487
- process.exit(result.status ?? 1);
1609
+ exitAfterFlush(result.status ?? 1);
1488
1610
  };
1489
1611
 
1490
1612
  const uninstall = ({ dryRun }) => {
@@ -1493,8 +1615,15 @@ const uninstall = ({ dryRun }) => {
1493
1615
  const updateNotifierRoot = dirname(updateNotifierCachePath(home));
1494
1616
  if (dryRun) {
1495
1617
  process.stdout.write(`DRY_RUN: remove checked ${versionRoot(home)}, ${currentRoot(home)}, ${marketplaceRoot(home)}, ${claudePluginRoot()}, ${pluginKey}, and its marketplace/settings entries; preserve unrelated versions/cache entries and nonempty ${updateNotifierRoot}\n`);
1618
+ const fonts = uninstallIconFont({ litHome: home, dryRun: true });
1619
+ for (const path of fonts.removed) process.stdout.write(`Would remove icon font file: ${path}\n`);
1620
+ for (const { path, reason } of fonts.kept) process.stdout.write(`Would keep icon font file: ${path} (${reason})\n`);
1496
1621
  return;
1497
1622
  }
1623
+ // Before the LitClaude home goes: the manifest there says which font files LitClaude wrote.
1624
+ const fonts = uninstallIconFont({ litHome: home });
1625
+ for (const path of fonts.removed) process.stdout.write(`ICON_FONT_REMOVED: ${path}\n`);
1626
+ for (const { path, reason } of fonts.kept) process.stdout.write(`ICON_FONT_KEPT: ${path} (${reason})\n`);
1498
1627
  rmSync(currentRoot(home), { recursive: true, force: true });
1499
1628
  rmSync(versionRoot(home), { recursive: true, force: true });
1500
1629
  removeEmptyDirectory(join(home, "litclaude-ai"));
@@ -1560,7 +1689,8 @@ const main = async () => {
1560
1689
 
1561
1690
  if (!command) {
1562
1691
  printUsage();
1563
- process.exit(64);
1692
+ exitAfterFlush(64);
1693
+ return;
1564
1694
  }
1565
1695
  if (parsed.invalidOption) fail(`Unknown update option: ${parsed.invalidOption}`, 64);
1566
1696
 
@@ -1570,6 +1700,13 @@ const main = async () => {
1570
1700
  return;
1571
1701
  }
1572
1702
 
1703
+ // Unknown flags on the managed commands stop here, before the ownership preflight, the
1704
+ // automatic update or the command itself can change anything.
1705
+ if (command === "install" || command === "update") parseInstallOptions(parsed.rest);
1706
+ if ((command === "uninstall" || command === "doctor") && parsed.rest.length > 0) {
1707
+ fail(`Unknown ${command} option: ${parsed.rest[0]}`, 64);
1708
+ }
1709
+
1573
1710
  if (!parsed.dryRun && ["install", "update", "uninstall"].includes(command)) {
1574
1711
  preflightInstallOwnership({ preserveModifiedSkill: ["install", "update"].includes(parsed.command) });
1575
1712
  }
@@ -1603,26 +1740,26 @@ const main = async () => {
1603
1740
  runClaude(parsed);
1604
1741
  break;
1605
1742
  case "litgoal":
1606
- process.exit(runLitgoalCli(parsed.rest));
1743
+ exitAfterFlush(runLitgoalCli(parsed.rest));
1607
1744
  break;
1608
1745
  case "wikify":
1609
- process.exit(runWikifyKnowledgeCli(parsed.rest, undefined, process.cwd()));
1746
+ exitAfterFlush(runWikifyKnowledgeCli(parsed.rest, undefined, process.cwd()));
1610
1747
  break;
1611
1748
  case "workflow-check":
1612
- process.exit(runWorkflowCheckCli(root, version, parsed.rest));
1749
+ exitAfterFlush(runWorkflowCheckCli(root, version, parsed.rest));
1613
1750
  break;
1614
1751
  case "public-read":
1615
- process.exit(await runPublicRead(parsed));
1752
+ exitAfterFlush(await runPublicRead(parsed));
1616
1753
  break;
1617
1754
  case "start-work-next":
1618
- process.exit(runStartWorkContinuationCli(root, parsed.rest));
1755
+ exitAfterFlush(runStartWorkContinuationCli(root, parsed.rest));
1619
1756
  break;
1620
1757
  case "start-work":
1621
- process.exit(runStartWorkCli(parsed.rest, undefined, process.cwd()));
1758
+ exitAfterFlush(runStartWorkCli(parsed.rest, undefined, process.cwd()));
1622
1759
  break;
1623
1760
  case "motion-runtime": {
1624
1761
  const { main: motionRuntime } = await import(pathToFileURL(join(motionSkillScripts, "motion-runtime.mjs")).href);
1625
- process.exit(await motionRuntime(parsed.rest));
1762
+ exitAfterFlush(await motionRuntime(parsed.rest));
1626
1763
  break;
1627
1764
  }
1628
1765
  case "uninstall":
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.21"><title>release: 1.0.21</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.21</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.23"><title>release: 1.0.23</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.23</text></g></svg>