claudeup 4.41.0 → 4.42.1

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.
@@ -0,0 +1,143 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { renderMarkdown } from "../ui/renderers/markdownRenderer.js";
3
+
4
+ /**
5
+ * The renderer emits OpenTUI elements, so these assert structure and counts
6
+ * rather than pixels: how many lines a construct produces, and that the raw
7
+ * markers (fences, pipes, hashes) do not survive into the output.
8
+ */
9
+
10
+ /** Flatten a rendered node tree to the text a reader would see. */
11
+ // biome-ignore lint/suspicious/noExplicitAny: walking opaque element trees
12
+ function textOf(node: any): string {
13
+ if (node === null || node === undefined || typeof node === "boolean") {
14
+ return "";
15
+ }
16
+ if (typeof node === "string" || typeof node === "number") return String(node);
17
+ if (Array.isArray(node)) return node.map(textOf).join("");
18
+ if (node.props) return textOf(node.props.children);
19
+ return "";
20
+ }
21
+
22
+ const render = (src: string, width = 80) =>
23
+ renderMarkdown(src, { width, maxLines: 500 });
24
+
25
+ describe("renderMarkdown", () => {
26
+ it("drops the # markers from a heading", () => {
27
+ const { nodes } = render("## Routing");
28
+
29
+ expect(nodes).toHaveLength(1);
30
+ expect(textOf(nodes[0])).toBe("Routing");
31
+ });
32
+
33
+ it("drops the fence markers and keeps the code", () => {
34
+ const { nodes } = render(
35
+ ["```bash", "which mnemex && mnemex --version", "```"].join("\n"),
36
+ );
37
+
38
+ const text = nodes.map(textOf).join("\n");
39
+ expect(text).not.toContain("```");
40
+ expect(text).toContain("which mnemex && mnemex --version");
41
+ expect(text).toContain("bash");
42
+ });
43
+
44
+ it("turns a pipe table into aligned columns with a rule, no pipes", () => {
45
+ const { nodes } = render(
46
+ [
47
+ "| Mode | Keywords |",
48
+ "|------|----------|",
49
+ "| Bug | debug, error |",
50
+ "| Test | coverage |",
51
+ ].join("\n"),
52
+ );
53
+
54
+ // header + rule + 2 body rows
55
+ expect(nodes).toHaveLength(4);
56
+ const text = nodes.map(textOf).join("\n");
57
+ expect(text).not.toContain("|");
58
+ expect(text).toContain("Mode");
59
+ expect(text).toContain("debug, error");
60
+ expect(textOf(nodes[1])).toMatch(/^\s*─+\s+─+$/);
61
+ });
62
+
63
+ it("narrows the widest table column instead of overflowing the pane", () => {
64
+ const { nodes } = render(
65
+ ["| Mode | Notes |", "|---|---|", `| Bug | ${"x".repeat(120)} |`].join(
66
+ "\n",
67
+ ),
68
+ 40,
69
+ );
70
+
71
+ for (const node of nodes) {
72
+ expect(textOf(node).length).toBeLessThanOrEqual(41);
73
+ }
74
+ expect(nodes.map(textOf).join("")).toContain("…");
75
+ });
76
+
77
+ it("strips inline markers inside table cells", () => {
78
+ const { nodes } = render(
79
+ [
80
+ "| Mode | Primary Commands |",
81
+ "|---|---|",
82
+ "| Bug | `context`, `callers` |",
83
+ "| Arch | **map** |",
84
+ ].join("\n"),
85
+ );
86
+
87
+ const text = nodes.map(textOf).join("\n");
88
+ expect(text).toContain("context, callers");
89
+ expect(text).not.toContain("`");
90
+ expect(text).not.toContain("**");
91
+ });
92
+
93
+ it("draws a rule for --- rather than printing hyphens as text", () => {
94
+ const { nodes } = render("---", 30);
95
+
96
+ expect(textOf(nodes[0])).toMatch(/^─+$/);
97
+ });
98
+
99
+ it("renders a list bullet and keeps ordered markers", () => {
100
+ const { nodes } = render(["- first", "2. second"].join("\n"));
101
+
102
+ expect(textOf(nodes[0])).toContain("•");
103
+ expect(textOf(nodes[0])).toContain("first");
104
+ expect(textOf(nodes[1])).toContain("2.");
105
+ });
106
+
107
+ it("strips inline code backticks and bold asterisks", () => {
108
+ const { nodes } = render("Run `bun test` and **stop**.");
109
+
110
+ const text = textOf(nodes[0]);
111
+ expect(text).toBe("Run bun test and stop.");
112
+ });
113
+
114
+ it("does not read bold markers that sit inside a code span", () => {
115
+ const { nodes } = render("Use `a**b` here.");
116
+
117
+ expect(textOf(nodes[0])).toBe("Use a**b here.");
118
+ });
119
+
120
+ it("marks a blockquote with a bar instead of a > character", () => {
121
+ const { nodes } = render("> With a sufficient number of users");
122
+
123
+ const text = textOf(nodes[0]);
124
+ expect(text).toContain("│");
125
+ expect(text).not.toContain(">");
126
+ });
127
+
128
+ it("reports the lines it stopped short of", () => {
129
+ const long = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
130
+
131
+ const { nodes, withheld } = renderMarkdown(long, {
132
+ width: 80,
133
+ maxLines: 10,
134
+ });
135
+
136
+ expect(nodes).toHaveLength(10);
137
+ expect(withheld).toBe(20);
138
+ });
139
+
140
+ it("withholds nothing when the whole document fits", () => {
141
+ expect(render("one\ntwo").withheld).toBe(0);
142
+ });
143
+ });
@@ -58,6 +58,10 @@ const COMMUNITY_MARKETPLACES: ReadonlyArray<[name: string, repo: string]> = [
58
58
  ["addy-agent-skills", "addyosmani/agent-skills"],
59
59
  ["humanizer", "blader/humanizer"],
60
60
  ["opendesign", "manalkaff/opendesign"],
61
+ // A fork, not the original: cursor/plugins keeps every manifest under
62
+ // `.cursor-plugin/` and carries no `.claude-plugin/marketplace.json`, so
63
+ // Claude Code cannot resolve it at all.
64
+ ["open-pstack", "ericlitman/open-pstack"],
61
65
  ];
