claudeup 4.33.0 → 4.34.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__/skill-install-files.test.ts +128 -0
- package/src/data/skill-repos.ts +6 -0
- package/src/services/skills-manager.ts +138 -3
- 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.34.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.34.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.34.0",
|
|
69
|
+
"claudeup-linux-x64": "4.34.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
|
});
|
|
@@ -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/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
|
/**
|
|
@@ -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 {
|
|
@@ -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
|
}
|