claudeup 4.33.0 → 4.35.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.33.0",
3
+ "version": "4.35.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.33.0",
68
- "claudeup-darwin-x64": "4.33.0",
69
- "claudeup-linux-x64": "4.33.0"
67
+ "claudeup-darwin-arm64": "4.35.0",
68
+ "claudeup-darwin-x64": "4.35.0",
69
+ "claudeup-linux-x64": "4.35.0"
70
70
  }
71
71
  }
@@ -20,8 +20,17 @@ let configDir: string;
20
20
  let prevConfigDir: string | undefined;
21
21
  let repo: string;
22
22
 
23
+ // commit.gpgsign=false isolates the developer's git config. With signing on
24
+ // (1Password's SSH signer, for instance) every commit here calls that signer,
25
+ // and because this helper ignores the exit status a failure is silent: no
26
+ // commit is created, so drift is never detected and the assertions fail with a
27
+ // bare `false` that points nowhere near the real cause. CI has no signing
28
+ // config, so this only ever broke locally.
23
29
  const git = (args: string[]) =>
24
- spawnSync("git", args, { cwd: repo, encoding: "utf-8" });
30
+ spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
31
+ cwd: repo,
32
+ encoding: "utf-8",
33
+ });
25
34
 
26
35
  const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
27
36
 
@@ -9,7 +9,19 @@ import type { ResolvedManifest } from "../types/gitignore";
9
9
  function git(cwd: string, ...args: string[]): string {
10
10
  // core.excludesFile=/dev/null bypasses the developer's global gitignore
11
11
  // so the test environment doesn't leak into the result.
12
- const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
12
+ //
13
+ // commit.gpgsign=false is the same isolation, one step further: a developer
14
+ // with signed commits makes every `git commit` here call their signer. With
15
+ // 1Password's SSH agent that means a prompt or an agent error, so these tests
16
+ // failed or timed out locally for reasons that have nothing to do with the
17
+ // code under test. CI has no signing config, so it only ever broke locally.
18
+ const r = spawnSync("git", [
19
+ "-c",
20
+ "core.excludesFile=/dev/null",
21
+ "-c",
22
+ "commit.gpgsign=false",
23
+ ...args,
24
+ ], {
13
25
  cwd,
14
26
  encoding: "utf8",
15
27
  });
@@ -216,3 +216,74 @@ describe("resolveAllProfiles — union", () => {
216
216
  expect(union.conflicts?.some((c) => c.includes("claudish"))).toBe(true);
217
217
  });
218
218
  });