62
66
 
63
67
  describe("community marketplaces", () => {
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ checkoutDirName,
5
+ checkoutPath,
6
+ ensureCheckout,
7
+ } from "../services/plugin-checkout.js";
8
+
9
+ /**
10
+ * A checkout is a read-only shallow clone claudeup owns, used so that browsing
11
+ * what a plugin contains never requires installing it and never spends the
12
+ * GitHub API budget (60 requests an hour, unauthenticated, shared across every
13
+ * repo on screen).
14
+ */
15
+ describe("checkoutDirName", () => {
16
+ it("flattens owner/repo into one filesystem-safe segment", () => {
17
+ expect(checkoutDirName("addyosmani/agent-skills")).toBe(
18
+ "addyosmani__agent-skills",
19
+ );
20
+ expect(checkoutDirName("MadAppGang/magus")).toBe("MadAppGang__magus");
21
+ });
22
+
23
+ it("never yields a name containing a path separator", () => {
24
+ for (const repo of [
25
+ "a/b",
26
+ "../../etc/passwd",
27
+ "owner/repo.git",
28
+ "weird name/with spaces",
29
+ ]) {
30
+ const name = checkoutDirName(repo);
31
+ expect(name).not.toContain("/");
32
+ expect(name).not.toContain("\\");
33
+ // A traversal attempt must not survive as one: `..` collapses into the
34
+ // same separator run as everything else, so the result cannot escape
35
+ // the checkout root.
36
+ expect(path.basename(name)).toBe(name);
37
+ }
38
+ });
39
+
40
+ it("keeps dots that are part of a real repo name", () => {
41
+ expect(checkoutDirName("owner/repo.js")).toBe("owner__repo.js");
42
+ });
43
+ });
44
+
45
+ describe("checkoutPath", () => {
46
+ it("puts every checkout under one root, outside Claude Code's own dirs", () => {
47
+ const a = checkoutPath("owner/one");
48
+ const b = checkoutPath("owner/two");
49
+
50
+ expect(path.dirname(a)).toBe(path.dirname(b));
51
+ expect(path.dirname(a)).toContain("claudeup-checkouts");
52
+ // Claude Code owns `plugins/`; a checkout must not look like installed
53
+ // state, or a reader could mistake it for something Claude Code loads.
54
+ expect(a).not.toContain(`${path.sep}plugins${path.sep}`);
55
+ });
56
+ });
57
+
58
+ describe("ensureCheckout", () => {
59
+ it("refuses to clone from a test run", async () => {
60
+ // Not a precaution. A test that only meant to exercise the tree-API path
61
+ // shallow-cloned a real GitHub repo, because stubbing `globalThis.fetch`
62
+ // does not stop `git`.
63
+ expect(process.env.NODE_ENV).toBe("test");
64
+ expect(await ensureCheckout("blader/humanizer")).toBeNull();
65
+ });
66
+ });
@@ -0,0 +1,371 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import type { PluginInfo } from "../services/plugin-manager.js";
3
+ import {
4
+ fetchPluginContents,
5
+ splitSkillDocument,
6
+ } from "../services/skills-manager.js";
7
+ import type { Marketplace } from "../types/index.js";
8
+ import {
9
+ buildPluginBrowserItems,
10
+ pluginContentsRepo,
11
+ } from "../ui/adapters/pluginsAdapter.js";
12
+ import type { PluginContentsEntry } from "../ui/state/types.js";
13
+
14
+ // ─── fetchPluginContents ─────────────────────────────────────────────────────
15
+
16
+ const realFetch = globalThis.fetch;
17
+
18
+ /**
19
+ * Serve one canned git-tree response. `fetchGitTree` caches per repo for the
20
+ * life of the process, so every test must use a repo name of its own.
21
+ */
22
+ function stubTree(paths: string[]): void {
23
+ globalThis.fetch = (async () =>
24
+ new Response(
25
+ JSON.stringify({
26
+ sha: "deadbeef",
27
+ truncated: false,
28
+ tree: paths.map((path) => ({
29
+ path,
30
+ mode: "100644",
31
+ type: "blob",
32
+ sha: `sha-${path}`,
33
+ })),
34
+ }),
35
+ { status: 200, headers: { ETag: '"x"' } },
36
+ )) as unknown as typeof fetch;
37
+ }
38
+
39
+ afterEach(() => {
40
+ globalThis.fetch = realFetch;
41
+ });
42
+
43
+ describe("fetchPluginContents", () => {
44
+ it("counts a grouped skills/ tree once per skill, not once per group", async () => {
45
+ stubTree([
46
+ "README.md",
47
+ "skills/engineering/code-review/SKILL.md",
48
+ "skills/engineering/tdd/SKILL.md",
49
+ "skills/writing/prd/SKILL.md",
50
+ ]);
51
+
52
+ const skills = await fetchPluginContents("fake/grouped", "./");
53
+
54
+ expect(skills.map((s) => s.name)).toEqual(["code-review", "tdd", "prd"]);
55
+ expect(skills.every((s) => s.kind === "skill")).toBe(true);
56
+ expect(skills[0].group).toBe("engineering");
57
+ expect(skills[2].group).toBe("writing");
58
+ });
59
+
60
+ it("ignores harness mirrors and translated docs trees", async () => {
61
+ // everything-claude-code's real shape: 286 skills under skills/, mirrored
62
+ // into four harness directories and six docs/<lang>/ trees for 898 files.
63
+ stubTree([
64
+ "skills/api-design/SKILL.md",
65
+ "skills/agent-sort/SKILL.md",
66
+ ".agents/skills/api-design/SKILL.md",
67
+ ".kiro/skills/api-design/SKILL.md",
68
+ ".cursor/skills/api-design/SKILL.md",
69
+ "docs/ja-JP/skills/api-design/SKILL.md",
70
+ "docs/zh-CN/skills/api-design/SKILL.md",
71
+ ]);
72
+
73
+ const skills = await fetchPluginContents("fake/mirrored", "./");
74
+
75
+ expect(skills.map((s) => s.name)).toEqual(["agent-sort", "api-design"]);
76
+ });
77
+
78
+ it("finds a plugin that is one skill at its root", async () => {
79
+ // blader/humanizer: a root SKILL.md and no skills/ directory at all.
80
+ stubTree([".claude-plugin/plugin.json", "SKILL.md", "README.md"]);
81
+
82
+ const skills = await fetchPluginContents("blader/humanizer", "./");
83
+
84
+ expect(skills).toHaveLength(1);
85
+ expect(skills[0].name).toBe("humanizer");
86
+ expect(skills[0].repoPath).toBe("SKILL.md");
87
+ });
88
+
89
+ it("scopes to one plugin's subtree in a multi-plugin marketplace", async () => {
90
+ stubTree([
91
+ "plugins/dev/skills/frontend/design-system/SKILL.md",
92
+ "plugins/dev/skills/backend/db-branching/SKILL.md",
93
+ "plugins/seo/skills/audit/SKILL.md",
94
+ ]);
95
+
96
+ const skills = await fetchPluginContents("fake/multi", "./plugins/dev");
97
+
98
+ expect(skills.map((s) => s.name)).toEqual([
99
+ "db-branching",
100
+ "design-system",
101
+ ]);
102
+ });
103
+
104
+ it("still lists commands and agents for a plugin with no skills", async () => {
105
+ stubTree(["agents/reviewer.md", "commands/go.md"]);
106
+
107
+ const found = await fetchPluginContents("fake/no-skills", "./");
108
+
109
+ expect(found.filter((c) => c.kind === "skill")).toEqual([]);
110
+ expect(found.map((c) => `${c.kind}:${c.name}`)).toEqual([
111
+ "command:go",
112
+ "agent:reviewer",
113
+ ]);
114
+ });
115
+ });
116
+
117
+ // ─── splitSkillDocument ──────────────────────────────────────────────────────
118
+
119
+ describe("splitSkillDocument", () => {
120
+ it("keeps frontmatter in file order so description stays near the top", () => {
121
+ const { frontmatter } = splitSkillDocument(
122
+ [
123
+ "---",
124
+ "name: code-review",
125
+ "description: Reviews a diff for correctness",
126
+ "allowed-tools: Read, Grep",
127
+ "---",
128
+ "",
129
+ "# Code review",
130
+ ].join("\n"),
131
+ );
132
+
133
+ expect(frontmatter.map((f) => f.key)).toEqual([
134
+ "name",
135
+ "description",
136
+ "allowed-tools",
137
+ ]);
138
+ expect(frontmatter[1].value).toBe("Reviews a diff for correctness");
139
+ });
140
+
141
+ it("flattens both list shapes to one display string", () => {
142
+ const { frontmatter } = splitSkillDocument(
143
+ [
144
+ "---",
145
+ 'tags: ["tdd", "testing"]',
146
+ "allowed-tools:",
147
+ " - Read",
148
+ " - Bash",
149
+ "---",
150
+ "body",
151
+ ].join("\n"),
152
+ );
153
+
154
+ expect(frontmatter[0]).toEqual({ key: "tags", value: "tdd, testing" });
155
+ expect(frontmatter[1]).toEqual({
156
+ key: "allowed-tools",
157
+ value: "Read, Bash",
158
+ });
159
+ });
160
+
161
+ it("separates the body and counts its lines", () => {
162
+ const doc = splitSkillDocument(
163
+ ["---", "name: x", "---", "", "# Title", "", "Body text.", ""].join("\n"),
164
+ );
165
+
166
+ expect(doc.body).toBe("# Title\n\nBody text.");
167
+ expect(doc.bodyLines).toBe(3);
168
+ });
169
+
170
+ it("treats a file with no frontmatter as all body", () => {
171
+ const doc = splitSkillDocument("# Just markdown\n\nNo frontmatter here.");
172
+
173
+ expect(doc.frontmatter).toEqual([]);
174
+ expect(doc.body).toBe("# Just markdown\n\nNo frontmatter here.");
175
+ });
176
+
177
+ it("reports an empty body rather than a phantom line", () => {
178
+ const doc = splitSkillDocument("---\nname: x\ndescription: y\n---\n");
179
+
180
+ expect(doc.body).toBe("");
181
+ expect(doc.bodyLines).toBe(0);
182
+ });
183
+ });
184
+
185
+ // ─── buildPluginBrowserItems ─────────────────────────────────────────────────
186
+
187
+ const marketplace: Marketplace = {
188
+ name: "mattpocock",
189
+ displayName: "Matt Pocock",
190
+ source: { source: "github", repo: "mattpocock/skills" },
191
+ description: "Agent skills for real engineering",
192
+ featured: true,
193
+ };
194
+
195
+ function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
196
+ return {
197
+ id: "mattpocock-skills@mattpocock",
198
+ name: "mattpocock-skills",
199
+ version: "1.0.0",
200
+ description: "Matt Pocock's agent skills",
201
+ marketplace: "mattpocock",
202
+ marketplaceDisplay: "Matt Pocock",
203
+ enabled: false,
204
+ source: "./",
205
+ ...overrides,
206
+ };
207
+ }
208
+
209
+ function build(
210
+ plugin: PluginInfo,
211
+ expandedPlugins: Set<string>,
212
+ pluginContents: Map<string, PluginContentsEntry> = new Map(),
213
+ ) {
214
+ return buildPluginBrowserItems({
215
+ marketplaces: [marketplace],
216
+ plugins: [plugin],
217
+ collapsedMarketplaces: new Set(),
218
+ expandedPlugins,
219
+ pluginContents,
220
+ });
221
+ }
222
+
223
+ describe("buildPluginBrowserItems component rows", () => {
224
+ it("marks a plugin expandable and emits no component rows while collapsed", () => {
225
+ const items = build(makePlugin(), new Set());
226
+
227
+ expect(items.map((i) => i.kind)).toEqual(["category", "plugin"]);
228
+ const row = items[1];
229
+ expect(row.kind === "plugin" && row.isExpandable).toBe(true);
230
+ expect(row.kind === "plugin" && row.isExpanded).toBe(false);
231
+ });
232
+
233
+ it("emits one row per component when expanded, of every kind", () => {
234
+ const items = build(
235
+ makePlugin(),
236
+ new Set(["mattpocock-skills@mattpocock"]),
237
+ new Map([
238
+ [
239
+ "mattpocock-skills@mattpocock",
240
+ {
241
+ status: "success",
242
+ components: [
243
+ {
244
+ kind: "skill",
245
+ name: "code-review",
246
+ repoPath: "skills/engineering/code-review/SKILL.md",
247
+ group: "engineering",
248
+ },
249
+ { kind: "command", name: "go", repoPath: "commands/go.md" },
250
+ { kind: "agent", name: "critic", repoPath: "agents/critic.md" },
251
+ { kind: "mcp", name: "mnemex", repoPath: ".mcp.json" },
252
+ ],
253
+ } satisfies PluginContentsEntry,
254
+ ],
255
+ ]),
256
+ );
257
+
258
+ expect(items.map((i) => i.kind)).toEqual([
259
+ "category",
260
+ "plugin",
261
+ "component",
262
+ "component",
263
+ "component",
264
+ "component",
265
+ ]);
266
+ expect(items.slice(2).map((i) => i.label)).toEqual([
267
+ "code-review",
268
+ "go",
269
+ "critic",
270
+ "mnemex",
271
+ ]);
272
+ });
273
+
274
+ it("gives an MCP-only plugin a row instead of rendering it as empty", () => {
275
+ // mnemex: no skills, no commands, no agents, one server. It used to show
276
+ // nothing at all, which read as a plugin that ships nothing.
277
+ const items = build(
278
+ makePlugin({ id: "mnemex@magus", name: "mnemex" }),
279
+ new Set(["mnemex@magus"]),
280
+ new Map([
281
+ [
282
+ "mnemex@magus",
283
+ {
284
+ status: "success",
285
+ components: [
286
+ {
287
+ kind: "mcp",
288
+ name: "mnemex",
289
+ repoPath: ".mcp.json",
290
+ mcpCommand: "mnemex --mcp",
291
+ },
292
+ ],
293
+ } satisfies PluginContentsEntry,
294
+ ],
295
+ ]),
296
+ );
297
+
298
+ expect(items[2].kind).toBe("component");
299
+ expect(items[2].label).toBe("mnemex");
300
+ });
301
+
302
+ it("shows a loading row before the listing arrives", () => {
303
+ const items = build(
304
+ makePlugin(),
305
+ new Set(["mattpocock-skills@mattpocock"]),
306
+ );
307
+
308
+ expect(items[2].kind).toBe("contents-status");
309
+ expect(items[2].kind === "contents-status" && items[2].status).toBe(
310
+ "loading",
311
+ );
312
+ });
313
+
314
+ it("surfaces a failed listing instead of rendering an empty expansion", () => {
315
+ const items = build(
316
+ makePlugin(),
317
+ new Set(["mattpocock-skills@mattpocock"]),
318
+ new Map([
319
+ [
320
+ "mattpocock-skills@mattpocock",
321
+ {
322
+ status: "error",
323
+ error: "GitHub API rate limit exceeded",
324
+ } satisfies PluginContentsEntry,
325
+ ],
326
+ ]),
327
+ );
328
+
329
+ expect(items[2].kind === "contents-status" && items[2].status).toBe(
330
+ "error",
331
+ );
332
+ expect(items[2].kind === "contents-status" && items[2].error).toBe(
333
+ "GitHub API rate limit exceeded",
334
+ );
335
+ });
336
+
337
+ it("does not offer expansion for an orphaned plugin", () => {
338
+ const items = build(makePlugin({ isOrphaned: true }), new Set());
339
+
340
+ expect(items[1].kind === "plugin" && items[1].isExpandable).toBe(false);
341
+ });
342
+
343
+ it("does not offer expansion when the marketplace has no repo to read", () => {
344
+ const items = buildPluginBrowserItems({
345
+ marketplaces: [
346
+ { ...marketplace, source: { source: "directory", repo: undefined } },
347
+ ],
348
+ plugins: [makePlugin()],
349
+ collapsedMarketplaces: new Set(),
350
+ expandedPlugins: new Set(),
351
+ pluginContents: new Map(),
352
+ });
353
+
354
+ expect(items[1].kind === "plugin" && items[1].isExpandable).toBe(false);
355
+ });
356
+ });
357
+
358
+ describe("pluginContentsRepo", () => {
359
+ it("prefers a plugin's own repo over its marketplace's", () => {
360
+ const plugin = makePlugin({ sourceRepo: "addyosmani/agent-skills" });
361
+ expect(pluginContentsRepo(plugin, marketplace)).toBe(
362
+ "addyosmani/agent-skills",
363
+ );
364
+ });
365
+
366
+ it("falls back to the marketplace repo", () => {
367
+ expect(pluginContentsRepo(makePlugin(), marketplace)).toBe(
368
+ "mattpocock/skills",
369
+ );
370
+ });
371
+ });
@@ -161,6 +161,28 @@ export const defaultMarketplaces: Marketplace[] = [
161
161
  "Designs and redesigns frontend UI and marketing graphics, routing to specialist skills per artifact type",
162
162
  featured: true,
163
163
  },
