claudeup 4.36.0 → 4.38.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.
Files changed (47) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  4. package/src/__tests__/catalog-notice.test.ts +155 -0
  5. package/src/__tests__/community-fetch.test.ts +545 -0
  6. package/src/__tests__/community-registry.test.ts +269 -0
  7. package/src/__tests__/community-staleness.test.ts +722 -0
  8. package/src/__tests__/github-budget.test.ts +200 -0
  9. package/src/__tests__/open-file.test.ts +59 -0
  10. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  11. package/src/__tests__/style-wrap.test.ts +220 -0
  12. package/src/__tests__/styles-manager.test.ts +1124 -0
  13. package/src/__tests__/styles-origins.test.ts +416 -0
  14. package/src/__tests__/styles-screen-state.test.ts +460 -0
  15. package/src/__tests__/styles-status-line.test.ts +72 -0
  16. package/src/__tests__/styles-sync.test.ts +452 -0
  17. package/src/__tests__/tabbar-layout.test.ts +62 -0
  18. package/src/__tests__/terminology-filler.test.ts +214 -0
  19. package/src/data/community-styles.ts +521 -0
  20. package/src/main.tsx +15 -0
  21. package/src/services/catalog-cache-store.ts +312 -0
  22. package/src/services/community-fetcher.ts +90 -0
  23. package/src/services/community-styles.ts +1194 -0
  24. package/src/services/github-budget.ts +274 -0
  25. package/src/services/marketplace-catalog-git.ts +170 -0
  26. package/src/services/marketplace-catalog.ts +95 -0
  27. package/src/services/marketplace-fetcher.ts +310 -87
  28. package/src/services/plugin-manager.ts +103 -92
  29. package/src/services/styles-manager.ts +1400 -0
  30. package/src/services/terminology-filler.ts +266 -0
  31. package/src/ui/App.tsx +15 -3
  32. package/src/ui/adapters/catalogNotice.ts +122 -0
  33. package/src/ui/adapters/stylesAdapter.ts +403 -0
  34. package/src/ui/components/TabBar.tsx +43 -9
  35. package/src/ui/components/layout/ScreenLayout.tsx +19 -2
  36. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  37. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  38. package/src/ui/registry.ts +6 -0
  39. package/src/ui/renderers/pluginRenderers.tsx +39 -1
  40. package/src/ui/renderers/styleRenderers.tsx +809 -0
  41. package/src/ui/screens/PluginsScreen.tsx +138 -29
  42. package/src/ui/screens/StylesScreen.tsx +1089 -0
  43. package/src/ui/screens/index.ts +1 -0
  44. package/src/ui/state/reducer.ts +124 -3
  45. package/src/ui/state/types.ts +76 -3
  46. package/src/utils/config-dir.ts +47 -0
  47. package/src/utils/open-file.ts +84 -0
@@ -5,5 +5,6 @@ export { SettingsScreen } from "./EnvVarsScreen.js";
5
5
  export { CliToolsScreen } from "./CliToolsScreen.js";
6
6
  export { ProfilesScreen } from "./ProfilesScreen.js";
7
7
  export { SkillsScreen } from "./SkillsScreen.js";
8
+ export { StylesScreen } from "./StylesScreen.js";
8
9
  export { GitignoreScreen } from "./GitignoreScreen.js";
9
10
  export { AliasScreen } from "./AliasScreen.js";
@@ -1,4 +1,5 @@
1
- import type { AppState, AppAction } from "./types.js";
1
+ import type { AppAction, AppState } from "./types.js";
2
+ import { asyncValue } from "./types.js";
2
3
 
3
4
  export const initialState: AppState = {
4
5
  // Navigation - start on plugins screen
@@ -60,6 +61,15 @@ export const initialState: AppState = {
60
61
  skills: { status: "idle" },
61
62
  updateStatus: null,
62
63
  },
64
+
65
+ styles: {
66
+ selectedIndex: 0,
67
+ searchQuery: "",
68
+ snapshot: { status: "idle" },
69
+ selected: new Set(),
70
+ status: null,
71
+ isFilling: false,
72
+ },
63
73
  };