219
+
220
+ describe("marketplaces derived from plugin ids", () => {
221
+ const noBins = { binResolver: fakeBinResolver({}) };
222
+
223
+ test("a plugin id registers the marketplace it names", async () => {
224
+ const manifest = manifestOf({
225
+ solo: { name: "Solo", plugins: { "dev@magus": "latest" } },
226
+ });
227
+ const c = await resolveProfile(manifest, "solo", noBins);
228
+ expect(c.marketplaces.magus).toEqual({
229
+ source: "github",
230
+ repo: "MadAppGang/magus",
231
+ });
232
+ });
233
+
234
+ test("extends alone is enough — the failure this fixes", async () => {
235
+ // A profile that only extends a predefined one inherits plugins like
236
+ // dev@magus and previously NO marketplaces, so install resolved every
237
+ // plugin against a marketplace that was never registered.
238
+ const manifest = manifestOf({
239
+ team: { name: "Team", extends: "developer-essentials" },
240
+ });
241
+ const c = await resolveProfile(manifest, "team", noBins);
242
+ expect(Object.keys(c.plugins).length).toBeGreaterThan(0);
243
+ expect(c.marketplaces.magus?.repo).toBe("MadAppGang/magus");
244
+ });
245
+
246
+ test("each distinct channel is derived, not just the first", async () => {
247
+ const manifest = manifestOf({
248
+ mixed: {
249
+ name: "Mixed",
250
+ plugins: {
251
+ "dev@magus": "latest",
252
+ "seo@magus-marketing": "latest",
253
+ "autolinear@magus-alpha": "latest",
254
+ },
255
+ },
256
+ });
257
+ const c = await resolveProfile(manifest, "mixed", noBins);
258
+ expect(c.marketplaces["magus-marketing"]?.repo).toBe("MadAppGang/magus-marketing");
259
+ expect(c.marketplaces["magus-alpha"]?.repo).toBe("MadAppGang/magus-alpha");
260
+ });
261
+
262
+ test("an explicit declaration is never overridden", async () => {
263
+ const manifest = manifestOf({
264
+ pinned: {
265
+ name: "Pinned",
266
+ marketplaces: { magus: { source: "github", repo: "MadAppGang/magus-fork" } },
267
+ plugins: { "dev@magus": "latest" },
268
+ },
269
+ });
270
+ const c = await resolveProfile(manifest, "pinned", noBins);
271
+ expect(c.marketplaces.magus?.repo).toBe("MadAppGang/magus-fork");
272
+ });
273
+
274
+ test("an unknown marketplace is left for the manifest to declare", async () => {
275
+ const manifest = manifestOf({
276
+ third: { name: "Third", plugins: { "thing@someones-marketplace": "latest" } },
277
+ });
278
+ const c = await resolveProfile(manifest, "third", noBins);
279
+ expect(c.marketplaces["someones-marketplace"]).toBeUndefined();
280
+ });
281
+
282
+ test("a bare plugin id contributes no marketplace", async () => {
283
+ const manifest = manifestOf({
284
+ bare: { name: "Bare", plugins: { local: "latest" } },
285
+ });
286
+ const c = await resolveProfile(manifest, "bare", noBins);
287
+ expect(Object.keys(c.marketplaces)).toHaveLength(0);
288
+ });
289
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ resolveSkillFilePath,
5
+ selectSkillFiles,
6
+ } from "../services/skills-manager.js";
7
+
8
+ /**
9
+ * Installing a skill used to write exactly one file, SKILL.md, and drop
10
+ * everything beside it. Measured against the real repos: audit-website lost
11
+ * `references/OUTPUT-FORMAT.md` while its SKILL.md still linked to it,
12
+ * systematic-debugging lost `find-polluter.sh` and 9 more, and
13
+ * remotion-best-practices lost 136 files. The installed skill looked fine and
14
+ * had dangling links.
15
+ *
16
+ * These cover the two pieces of that fix that are pure: choosing which files
17
+ * belong to a skill, and refusing to write outside its directory.
18
+ */
19
+
20
+ const blob = (p: string) => ({ path: p, type: "blob" });
21
+ const tree = (p: string) => ({ path: p, type: "tree" });
22
+
23
+ describe("selectSkillFiles", () => {
24
+ test("returns paths relative to the skill directory", () => {
25
+ expect(
26
+ selectSkillFiles(
27
+ [
28
+ blob("skills/audit-website/SKILL.md"),
29
+ blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
30
+ blob("skills/audit-website/assets/icon-small.svg"),
31
+ ],
32
+ "skills/audit-website",
33
+ ),
34
+ ).toEqual(["SKILL.md", "references/OUTPUT-FORMAT.md", "assets/icon-small.svg"]);
35
+ });
36
+
37
+ test("REGRESSION: a sibling with a shared name prefix is excluded", () => {
38
+ // Without the trailing slash on the prefix, "skills/audit" also matches
39
+ // "skills/audit-website/..." and dumps a second skill's files into the
40
+ // first one's directory.
41
+ expect(
42
+ selectSkillFiles(
43
+ [
44
+ blob("skills/audit/SKILL.md"),
45
+ blob("skills/audit-website/SKILL.md"),
46
+ blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
47
+ ],
48
+ "skills/audit",
49
+ ),
50
+ ).toEqual(["SKILL.md"]);
51
+ });
52
+
53
+ test("tree entries are skipped — only blobs are downloadable", () => {
54
+ expect(
55
+ selectSkillFiles(
56
+ [
57
+ tree("skills/x/references"),
58
+ blob("skills/x/SKILL.md"),
59
+ blob("skills/x/references/a.md"),
60
+ ],
61
+ "skills/x",
62
+ ),
63
+ ).toEqual(["SKILL.md", "references/a.md"]);
64
+ });
65
+
66
+ test("files outside the skill directory are excluded", () => {
67
+ expect(
68
+ selectSkillFiles(
69
+ [blob("README.md"), blob("other/SKILL.md"), blob("skills/x/SKILL.md")],
70
+ "skills/x",
71
+ ),
72
+ ).toEqual(["SKILL.md"]);
73
+ });
74
+
75
+ test("a trailing slash on the skill path does not break matching", () => {
76
+ expect(
77
+ selectSkillFiles([blob("skills/x/SKILL.md")], "skills/x/"),
78
+ ).toEqual(["SKILL.md"]);
79
+ });
80
+
81
+ test("a skill directory with no files yields nothing", () => {
82
+ expect(selectSkillFiles([blob("skills/other/SKILL.md")], "skills/x")).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("resolveSkillFilePath", () => {
87
+ const dir = path.join(path.sep, "tmp", "skills", "demo");
88
+
89
+ test("resolves a plain relative path inside the install directory", () => {
90
+ expect(resolveSkillFilePath(dir, "SKILL.md")).toBe(
91
+ path.join(dir, "SKILL.md"),
92
+ );
93
+ });
94
+
95
+ test("resolves a nested path", () => {
96
+ expect(resolveSkillFilePath(dir, "references/OUTPUT-FORMAT.md")).toBe(
97
+ path.join(dir, "references", "OUTPUT-FORMAT.md"),
98
+ );
99
+ });
100
+
101
+ test("SECURITY: rejects paths escaping via ..", () => {
102
+ // These paths come from a remote repository and are untrusted input to a
103
+ // filesystem write.
104
+ expect(resolveSkillFilePath(dir, "../evil.md")).toBeNull();
105
+ expect(resolveSkillFilePath(dir, "../../.bashrc")).toBeNull();
106
+ expect(resolveSkillFilePath(dir, "refs/../../../evil.md")).toBeNull();
107
+ });
108
+
109
+ test("SECURITY: rejects absolute paths", () => {
110
+ expect(resolveSkillFilePath(dir, path.join(path.sep, "etc", "passwd"))).toBeNull();
111
+ });
112
+
113
+ test("rejects an empty path", () => {
114
+ expect(resolveSkillFilePath(dir, "")).toBeNull();
115
+ });
116
+
117
+ test("a .. that stays inside the directory is allowed", () => {
118
+ expect(resolveSkillFilePath(dir, "refs/../SKILL.md")).toBe(
119
+ path.join(dir, "SKILL.md"),
120
+ );
121
+ });
122
+
123
+ test("REGRESSION: a sibling directory sharing the name prefix is rejected", () => {
124
+ // path.startsWith without a separator would accept /tmp/skills/demo-evil
125
+ // as being inside /tmp/skills/demo.
126
+ expect(resolveSkillFilePath(dir, "../demo-evil/x.md")).toBeNull();
127
+ });
128
+ });
@@ -53,6 +53,19 @@ export const defaultMarketplaces: Marketplace[] = [
53
53
  owned: true, // Ours: always listed even when uninstalled, sorted to the top
54
54
  // Not featured: a niche channel, so keep it collapsed by default.
55
55
  },
56
+ {
57
+ name: "magus-alpha",
58
+ displayName: "Magus Alpha",
59
+ source: {
60
+ source: "github",
61
+ repo: "MadAppGang/magus-alpha",
62
+ },
63
+ description:
64
+ "Experimental plugins with evolving interfaces — may change or be withdrawn without notice",
65
+ official: false,
66
+ owned: true, // Ours: always listed even when uninstalled, sorted to the top
67
+ // Not featured: experimental, so keep it collapsed by default.
68
+ },
56
69
  {
57
70
  name: "claude-plugins-official",
58
71
  displayName: "Anthropic Official",
@@ -32,11 +32,17 @@ const MEGA_REPOS = new Set([
32
32
  ]);
33
33
 
34
34
  /** Skill-dump repos: large collections where stars reflect quantity, not quality. */
35
+ // Renamed repos are listed under BOTH names on purpose. The curated catalog
36
+ // serves the current name while older cached copies and this file's fallback
37
+ // list still carry the old one; dropping either silently reclassifies the repo
38
+ // as "dedicated" and its stars start reading as a quality signal again.
35
39
  const SKILL_DUMP_REPOS = new Set([
36
40
  "affaan-m/everything-claude-code",
37
41
  "openclaw/openclaw",
38
42
  "jh941213/my-claude-code-asset",
43
+ "jh941213/my-cc-harness", // renamed from my-claude-code-asset
39
44
  "inferen-sh/skills",
45
+ "inference-sh/skills", // renamed from inferen-sh
40
46
  ]);
41
47
 
42
48
  /**
@@ -17,6 +17,7 @@ import type {
17
17
  ResolvedClosure,
18
18
  } from "../types/index.js";
19
19
  import { cliTools } from "../data/cli-tools.js";
20
+ import { getMarketplaceByName } from "../data/marketplaces.js";
20
21
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
21
22
  import { resolvePluginBinRequirements } from "./plugin-requires.js";
22
23
 
@@ -147,6 +148,36 @@ async function resolveBins(
147
148
  }
148
149
 
149
150
  /** Resolve a single profile (applying `extends`) into an install closure. */
151
+ /**
152
+ * Add the marketplace every plugin id already names.
153
+ *
154
+ * A plugin id carries its marketplace — `dev@magus`, `seo@magus-marketing` —
155
+ * so requiring the manifest to ALSO declare that marketplace is redundant, and
156
+ * forgetting to is silent: the plugin resolves against a marketplace that was
157
+ * never registered. `extends` made this the default failure, because it
158
+ * inherits plugins from a predefined profile and no marketplaces at all.
159
+ *
160
+ * Only marketplaces claudeup already knows are derived, since a repo is needed
161
+ * to register one. An unknown suffix is left alone for the manifest to declare.
162
+ * An explicit entry always wins: deriving must never override a pin.
163
+ */
164
+ function withDerivedMarketplaces(
165
+ declared: ProfileManifestEntry["marketplaces"],
166
+ plugins: Record<string, string>,
167
+ ): NonNullable<ProfileManifestEntry["marketplaces"]> {
168
+ const out = { ...(declared ?? {}) };
169
+ for (const id of Object.keys(plugins)) {
170
+ const at = id.lastIndexOf("@");
171
+ if (at <= 0) continue;
172
+ const name = id.slice(at + 1);
173
+ if (out[name]) continue;
174
+ const known = getMarketplaceByName(name);
175
+ if (!known?.source.repo) continue;
176
+ out[name] = { source: "github", repo: known.source.repo };
177
+ }
178
+ return out;
179
+ }
180
+
150
181
  export async function resolveProfile(
151
182
  manifest: ProfileManifest,
152
183
  profileId: string,
@@ -161,7 +192,7 @@ export async function resolveProfile(
161
192
  const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
162
193
 
163
194
  return {
164
- marketplaces: { ...(entry.marketplaces ?? {}) },
195
+ marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
165
196
  plugins,
166
197
  mcpServers: { ...(entry.mcpServers ?? {}) },
167
198
  bins,
@@ -344,11 +344,69 @@ export async function fetchAvailableSkills(
344
344
 
345
345
  // ─── Install / Uninstall ──────────────────────────────────────────────────────
346
346
 
347
+ /** Hard ceilings so one pathological repo cannot fill a user's disk. */
348
+ const MAX_SKILL_FILES = 200;
349
+ const MAX_SKILL_BYTES = 20 * 1024 * 1024;
350
+
351
+ /**
352
+ * Pick the files that belong to one skill out of a whole-repo tree listing.
353
+ *
354
+ * Returns paths relative to the skill directory, so `skills/x/refs/a.md`
355
+ * under `skills/x` becomes `refs/a.md`.
356
+ *
357
+ * The trailing slash on the prefix is load-bearing: without it `skills/audit`
358
+ * also matches `skills/audit-website/...`, and the user gets a second skill's
359
+ * files dumped into the first one's directory.
360
+ */
361
+ export function selectSkillFiles(
362
+ treePaths: { path: string; type: string }[],
363
+ repoPath: string,
364
+ ): string[] {
365
+ const prefix = `${repoPath.replace(/\/+$/, "")}/`;
366
+ return treePaths
367
+ .filter((e) => e.type === "blob" && e.path.startsWith(prefix))
368
+ .map((e) => e.path.slice(prefix.length))
369
+ .filter((rel) => rel.length > 0);
370
+ }
371
+
372
+ /**
373
+ * Resolve one relative path inside the install directory, or return null.
374
+ *
375
+ * These paths come from a remote repository, so they are untrusted input to a
376
+ * filesystem write. Anything that escapes the install directory — `..`
377
+ * segments, an absolute path — is dropped rather than written outside the
378
+ * skill folder.
379
+ */
380
+ export function resolveSkillFilePath(
381
+ installDir: string,
382
+ relativePath: string,
383
+ ): string | null {
384
+ if (!relativePath || path.isAbsolute(relativePath)) return null;
385
+ const resolvedDir = path.resolve(installDir);
386
+ const target = path.resolve(resolvedDir, relativePath);
387
+ if (target !== resolvedDir && !target.startsWith(resolvedDir + path.sep)) {
388
+ return null;
389
+ }
390
+ return target;
391
+ }
392
+
393
+ /** What an install actually delivered — surfaced so the UI can be honest. */
394
+ export interface InstallResult {
395
+ /** Number of files written, always ≥ 1 (SKILL.md). */
396
+ fileCount: number;
397
+ /**
398
+ * Set when only SKILL.md could be written for a skill that has more files,
399
+ * e.g. the GitHub tree call was rate-limited. The skill is installed but
400
+ * incomplete, and the caller should say so.
401
+ */
402
+ degraded?: string;
403
+ }
404
+
347
405
  export async function installSkill(
348
406
  skill: SkillInfo,
349
407
  scope: "user" | "project",
350
408
  projectPath?: string,
351
- ): Promise<void> {
409
+ ): Promise<InstallResult> {
352
410
  // Try multiple URL patterns — repos structure SKILL.md differently
353
411
  const repo = skill.source.repo;
354
412
  const repoPath = skill.repoPath.replace(/\/SKILL\.md$/, "");
@@ -392,6 +450,74 @@ export async function installSkill(
392
450
 
393
451
  await fs.ensureDir(installDir);
394
452
  await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
453
+
454
+ // SKILL.md alone is usually not a working skill. Skills ship scripts,
455
+ // references and assets beside it and link to them by relative path, so
456
+ // writing only SKILL.md leaves dangling links — measured: audit-website
457
+ // references `references/OUTPUT-FORMAT.md`, systematic-debugging ships
458
+ // `find-polluter.sh` and 9 more. Fetch the rest of the directory.
459
+ let extras: string[] = [];
460
+ try {
461
+ const tree = await fetchGitTree(repo);
462
+ extras = selectSkillFiles(tree.tree, repoPath).filter(
463
+ (rel) => rel !== "SKILL.md",
464
+ );
465
+ } catch (error) {
466
+ // The tree call is the rate-limited one. A skill with only SKILL.md is
467
+ // still correct, so report the shortfall instead of failing the install.
468
+ return {
469
+ fileCount: 1,
470
+ degraded:
471
+ error instanceof Error && /rate limit/i.test(error.message)
472
+ ? "GitHub rate limit — supporting files not fetched. Set GITHUB_TOKEN and reinstall."
473
+ : "supporting files could not be listed; SKILL.md only",
474
+ };
475
+ }
476
+
477
+ if (extras.length === 0) return { fileCount: 1 };
478
+
479
+ const truncated = extras.length > MAX_SKILL_FILES;
480
+ const selected = truncated ? extras.slice(0, MAX_SKILL_FILES) : extras;
481
+
482
+ let written = 1;
483
+ let bytes = content.length;
484
+ let budgetHit = false;
485
+
486
+ for (const rel of selected) {
487
+ if (bytes >= MAX_SKILL_BYTES) {
488
+ budgetHit = true;
489
+ break;
490
+ }
491
+ const target = resolveSkillFilePath(installDir, rel);
492
+ if (!target) continue; // escapes the install dir — never write it
493
+ try {
494
+ const res = await fetch(
495
+ `https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/${rel}`,
496
+ { signal: AbortSignal.timeout(15000) },
497
+ );
498
+ if (!res.ok) continue;
499
+ // Buffer, not text: skills ship binary assets (.png, .svg) and
500
+ // decoding those as UTF-8 corrupts them.
501
+ const buf = Buffer.from(await res.arrayBuffer());
502
+ await fs.ensureDir(path.dirname(target));
503
+ await fs.writeFile(target, buf);
504
+ written++;
505
+ bytes += buf.length;
506
+ } catch {
507
+ // One unreachable asset should not abandon a half-written skill.
508
+ }
509
+ }
510
+
511
+ let degraded: string | undefined;
512
+ if (budgetHit) {
513
+ degraded = `stopped at ${MAX_SKILL_BYTES / 1024 / 1024}MB — skill is larger than the install budget`;
514
+ } else if (truncated) {
515
+ degraded = `only the first ${MAX_SKILL_FILES} of ${extras.length + 1} files were installed`;
516
+ } else if (written < selected.length + 1) {
517
+ degraded = `${selected.length + 1 - written} of ${selected.length + 1} files could not be fetched`;
518
+ }
519
+
520
+ return { fileCount: written, degraded };
395
521
  }
396
522
 
397
523
  export async function uninstallSkill(
@@ -411,11 +537,20 @@ export async function uninstallSkill(
411
537
 
412
538
  const skillMdPath = path.join(installDir, "SKILL.md");
413
539
 
540
+ // Remove the whole skill directory, not just SKILL.md. Installs now write
541
+ // supporting files (scripts, references, assets); deleting SKILL.md alone
542
+ // left those orphaned, and the follow-up `rmdir` then failed silently on the
543
+ // non-empty directory — so the skill vanished from the UI while its files
544
+ // stayed on disk forever.
545
+ //
546
+ // Gated on SKILL.md existing so this only ever deletes a directory that is
547
+ // actually an installed skill.
414
548
  if (await fs.pathExists(skillMdPath)) {
415
- await fs.remove(skillMdPath);
549
+ await fs.remove(installDir);
550
+ return;
416
551
  }
417
552
 
418
- // Try to remove directory if empty
553
+ // No SKILL.md: at most an empty leftover directory. Remove it only if empty.
419
554
  try {
420
555
  await fs.rmdir(installDir);
421
556
  } catch {
@@ -6,7 +6,7 @@ import { ScreenLayout } from "../components/layout/index.js";
6
6
  import { ScrollableList } from "../components/ScrollableList.js";
7
7
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
8
8
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
9
- import { getAllMarketplaces } from "../../data/marketplaces.js";
9
+ import { defaultMarketplaces, getAllMarketplaces } from "../../data/marketplaces.js";
10
10
  import { clearContentDriftCache } from "../../services/content-drift.js";
11
11
  import {
12
12
  diffVersions,
@@ -33,6 +33,7 @@ import {
33
33
  } from "../../services/claude-settings.js";
34
34
  import { saveProfile } from "../../services/profiles.js";
35
35
  import {
36
+ addMarketplace as cliAddMarketplace,
36
37
  installPlugin as cliInstallPlugin,
37
38
  repairPlugin as cliRepairPlugin,
38
39
  uninstallPlugin as cliUninstallPlugin,
@@ -387,16 +388,60 @@ export function PluginsScreen() {
387
388
  }
388
389
  };
389
390
 
391
+ /**
392
+ * Add a marketplace.
393
+ *
394
+ * The known ones are offered as a menu and added here — that covers every
395
+ * marketplace claudeup ships with, including magus, so a first run does not
396
+ * have to leave the tool. An arbitrary owner/repo still needs the terminal:
397
+ * there is no text-input modal, only confirm/message/select.
398
+ */
390
399
  const handleShowAddMarketplaceInstructions = async () => {
400
+ const installed = new Set(
401
+ pluginsState.marketplaces.status === "success"
402
+ ? pluginsState.marketplaces.data.map((m) => m.name)
403
+ : [],
404
+ );
405
+ const addable = defaultMarketplaces.filter(
406
+ (m) => !installed.has(m.name) && !m.deprecated && m.source.repo,
407
+ );
408
+
409
+ if (addable.length > 0) {
410
+ const choice = await modal.select(
411
+ "Add Marketplace",
412
+ "Pick one to add now, or choose Other for a repo not listed.",
413
+ [
414
+ ...addable.map((m) => ({
415
+ label: m.displayName || m.name,
416
+ value: m.source.repo as string,
417
+ description: m.description,
418
+ })),
419
+ { label: "Other…", value: "", description: "Any owner/repo" },
420
+ ],
421
+ );
422
+ if (choice === null) return;
423
+ if (choice) {
424
+ try {
425
+ await cliAddMarketplace(choice);
426
+ await handleRefresh();
427
+ } catch (e) {
428
+ await modal.message(
429
+ "Could not add marketplace",
430
+ `${(e as Error).message}\n\nRun it yourself if this persists:\n\n` +
431
+ ` claude plugin marketplace add ${choice}`,
432
+ "error",
433
+ );
434
+ }
435
+ return;
436
+ }
437
+ }
438
+
391
439
  await modal.message(
392
440
  "Add Marketplace",
393
- "To add a marketplace, run this command in your terminal:\n\n" +
441
+ "For a marketplace that is not listed, run this in your terminal:\n\n" +
394
442
  " claude plugin marketplace add owner/repo\n\n" +
395
- "Examples:\n" +
396
- " claude plugin marketplace add MadAppGang/magus\n" +
397
- " claude plugin marketplace add anthropics/claude-plugins-official\n\n" +
398
443
  "Auto-update is enabled by default for new marketplaces.\n\n" +
399
- "After adding, refresh claudeup with 'r' to see the new marketplace.",
444
+ "After adding, refresh claudeup with 'r' to see it.",
400
445
  "info",
401
446
  );
402
447
  };
@@ -593,14 +638,30 @@ export function PluginsScreen() {
593
638
  );
594
639
  }
595
640
  } else {
596
- await modal.message(
641
+ // Add it here rather than printing the command for the user to run.
642
+ // Telling someone to leave the tool, paste a command, come back and
643
+ // press 'r' is a step claudeup can just take: it already knows the
644
+ // repo, and addMarketplace() is the same call they would make.
645
+ const repo = mp.source.repo || mp.name;
646
+ const wantAdd = await modal.confirm(
597
647
  `Add ${mp.displayName}?`,
598
- `To add this marketplace, run in your terminal:\n\n` +
599
- ` claude plugin marketplace add ${mp.source.repo || mp.name}\n\n` +
600
- `Auto-update is enabled by default.\n\n` +
601
- `After adding, refresh claudeup with 'r' to see it.`,
602
- "info",
648
+ `Clone ${repo} and register it as a marketplace?\n\n` +
649
+ `Auto-update is enabled by default.`,
603
650
  );
651
+ if (wantAdd) {
652
+ try {
653
+ await cliAddMarketplace(repo);
654
+ await handleRefresh();
655
+ } catch (e) {
656
+ await modal.message(
657
+ "Could not add marketplace",
658
+ `${(e as Error).message}\n\n` +
659
+ `Run it yourself if this persists:\n\n` +
660
+ ` claude plugin marketplace add ${repo}`,
661
+ "error",
662
+ );
663
+ }
664
+ }
604
665
  }
605
666
  } else if (item.kind === "plugin") {
606
667
  const plugin = item.plugin;
@@ -271,10 +271,12 @@ export function SkillsScreen() {
271
271
  showStatus(`Installing ${toInstall.length} skills from ${set.name}...`);
272
272
  let installed = 0;
273
273
  let failed = 0;
274
+ let degraded = 0;
274
275
  for (const skill of toInstall) {
275
276
  try {
276
- await installSkill(skill, scope, state.projectPath);
277
+ const result = await installSkill(skill, scope, state.projectPath);
277
278
  installed++;
279
+ if (result.degraded) degraded++;
278
280
  } catch {
279
281
  failed++;
280
282
  }
@@ -282,6 +284,13 @@ export function SkillsScreen() {
282
284
  await scanDisk();
283
285
  if (failed > 0) {
284
286
  showStatus(`Installed ${installed}/${toInstall.length} (${failed} failed)`, "error");
287
+ } else if (degraded > 0) {
288
+ // Incomplete is not the same as installed. Say so rather than let a
289
+ // batch of half-written skills report a clean success.
290
+ showStatus(
291
+ `Installed ${installed} skills from ${set.name} to ${scope} — ${degraded} incomplete (missing supporting files)`,
292
+ "error",
293
+ );
285
294
  } else {
286
295
  showStatus(`Installed ${installed} skills from ${set.name} to ${scope}`);
287
296
  }
@@ -443,9 +452,20 @@ export function SkillsScreen() {
443
452
  const handleInstall = useCallback(async (scope: "user" | "project") => {
444
453
  if (!selectedSkill) return;
445
454
  try {
446
- await installSkill(selectedSkill, scope, state.projectPath);
455
+ const result = await installSkill(selectedSkill, scope, state.projectPath);
447
456
  await scanDisk(); // Re-scan disk only — no network re-fetch needed
448
- showStatus(`Installed ${selectedSkill.name} to ${scope}`);
457
+ const files =
458
+ result.fileCount === 1 ? "1 file" : `${result.fileCount} files`;
459
+ // An install that silently dropped supporting files used to report plain
460
+ // success, so a half-installed skill looked identical to a good one.
461
+ if (result.degraded) {
462
+ showStatus(
463
+ `Installed ${selectedSkill.name} to ${scope} (${files}) — ${result.degraded}`,
464
+ "error",
465
+ );
466
+ } else {
467
+ showStatus(`Installed ${selectedSkill.name} to ${scope} (${files})`);
468
+ }
449
469
  } catch (error) {
450
470
  showStatus(`Failed: ${error}`, "error");
451
471
  }