164
+ // A community port, deliberately, because the original cannot be installed.
165
+ // pstack is published by Cursor in cursor/plugins, whose manifests all live
166
+ // under `.cursor-plugin/` — measured over the whole tree: 61 `.cursor-plugin`
167
+ // files, 0 `.claude-plugin` files. Claude Code reads
168
+ // `.claude-plugin/marketplace.json` only, so `claude plugin marketplace add
169
+ // cursor/plugins` cannot resolve it. Same reason github/spec-kit is absent.
170
+ //
171
+ // Of the ports that relocate the manifest, this is the most starred: 95
172
+ // against pstack-claude's 94. backnotprop/pstack has more (140) but carries
173
+ // no Claude manifest either, and peadar/pstack (261) is an unrelated C
174
+ // stack-trace tool.
175
+ {
176
+ name: "open-pstack",
177
+ displayName: "pstack",
178
+ source: {
179
+ source: "github",
180
+ repo: "ericlitman/open-pstack",
181
+ },
182
+ description:
183
+ "Rigorous, parallelizable agent workflows that favour going deep before going fast. Community port of Cursor's pstack",
184
+ featured: true,
185
+ },
164
186
  {
165
187
  name: "claude-code-plugins",
166
188
  displayName: "Anthropic Deprecated",
package/src/main.tsx CHANGED
@@ -6,6 +6,7 @@ import { createRoot } from "@opentui/react";
6
6
  // a dynamic require("../package.json") is not resolvable inside the bunfs root.
7
7
  import pkg from "../package.json";
8
8
  import { route } from "./cli/router.js";
9
+ import { loadProjectDotenv } from "./services/dotenv.js";
9
10
  import { App } from "./ui/App.js";
10
11
  import { setThemeMode } from "./ui/theme-mode.js";
11
12
 
@@ -17,6 +18,23 @@ export const VERSION = (pkg as { version: string }).version;
17
18
  async function main(): Promise<void> {
18
19
  const args = process.argv.slice(2);
19
20
 
21
+ // Load the project's .env ourselves. Bun's standalone autoload is compiled OFF
22
+ // (scripts/build-binaries.ts) because it killed claudeup outright wherever
23
+ // .env was a symlink to a FIFO — the shape 1Password uses — exiting 1 with no
24
+ // output at all. services/dotenv.ts does the same job as an ordinary guarded
25
+ // read: regular files only, and every failure reported rather than fatal.
26
+ try {
27
+ for (const warning of loadProjectDotenv().warnings) {
28
+ console.warn(`claudeup: ${warning}`);
29
+ }
30
+ } catch (error) {
31
+ // Belt and braces. loadProjectDotenv is total, but no future edit to it may
32
+ // ever be allowed to stop claudeup from starting.
33
+ console.warn(
34
+ `claudeup: .env could not be loaded: ${error instanceof Error ? error.message : String(error)}`,
35
+ );
36
+ }
37
+
20
38
  // Dispatch non-interactive subcommands (claude, update, install, …) and
21
39
  // top-level flags (--version/--help). A bare invocation falls through to
22
40
  // the interactive TUI below.