64
74
 
65
75
  export function appReducer(state: AppState, action: AppAction): AppState {
@@ -137,8 +147,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
137
147
  ...state,
138
148
  plugins: {
139
149
  ...state.plugins,
140
- marketplaces: { status: "loading" },
141
- plugins: { status: "loading" },
150
+ // Hand the previous list forward so a reload refreshes in place
151
+ // instead of replacing the screen with "Loading...".
152
+ marketplaces: {
153
+ status: "loading",
154
+ previous: asyncValue(state.plugins.marketplaces),
155
+ },
156
+ plugins: {
157
+ status: "loading",
158
+ previous: asyncValue(state.plugins.plugins),
159
+ },
142
160
  },
143
161
  };
144
162
 
@@ -477,6 +495,109 @@ export function appReducer(state: AppState, action: AppAction): AppState {
477
495
  };
478
496
  }
479
497
 
498
+ // =========================================================================
499
+ // Styles screen
500
+ // =========================================================================
501
+ case "STYLES_SELECT":
502
+ return {
503
+ ...state,
504
+ styles: { ...state.styles, selectedIndex: action.index },
505
+ };
506
+
507
+ case "STYLES_SET_SEARCH":
508
+ return {
509
+ ...state,
510
+ styles: { ...state.styles, searchQuery: action.query },
511
+ };
512
+
513
+ case "STYLES_SEARCH_APPEND":
514
+ return {
515
+ ...state,
516
+ styles: {
517
+ ...state.styles,
518
+ searchQuery: state.styles.searchQuery + action.char,
519
+ selectedIndex: 0,
520
+ },
521
+ };
522
+
523
+ case "STYLES_SEARCH_BACKSPACE":
524
+ return {
525
+ ...state,
526
+ styles: {
527
+ ...state.styles,
528
+ searchQuery: state.styles.searchQuery.slice(0, -1),
529
+ selectedIndex: 0,
530
+ },
531
+ };
532
+
533
+ case "STYLES_DATA_LOADING":
534
+ return {
535
+ ...state,
536
+ styles: { ...state.styles, snapshot: { status: "loading" } },
537
+ };
538
+
539
+ case "STYLES_DATA_SUCCESS":
540
+ return {
541
+ ...state,
542
+ styles: {
543
+ ...state.styles,
544
+ snapshot: { status: "success", data: action.snapshot },
545
+ // Seed from the project's committed declaration when there is
546
+ // one, falling back to whatever is live locally.
547
+ //
548
+ // The declaration wins deliberately. It is what the project asked
549
+ // for; the local artifact is only what this machine last did. When
550
+ // the two differ the screen says "press a to re-apply" — and that
551
+ // sentence is a lie if `a` would re-apply the stale local set
552
+ // instead of the pulled one.
553
+ selected: new Set(
554
+ action.snapshot.declaration
555
+ ? [
556
+ ...action.snapshot.declaration.presets,
557
+ ...action.snapshot.declaration.imports,
558
+ ]
559
+ : [
560
+ ...(action.snapshot.applied?.presets ?? []),
561
+ ...(action.snapshot.applied?.imports ?? []),
562
+ ],
563
+ ),
564
+ },
565
+ };
566
+
567
+ case "STYLES_DATA_ERROR":
568
+ return {
569
+ ...state,
570
+ styles: {
571
+ ...state.styles,
572
+ snapshot: { status: "error", error: action.error },
573
+ },
574
+ };
575
+
576
+ case "STYLES_STATUS_SET":
577
+ return { ...state, styles: { ...state.styles, status: action.status } };
578
+
579
+ case "STYLES_STATUS_CLEAR":
580
+ return { ...state, styles: { ...state.styles, status: null } };
581
+
582
+ case "STYLES_FILL_START":
583
+ return { ...state, styles: { ...state.styles, isFilling: true } };
584
+
585
+ case "STYLES_FILL_END":
586
+ return { ...state, styles: { ...state.styles, isFilling: false } };
587
+
588
+ case "STYLES_TOGGLE": {
589
+ const selected = new Set(state.styles.selected);
590
+ if (selected.has(action.id)) selected.delete(action.id);
591
+ else selected.add(action.id);
592
+ return { ...state, styles: { ...state.styles, selected } };
593
+ }
594
+
595
+ case "STYLES_SET_SELECTION":
596
+ return {
597
+ ...state,
598
+ styles: { ...state.styles, selected: new Set(action.ids) },
599
+ };
600
+
480
601
  // =========================================================================
