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 +4 -4
- package/src/__tests__/content-drift.test.ts +10 -1
- package/src/__tests__/gitignore-detector.test.ts +13 -1
- package/src/__tests__/resolver.test.ts +71 -0
- package/src/__tests__/skill-install-files.test.ts +128 -0
- package/src/data/marketplaces.ts +13 -0
- package/src/data/skill-repos.ts +6 -0
- package/src/services/resolver.ts +32 -1
- package/src/services/skills-manager.ts +138 -3
- package/src/ui/screens/PluginsScreen.tsx +73 -12
- package/src/ui/screens/SkillsScreen.tsx +23 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
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",
|
|
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
|
-
|
|
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
|
+
});
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -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",
|
package/src/data/skill-repos.ts
CHANGED
|
@@ -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
|
/**
|
package/src/services/resolver.ts
CHANGED
|
@@ -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:
|
|
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<
|
|
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(
|
|
549
|
+
await fs.remove(installDir);
|
|
550
|
+
return;
|
|
416
551
|
}
|
|
417
552
|
|
|
418
|
-
//
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
599
|
-
`
|
|
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
|
-
|
|
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
|
}
|