claudeup 4.39.0 → 4.40.0

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.
@@ -3,23 +3,19 @@
3
3
  *
4
4
  * ## What a "style" is
5
5
  *
6
- * Claude Code activates exactly ONE output style at a time. The `style@magus`
7
- * plugin's model is compositional instead — one verbosity preset plus any
8
- * number of modifiers — so the combination has to be flattened into a single
9
- * generated style file before the harness ever sees it. That flattening is
10
- * what `composeStyleFile` does here.
6
+ * Claude Code activates exactly ONE output style at a time. The preset model
7
+ * is compositional instead — one verbosity preset plus any number of
8
+ * modifiers — so the combination has to be flattened into a single generated
9
+ * style file before the harness ever sees it. That flattening is what
10
+ * `composeStyleFile` does here.
11
11
  *
12
- * ## Why this re-implements the plugin's compose-style.ts
12
+ * ## Where the presets come from
13
13
  *
14
- * The plugin ships `scripts/compose-style.ts`, run via `bun`. claudeup cannot
15
- * call it: claudeup ships as a `bun --compile` binary, so `bun` may not be on
16
- * PATH, and `scripts/` only exists when style@magus is installed. What IS
17
- * stable is the *file format* `styles/*.md` with frontmatter carrying
18
- * `name`, `axis`, `summary`, `conflicts`, `template`. That format is the
19
- * contract, and reading it needs no runtime at all.
20
- *
21
- * Parity with the plugin is asserted in `__tests__/styles-manager.test.ts`
22
- * against the real shipped preset files.
14
+ * `data/styles/*.md`, embedded in the binary at build time. They used to ship
15
+ * in the `style@magus` plugin and be discovered on disk, which meant two
16
+ * implementations of one composer and an empty Styles tab for anyone without
17
+ * the plugin. The plugin was retired at Marketplace 10.0.0; this is now the
18
+ * only composer, and the presets travel with the binary that parses them.
23
19
  *
24
20
  * ## Profile awareness
25
21
  *
@@ -40,6 +36,7 @@ import os from "node:os";
40
36
  import path from "node:path";
41
37
  import fs from "fs-extra";
42
38
  import { findCommunityStyle } from "../data/community-styles.js";
39
+ import { EMBEDDED_PRESETS } from "../data/styles/index.js";
43
40
  import { getManifestPath, readManifest, writeManifest } from "./manifest.js";
44
41
  import { activeProfile } from "./symlink-manager.js";
45
42
 
@@ -55,7 +52,7 @@ export const COMMUNITY_ID_PREFIX = "community:";
55
52
 
56
53
  export type StyleAxis = "verbosity" | "modifier";
57
54
 