481
602
  // Modals
482
603
  // =========================================================================
@@ -1,11 +1,12 @@
1
+ import type { PluginInfo } from "../../services/plugin-manager.js";
2
+ import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
3
+ import type { StylesSnapshot } from "../../services/styles-manager.js";
1
4
  import type {
2
5
  Marketplace,
3
6
  McpServer,
4
7
  ProfileEntry,
5
8
  SkillInfo,
6
9
  } from "../../types/index.js";
7
- import type { PluginInfo } from "../../services/plugin-manager.js";
8
- import type { VersionMismatchInfo } from "../../services/plugin-version-check.js";
9
10
 
10
11
  // ============================================================================
11
12
  // Route Types
@@ -19,6 +20,7 @@ export type Screen =
19
20
  | "cli-tools"
20
21
  | "profiles"
21
22
  | "skills"
23
+ | "styles"
22
24
  | "gitignore"
23
25
  | "alias";
24
26
 
@@ -30,6 +32,7 @@ export type Route =
30
32
  | { screen: "cli-tools" }
31
33
  | { screen: "profiles" }
32
34
  | { screen: "skills" }
35
+ | { screen: "styles" }
33
36
  | { screen: "gitignore" }
34
37
  | { screen: "alias" };
35
38
 
@@ -39,10 +42,25 @@ export type Route =
39
42
 
40
43
  export type AsyncData<T> =
41
44
  | { status: "idle" }
42
- | { status: "loading" }
45
+ /**
46
+ * `previous` carries the last successful value through a reload.
47
+ *
48
+ * Without it a refetch is indistinguishable from a first load, so a screen
49
+ * that already had data blanked itself to "Loading..." every time it
50
+ * remounted — and the router unmounts screens on every tab switch. Optional so
51
+ * that a genuine first load is still just `{ status: "loading" }`.
52
+ */
53
+ | { status: "loading"; previous?: T }
43
54
  | { status: "success"; data: T }
44
55
  | { status: "error"; error: Error };
45
56
 
57
+ /** The value to render: fresh if loaded, else the last good one during a reload. */
58
+ export function asyncValue<T>(d: AsyncData<T>): T | undefined {
59
+ if (d.status === "success") return d.data;
60
+ if (d.status === "loading") return d.previous;
61
+ return undefined;
62
+ }
63
+
46
64
  // ============================================================================
47
65
  // Modal Types
48
66
  // ============================================================================
@@ -176,6 +194,38 @@ export interface SkillsScreenState {
176
194
  updateStatus: Map<string, boolean> | null;
177
195
  }
178
196
 
197
+ export interface StylesScreenState {
198
+ selectedIndex: number;
199
+ searchQuery: string;
200
+ /** Presets + importable styles + what is currently live, in one read. */
201
+ snapshot: AsyncData<StylesSnapshot>;
202
+ /**
203
+ * Pending selection, by source id. Staged rather than applied on each
204
+ * keystroke: composing writes a file and flips the harness's active output
205
+ * style, which is not something a stray Space press should do.
206
+ */
207
+ selected: Set<string>;
208
+ /**
209
+ * The status line's current message, or null for the default line.
210
+ *
211
+ * App state for the same reason `isFilling` is: `Router` swaps the component
212
+ * type on a tab change, so anything held in the screen's own `useState` is
213
+ * destroyed by `9 → 1 → 9`. It also stops being cleared on a wall clock —
214
+ * the message now survives until the next action replaces it, so the last
215
+ * thing you did is still on screen whenever you look.
216
+ */
217
+ status: { text: string; tone: "success" | "error" } | null;
218
+ /**
219
+ * True while a template fill (Claude Code over the whole project) is running.
220
+ *
221
+ * App state, not screen state, because the screen UNMOUNTS when you switch
222
+ * tabs. A fill runs for minutes, so switching away is the normal thing to do
223
+ * — and with the flag held locally the returning user saw no sign one was in
224
+ * flight and could launch a second subprocess on top of the first.
225
+ */
226
+ isFilling: boolean;
227
+ }
228
+
179
229
  // ============================================================================
180
230
  // App State
181
231
  // ============================================================================
@@ -209,6 +259,7 @@ export interface AppState {
209
259
  cliTools: CliToolsScreenState;
210
260
  profiles: ProfilesScreenState;
211
261
  skills: SkillsScreenState;
262
+ styles: StylesScreenState;
212
263
  }
213
264
 
214
265
  // ============================================================================
@@ -297,5 +348,27 @@ export type AppAction =
297
348
  | { type: "SKILLS_UPDATE_STATUS"; updates: Map<string, boolean> }
298
349
  | { type: "SKILLS_UPDATE_ITEM"; name: string; updates: Partial<SkillInfo> }
299
350
 
351
+ // Styles screen
352
+ | { type: "STYLES_SELECT"; index: number }
353
+ | { type: "STYLES_SET_SEARCH"; query: string }
354
+ // Append/backspace are computed IN the reducer, from current state. A
355
+ // screen-side `searchQuery + char` reads a value captured at render time, so
356
+ // keystrokes arriving faster than React re-renders all overwrite each other
357
+ // and typing "slop" leaves just "p".
358
+ | { type: "STYLES_SEARCH_APPEND"; char: string }
359
+ | { type: "STYLES_SEARCH_BACKSPACE" }
360
+ | { type: "STYLES_DATA_LOADING" }
361
+ | { type: "STYLES_DATA_SUCCESS"; snapshot: StylesSnapshot }
362
+ | { type: "STYLES_DATA_ERROR"; error: Error }
363
+ | { type: "STYLES_TOGGLE"; id: string }
364
+ | {
365
+ type: "STYLES_STATUS_SET";
366
+ status: { text: string; tone: "success" | "error" };
367
+ }
368
+ | { type: "STYLES_STATUS_CLEAR" }
369
+ | { type: "STYLES_FILL_START" }
370
+ | { type: "STYLES_FILL_END" }
371
+ | { type: "STYLES_SET_SELECTION"; ids: string[] }
372
+
300
373
  // Data refresh - triggers screens to refetch
301
374
  | { type: "DATA_REFRESH_COMPLETE" };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * config-dir.ts — where Claude Code's config lives, with a test guard.