58
- /** A preset shipped by the style@magus plugin. */
55
+ /** A preset embedded in this binary, from `data/styles/`. */
59
56
  export interface StylePreset {
60
57
  kind: "preset";
61
58
  /** Selection key. Equals `name` — presets share one namespace. */
@@ -74,6 +71,11 @@ export interface StylePreset {
74
71
  /** Template presets ship an empty table and cannot be applied directly. */
75
72
  template: boolean;
76
73
  body: string;
74
+ /**
75
+ * What the detail panel shows under "Source". A label rather than a real
76
+ * path: an embedded preset has no file on disk, and the open-in-editor
77
+ * action refuses presets before it ever reads this.
78
+ */
77
79
  path: string;
78
80
  }
79
81
 
@@ -81,9 +83,9 @@ export interface StylePreset {
81
83
  * Where an importable style came from, which is what decides how it is grouped
82
84
  * and whether it travels with the repository.
83
85
  *
84
- * - `anthropic` — a Claude Code built-in captured by the style plugin's
85
- * `capture-builtin.ts`. Its text ships inside the binary, so the file is a
86
- * snapshot that goes stale on the next upgrade.
86
+ * - `anthropic` — a Claude Code built-in captured by
87
+ * `scripts/capture-builtin.ts`. Its text ships inside the Claude Code
88
+ * binary, so the file is a snapshot that goes stale on the next upgrade.
87
89
  * - `team` — project-scoped, so it lives in the repo and commits with it. This
88
90
  * is the one everyone on the project gets.
89
91
  * - `personal` — user-scoped, so it exists only on this machine.
@@ -180,8 +182,6 @@ export interface StylesSnapshot {
180
182
  settingsPath: string;
181
183
  /** Name of the generated style, e.g. "composed" or "composed-dev". */
182
184
  styleName: string;
183
- /** Directory presets were read from; null when style@magus isn't installed. */
184
- presetsRoot: string | null;
185
185
  /** Active profile name, when .claude/settings.json is a profile symlink. */
186
186
  profile: string | null;
187
187
  /** The value currently in settings.json, whatever set it. */
@@ -266,7 +266,8 @@ type Frontmatter = Record<string, string>;
266
266
  *
267
267
  * Deliberately not a YAML parser: the keys we read are flat scalars, and
268
268
  * pulling in a YAML dependency to read five strings would be the tail wagging
269
- * the dog. Mirrors the plugin's parser exactly, quote-unescaping included.
269
+ * the dog. Quote-unescaping is included because a `description:` containing a
270
+ * colon has to be quoted to stay valid YAML for every other reader.
270
271
  */
271
272
  export function splitFrontmatter(text: string): {
272
273
  frontmatter: Frontmatter;
@@ -346,106 +347,22 @@ async function markdownFiles(dir: string): Promise<string[]> {
346
347
  }
347
348
  }
348
349
 
349
- // ─── Preset discovery ─────────────────────────────────────────────────────────
350
-
351
- /** Newest-first semver-ish comparison. Non-numeric segments sort last. */
352
- function compareVersionsDesc(a: string, b: string): number {
353
- const parse = (v: string) =>
354
- v.split(".").map((part) => {
355
- const n = Number.parseInt(part, 10);
356
- return Number.isNaN(n) ? -1 : n;
357
- });
358
- const av = parse(a);
359
- const bv = parse(b);
360
- for (let i = 0; i < Math.max(av.length, bv.length); i++) {
361
- const diff = (bv[i] ?? 0) - (av[i] ?? 0);
362
- if (diff !== 0) return diff;
363
- }
364
- return 0;
365
- }
350
+ // ─── Presets ────────────────────────────────────────────────────────────────
366
351
 
367
352
  /**
368
- * Locate the style plugin's `styles/` directory.
353
+ * Parse the embedded preset files into usable presets.
369
354
  *
370
- * Order matters. The plugin CACHE is authoritative — that is what Claude Code
371
- * actually loads (see CLAUDE.md, "Marketplace directory deletion bug"), and it
372
- * survives a marketplace clone being deleted. The marketplace clone is the
373
- * fallback, and a repo-relative path covers running claudeup from source.
374
- *
375
- * Returns null when style@magus isn't installed; the screen renders an empty
376
- * state rather than pretending there are no styles.
355
+ * Synchronous and infallible by construction: the text is inlined at build
356
+ * time, so there is no directory to miss, no plugin to be uninstalled, and no
357
+ * version of the files other than the one this binary was built with. The
358
+ * frontmatter contract is `name`, `title`, `axis`, `summary`, `conflicts`,
359
+ * `template` — see `data/styles/index.ts`.
377
360
  */
378
- export async function discoverPresetsRoot(): Promise<string | null> {
379
- const home = os.homedir();
380
- const cacheRoot = path.join(home, ".claude", "plugins", "cache");
381
-
382
- // 1. Installed plugin cache: cache/<marketplace>/style/<version>/styles
383
- try {
384
- const marketplaces = await fs.readdir(cacheRoot);
385
- const candidates: Array<{ version: string; dir: string }> = [];
386
- for (const marketplace of marketplaces) {
387
- const styleDir = path.join(cacheRoot, marketplace, "style");
388
- if (!(await fs.pathExists(styleDir))) continue;
389
- for (const version of await fs.readdir(styleDir)) {
390
- const dir = path.join(styleDir, version, "styles");
391
- if (await fs.pathExists(dir)) candidates.push({ version, dir });
392
- }
393
- }
394
- if (candidates.length > 0) {
395
- candidates.sort((a, b) => compareVersionsDesc(a.version, b.version));
396
- return candidates[0].dir;
397
- }
398
- } catch {
399
- /* no cache dir — fall through */
400
- }
401
-
402
- // 2. Marketplace clone
403
- const marketplacesRoot = path.join(
404
- home,
405
- ".claude",
406
- "plugins",
407
- "marketplaces",
408
- );
409
- try {
410
- for (const marketplace of await fs.readdir(marketplacesRoot)) {
411
- const dir = path.join(
412
- marketplacesRoot,
413
- marketplace,
414
- "plugins",
415
- "style",
416
- "styles",
417
- );
418
- if (await fs.pathExists(dir)) return dir;
419
- }
420
- } catch {
421
- /* no marketplaces dir — fall through */
422
- }
423
-
424
- // 3. Running from source: walk up looking for plugins/style/styles
425
- try {
426
- let dir = path.dirname(new URL(import.meta.url).pathname);
427
- for (let depth = 0; depth < 8; depth++) {
428
- const candidate = path.join(dir, "plugins", "style", "styles");
429
- if (await fs.pathExists(candidate)) return candidate;
430
- const parent = path.dirname(dir);
431
- if (parent === dir) break;
432
- dir = parent;
433
- }
434
- } catch {
435
- /* not resolvable in a compiled binary — fine */
436
- }
437
-
438
- return null;
439
- }
440
-
441
- async function readPresets(root: string | null): Promise<StylePreset[]> {
442
- if (!root) return [];
361
+ export function loadPresets(): StylePreset[] {
443
362
  const presets: StylePreset[] = [];
444
- for (const file of await markdownFiles(root)) {
445
- const { frontmatter, body } = splitFrontmatter(
446
- await fs.readFile(file, "utf8"),
447
- );
448
- const name = frontmatter.name || path.basename(file, ".md");
363
+ for (const { file, text } of EMBEDDED_PRESETS) {
364
+ const { frontmatter, body } = splitFrontmatter(text);
365
+ const name = frontmatter.name || file.replace(/\.md$/, "");
449
366
  presets.push({
450
367
  kind: "preset",
451
368
  id: name,
@@ -456,7 +373,7 @@ async function readPresets(root: string | null): Promise<StylePreset[]> {
456
373
  conflicts: splitList(frontmatter.conflicts),
457
374
  template: asBool(frontmatter.template) === true,
458
375
  body,
459
- path: file,
376
+ path: `bundled with claudeup · styles/${file}`,
460
377
  });
461
378
  }
462
379
  return presets;
@@ -892,8 +809,7 @@ export interface LoadStylesOptions {
892
809
  /**
893
810
  * Home directory to read user-scoped styles from. Defaults to the real one.
894
811
  * Overridable so a test does not depend on whatever the developer happens to
895
- * have in ~/.claude/output-styles — the plugin's compose-style.ts takes a
896
- * `--home` flag for the same reason.
812
+ * have in ~/.claude/output-styles.
897
813
  */
898
814
  home?: string;
899
815
  }
@@ -909,8 +825,7 @@ export async function loadStyles(
909
825
  const stylePath = stylePathFor(projectPath, styleName);
910
826
  const settingsPath = settingsPathFor(projectPath);
911
827
 
912
- const presetsRoot = await discoverPresetsRoot();
913
- const presets = await readPresets(presetsRoot);
828
+ const presets = loadPresets();
914
829
  // Exclude every name we might generate, not just the current one — a style
915
830
  // left over from another profile is ours, not the user's, and importing it
916
831
  // would nest a composition inside a composition.
@@ -946,7 +861,6 @@ export async function loadStyles(
946
861
  stylePath,
947
862
  settingsPath,
948
863
  styleName,
949
- presetsRoot,
950
864
  profile,
951
865
  currentOutputStyle,
952
866
  };
@@ -1036,9 +950,11 @@ const MAX_DESCRIPTION = 200;
1036
950
  * specific-after-broad, so later text refines earlier text rather than being
1037
951
  * buried by it.
1038
952
  *
1039
- * Mirrored verbatim in the style plugin's `scripts/compose-style.ts`; the
1040
- * parity test in `__tests__/styles-manager.test.ts` reads that file and fails
1041
- * if the two drift.
953
+ * This is the only copy. It used to be duplicated in the retired style
954
+ * plugin's composer, with a test comparing the two; both are gone, so nothing
955
+ * can drift from it any more. It is still asserted to reach EVERY composition
956
+ * in `__tests__/styles-manager.test.ts` — that check is what matters, since
957
+ * the block is the security backstop on arbitrary assembled instruction text.
1042
958
  */
1043
959
  export const INTEGRITY_BLOCK = `## Style limits
1044
960
 
@@ -29,18 +29,35 @@ export function CategoryHeader({
29
29
  const countBadge = count !== undefined ? ` (${count})` : "";
30
30
  const statusText = status ? ` ${status}` : "";
31
31
 
32
- // Simple format without dynamic line calculation
32
+ // Flex row instead of one flat <text>: a flat text wraps when the pane is
33
+ // narrower than the row ("by MadAppGang" lost its final character and the wrap
34
+ // shifted every item below by one line). Here the dash filler is the only
35
+ // element allowed to shrink to nothing, so the badge survives at any width
36
+ // and the title clips last.
33
37
  return (
34
- <text fg={theme.colors.text}>
35
- <span fg={theme.colors.muted}>{expandIcon}</span>
36
- <span fg={theme.colors.text}>
37
- <strong> {title}</strong>
38
- </span>
39
- <span fg={theme.colors.muted}>{versionBadge}</span>
40
- <span fg={theme.colors.muted}>{countBadge}</span>
41
- <span fg={theme.colors.border}> ────</span>
42
- <span fg={statusColor}>{statusText}</span>
43
- </text>
38
+ <box flexDirection="row" width="100%" height={1} overflow="hidden">
39
+ <box flexShrink={1} minWidth={4} overflow="hidden" height={1}>
40
+ <text fg={theme.colors.text}>
41
+ <span fg={theme.colors.muted}>{expandIcon}</span>
42
+ <span fg={theme.colors.text}>
43
+ <strong> {title}</strong>
44
+ </span>
45
+ <span fg={theme.colors.muted}>{versionBadge}</span>
46
+ <span fg={theme.colors.muted}>{countBadge}</span>
47
+ </text>
48
+ </box>
49
+ {/* flexShrink 100: the filler must be the first thing sacrificed. With equal
50
+ shrink factors yoga takes width from the (larger-basis) title first, which
51
+ clipped a character off the title while all four dashes survived. */}
52
+ <box flexShrink={100} minWidth={0} overflow="hidden" height={1}>
53
+ <text fg={theme.colors.border}> ────</text>
54
+ </box>
55
+ {status ? (
56
+ <box flexShrink={0} height={1}>
57
+ <text fg={statusColor}>{statusText}</text>
58
+ </box>
59
+ ) : null}
60
+ </box>
44
61
  );
45
62
  }
46
63
 
@@ -70,6 +70,14 @@ export function ScrollableList<T>({
70
70
  <box
71
71
  key={getKey ? getKey(item, originalIndex) : `${originalIndex}`}
72
72
  width="100%"
73
+ // height=1 is load-bearing, not cosmetic. overflow="hidden" alone clips
74
+ // CONTENT but lets the box GROW: an over-wide <text> wraps to a second
75
+ // line, the row becomes two lines tall, and every item below shifts —
76
+ // which rendered as a phantom blank line under "Magus Marketing" and
77
+ // the last two plugins composited onto one row. The list's scroll math
78
+ // assumes one line per item; this makes that assumption true.
79
+ height={1}
80
+ flexShrink={0}
73
81
  overflow="hidden"
74
82
  >
75
83
  {renderItem(item, originalIndex, originalIndex === selectedIndex)}
@@ -628,9 +628,9 @@ export function StylesScreen() {
628
628
  /**
629
629
  * Open the selected style's file in the system's default application.
630
630
  *
631
- * Presets are excluded on purpose: they live in the installed plugin cache,
632
- * so an edit there is silently discarded the next time the plugin updates.
633
- * Saying that is more useful than opening a file whose changes will vanish.
631
+ * Presets are excluded on purpose: they are compiled into this binary and
632
+ * have no file on disk to open. Saying so is more useful than failing on a
633
+ * path that does not exist.
634
634
  */
635
635
  const handleOpen = useCallback(async () => {
636
636
  if (selectedItem?.kind === "offer") {
@@ -645,7 +645,7 @@ export function StylesScreen() {
645
645
 
646
646
  if (source.kind === "preset") {
647
647
  showStatus(
648
- `${source.name} ships with style@magus and is replaced on update — press n to make a team style instead`,
648
+ `${source.name} is built into claudeup and is replaced on update — press n to make a team style instead`,
649
649
  "error",
650
650
  );
651
651
  return;
@@ -943,7 +943,6 @@ export function StylesScreen() {
943
943
  Math.floor(dimensions.terminalWidth * 0.5) - 4,
944
944
  );
945
945
  const query = stylesState.searchQuery.trim();
946
- const noPresets = snapshot !== null && snapshot.presetsRoot === null;
947
946
  // `t` only means anything on a template preset, so it is only advertised
948
947
  // there — a footer full of keys that do nothing on the current row teaches
949
948
  // the wrong thing.
@@ -1039,30 +1038,6 @@ export function StylesScreen() {
1039
1038
  </box>
1040
1039
  )}
1041
1040
 
1042
- {noPresets && allItems.length === 0 && (
1043
- <box flexDirection="column" paddingLeft={2} paddingRight={2}>
1044
- <text fg={theme.colors.warning}>
1045
- The style plugin is not installed.
1046
- </text>
1047
- <box marginTop={1}>
1048
- <text fg={theme.colors.muted}>
1049
- Presets come from style@magus. Install it from the
1050
- </text>
1051
- <text fg={theme.colors.muted}>
1052
- Plugins tab, then press r to reload.
1053
- </text>
1054
- </box>
1055
- <box marginTop={1}>
1056
- <text fg={theme.colors.muted}>
1057
- Output styles you have written yourself are still
1058
- </text>
1059
- <text fg={theme.colors.muted}>
1060
- listed under Imported, with no plugin needed.
1061
- </text>
1062
- </box>
1063
- </box>
1064
- )}
1065
-
1066
1041
  {allItems.length > 0 && (
1067
1042
  <ScrollableList
1068
1043
  items={allItems}
@@ -1077,8 +1052,7 @@ export function StylesScreen() {
1077
1052
 
1078
1053
  {query.length > 0 &&
1079
1054
  allItems.length === 0 &&
1080
- stylesState.snapshot.status === "success" &&
1081
- !noPresets && (
1055
+ stylesState.snapshot.status === "success" && (
1082
1056
  <EmptyFilterState
1083
1057
  query={stylesState.searchQuery}
1084
1058
  entityName="styles"