3
+ *
4
+ * `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
5
+ * resolved per call rather than captured at import time, because a module-level
6
+ * `os.homedir()` bakes in the operator's real directory and cannot be overridden
7
+ * afterwards.
8
+ *
9
+ * The test guard
10
+ * --------------
11
+ * Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
12
+ * `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
13
+ *
14
+ * This is not defensive decoration. Once the catalog cache and the rate-limit
15
+ * cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
16
+ * any test that had not isolated its config dir began reading the operator's real
17
+ * cooldown file and fetching from their real marketplace clones. The suite went
18
+ * from 6s to 59s and started failing intermittently, because results depended on
19
+ * whether this particular machine happened to be rate-limited at that moment.
20
+ *
21
+ * A test that forgets to isolate should get nothing, not the developer's live
22
+ * state. Production is unaffected: NODE_ENV is not "test" there.
23
+ */
24
+
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ /**
29
+ * The Claude config directory, or null when a test has not chosen one.
30
+ *
31
+ * Callers that must always have a path (writing real user config) should use
32
+ * `requireClaudeConfigDir`. Callers holding regenerable state — caches — should
33
+ * treat null as "cache unavailable" and carry on.
34
+ */
35
+ export function claudeConfigDirOrNull(): string | null {
36
+ const explicit = process.env.CLAUDE_CONFIG_DIR;
37
+ if (explicit) return explicit;
38
+ if (process.env.NODE_ENV === "test") return null;
39
+ return path.join(os.homedir(), ".claude");
40
+ }
41
+
42
+ /** The Claude config directory, falling back to `~/.claude` even under test. */
43
+ export function requireClaudeConfigDir(): string {
44
+ return (
45
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
46
+ );
47
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Hand a file or URL to the operating system's default application.
3
+ *
4
+ * Why this exists rather than launching `$EDITOR`: claudeup owns the terminal.
5
+ * Suspending an OpenTUI app to give the TTY to a full-screen editor and then
6
+ * restoring raw mode, the alternate screen buffer and the cursor is a reliable
7
+ * way to leave the terminal wedged if anything goes wrong in between. Handing
8
+ * the path to the OS launches a separate process that never touches our TTY.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+
13
+ export interface Opener {
14
+ command: string;
15
+ /** Arguments that precede the target path. */
16
+ args: string[];
17
+ }
18
+
19
+ /**
20
+ * The platform's "open this with whatever handles it" command.
21
+ *
22
+ * claudeup ships darwin-arm64, darwin-x64 and linux-x64, so macOS and Linux
23
+ * both have to work — the existing `open "${url}"` call in the Skills screen is
24
+ * macOS-only and silently does nothing on Linux.
25
+ */
26
+ export function openerFor(platform: NodeJS.Platform): Opener {
27
+ if (platform === "darwin") return { command: "open", args: [] };
28
+ // `start` is a cmd.exe builtin, and its first quoted argument is the window
29
+ // title — hence the empty string, or a quoted path would be consumed as one.
30
+ if (platform === "win32")
31
+ return { command: "cmd", args: ["/c", "start", ""] };
32
+ return { command: "xdg-open", args: [] };
33
+ }
34
+
35
+ /**
36
+ * Open `target` in the default application, without blocking the TUI.
37
+ *
38
+ * The child is spawned WITHOUT a shell and the target is passed as its own
39
+ * argv entry, so a filename containing a space, quote or `$(…)` is opened
40
+ * rather than interpreted. Resolves once the process has been spawned, not
41
+ * when it exits — the viewer outlives us by design.
42
+ */
43
+ export function openInDefaultApp(
44
+ target: string,
45
+ platform: NodeJS.Platform = process.platform,
46
+ /**
47
+ * Test seam. The default is the platform's real opener; a test passes a
48
+ * command that cannot exist, so the missing-opener path is exercised
49
+ * deterministically on every OS — CI runners ship `xdg-open`, so "assume
50
+ * the opener is absent" fails exactly there, and "spawn the real one"
51
+ * launches applications on a build machine.
52
+ */
53
+ opener: Opener = openerFor(platform),
54
+ ): Promise<void> {
55
+ const { command, args } = opener;
56
+
57
+ return new Promise((resolve, reject) => {
58
+ let settled = false;
59
+ const child = spawn(command, [...args, target], {
60
+ detached: true,
61
+ stdio: "ignore",
62
+ });
63
+
64
+ child.once("error", (error: NodeJS.ErrnoException) => {
65
+ if (settled) return;
66
+ settled = true;
67
+ reject(
68
+ error.code === "ENOENT"
69
+ ? new Error(
70
+ `No way to open files here — "${command}" is not on PATH.`,
71
+ )
72
+ : error,
73
+ );
74
+ });
75
+
76
+ child.once("spawn", () => {
77
+ if (settled) return;
78
+ settled = true;
79
+ // Detach so claudeup exiting does not take the editor with it.
80
+ child.unref();
81
+ resolve();
82
+ });
83
+ });
84
+ }