claudeup 4.41.0 → 4.42.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__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/markdown-renderer.test.ts +143 -0
- package/src/__tests__/marketplaces.test.ts +4 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/data/marketplaces.ts +22 -0
- package/src/services/local-marketplace.ts +110 -19
- package/src/services/marketplace-catalog.ts +34 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +18 -0
- package/src/services/skills-manager.ts +531 -14
- package/src/ui/adapters/pluginsAdapter.ts +154 -17
- package/src/ui/components/primitives/KeyValueLine.tsx +8 -2
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/pluginRenderers.tsx +415 -9
- package/src/ui/screens/PluginsScreen.tsx +181 -53
- package/src/ui/state/reducer.ts +56 -0
- package/src/ui/state/types.ts +42 -0
- package/src/ui/theme-mode.ts +67 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin-checkout.ts — a read-only shallow clone, so browsing never installs.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Listing what a plugin contains needs its files. Three ways to get them, and
|
|
7
|
+
* only one is both complete and free:
|
|
8
|
+
*
|
|
9
|
+
* 1. Claude Code's marketplace clone — free and complete, but only exists for
|
|
10
|
+
* a marketplace the user has actually added. "✓ Added" in the plugin list
|
|
11
|
+
* does NOT imply a clone: that badge means the catalog resolved, and a
|
|
12
|
+
* catalog resolves over HTTP for marketplaces that were never cloned.
|
|
13
|
+
* 2. The GitHub Tree API — works for anything, but an unauthenticated client
|
|
14
|
+
* gets 60 requests an hour across every repo on screen combined. A list of
|
|
15
|
+
* 300+ rows exhausts that in one sitting, after which every expand answers
|
|
16
|
+
* "Could not read the plugin".
|
|
17
|
+
* 3. A shallow git clone of our own. Unlimited, complete, and offline after
|
|
18
|
+
* the first fetch.
|
|
19
|
+
*
|
|
20
|
+
* This module is (3). It is deliberately NOT an install: nothing here writes to
|
|
21
|
+
* `installed_plugins.json`, `known_marketplaces.json`, `enabledPlugins`, or
|
|
22
|
+
* Claude Code's plugin cache. Those are Claude Code-owned and claudeup does not
|
|
23
|
+
* touch them. A checkout lives in claudeup's own directory, is never loaded by
|
|
24
|
+
* Claude Code, and can be deleted at any time with no effect on what is
|
|
25
|
+
* installed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFile } from "node:child_process";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { promisify } from "node:util";
|
|
31
|
+
import fs from "fs-extra";
|
|
32
|
+
import { requireClaudeConfigDir } from "../utils/config-dir.js";
|
|
33
|
+
|
|
34
|
+
const run = promisify(execFile);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How long a checkout is reused before it is refreshed.
|
|
38
|
+
*
|
|
39
|
+
* Matches the catalog TTL so the tool has one refresh rhythm rather than two.
|
|
40
|
+
* A checkout going stale costs the user a skill added in the last hour; longer
|
|
41
|
+
* would start to misreport what a plugin ships.
|
|
42
|
+
*/
|
|
43
|
+
export const CHECKOUT_TTL_MS = 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
/** Clones live beside Claude Code's data, never inside its owned directories. */
|
|
46
|
+
function checkoutRoot(): string {
|
|
47
|
+
return path.join(requireClaudeConfigDir(), "claudeup-checkouts");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `owner/repo` → a flat directory name that is safe on every filesystem. */
|
|
51
|
+
export function checkoutDirName(repo: string): string {
|
|
52
|
+
return repo.replace(/[^a-zA-Z0-9._-]+/g, "__");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkoutPath(repo: string): string {
|
|
56
|
+
return path.join(checkoutRoot(), checkoutDirName(repo));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Is `git` on PATH? Cached — the answer cannot change mid-session. */
|
|
60
|
+
let gitAvailable: boolean | null = null;
|
|
61
|
+
async function hasGit(): Promise<boolean> {
|
|
62
|
+
if (gitAvailable !== null) return gitAvailable;
|
|
63
|
+
try {
|
|
64
|
+
await run("git", ["--version"], { timeout: 5000 });
|
|
65
|
+
gitAvailable = true;
|
|
66
|
+
} catch {
|
|
67
|
+
gitAvailable = false;
|
|
68
|
+
}
|
|
69
|
+
return gitAvailable;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** In-flight clones, so two rows expanding at once do not clone twice. */
|
|
73
|
+
const inFlight = new Map<string, Promise<string | null>>();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ensure a read-only checkout of `repo` exists, and return its path.
|
|
77
|
+
*
|
|
78
|
+
* Returns null when git is unavailable or the clone fails, so the caller can
|
|
79
|
+
* fall back to the API rather than treating a missing checkout as an empty
|
|
80
|
+
* plugin — reporting "ships nothing" because a clone failed would be a lie
|
|
81
|
+
* about the plugin rather than about the network.
|
|
82
|
+
*/
|
|
83
|
+
export async function ensureCheckout(repo: string): Promise<string | null> {
|
|
84
|
+
// Never clone from a test. This was not a precaution: a unit test that only
|
|
85
|
+
// meant to exercise the tree-API path shallow-cloned a real GitHub repo,
|
|
86
|
+
// because a stubbed `globalThis.fetch` does not stop `git`. Same convention
|
|
87
|
+
// `claudeConfigDirOrNull` uses to keep tests off the real config directory.
|
|
88
|
+
if (process.env.NODE_ENV === "test") return null;
|
|
89
|
+
|
|
90
|
+
const existing = inFlight.get(repo);
|
|
91
|
+
if (existing) return existing;
|
|
92
|
+
|
|
93
|
+
const task = (async () => {
|
|
94
|
+
const dir = checkoutPath(repo);
|
|
95
|
+
|
|
96
|
+
if (await fs.pathExists(path.join(dir, ".git"))) {
|
|
97
|
+
if (!(await isStale(dir))) return dir;
|
|
98
|
+
// A refresh that fails leaves the previous checkout in place: stale
|
|
99
|
+
// content is a better answer than none, and the alternative is a plugin
|
|
100
|
+
// that browsed fine yesterday reading as unreadable today.
|
|
101
|
+
if (await refresh(dir)) return dir;
|
|
102
|
+
return dir;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!(await hasGit())) return null;
|
|
106
|
+
|
|
107
|
+
await fs.ensureDir(checkoutRoot());
|
|
108
|
+
const tmp = `${dir}.partial`;
|
|
109
|
+
await fs.remove(tmp);
|
|
110
|
+
try {
|
|
111
|
+
// Shallow, one branch, no tags: this is a content read, not history.
|
|
112
|
+
await run(
|
|
113
|
+
"git",
|
|
114
|
+
[
|
|
115
|
+
"clone",
|
|
116
|
+
"--depth",
|
|
117
|
+
"1",
|
|
118
|
+
"--single-branch",
|
|
119
|
+
"--no-tags",
|
|
120
|
+
"--quiet",
|
|
121
|
+
`https://github.com/${repo}.git`,
|
|
122
|
+
tmp,
|
|
123
|
+
],
|
|
124
|
+
{ timeout: 120_000 },
|
|
125
|
+
);
|
|
126
|
+
// Move into place only once complete, so an interrupted clone never
|
|
127
|
+
// leaves a half-tree that would read as a plugin missing most of itself.
|
|
128
|
+
await fs.remove(dir);
|
|
129
|
+
await fs.move(tmp, dir);
|
|
130
|
+
return dir;
|
|
131
|
+
} catch {
|
|
132
|
+
await fs.remove(tmp).catch(() => {});
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
})();
|
|
136
|
+
|
|
137
|
+
inFlight.set(repo, task);
|
|
138
|
+
try {
|
|
139
|
+
return await task;
|
|
140
|
+
} finally {
|
|
141
|
+
inFlight.delete(repo);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function isStale(dir: string): Promise<boolean> {
|
|
146
|
+
try {
|
|
147
|
+
const stat = await fs.stat(path.join(dir, ".git"));
|
|
148
|
+
return Date.now() - stat.mtimeMs > CHECKOUT_TTL_MS;
|
|
149
|
+
} catch {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function refresh(dir: string): Promise<boolean> {
|
|
155
|
+
if (!(await hasGit())) return false;
|
|
156
|
+
try {
|
|
157
|
+
await run("git", ["fetch", "--depth", "1", "--quiet", "origin"], {
|
|
158
|
+
cwd: dir,
|
|
159
|
+
timeout: 120_000,
|
|
160
|
+
});
|
|
161
|
+
await run("git", ["reset", "--hard", "--quiet", "FETCH_HEAD"], {
|
|
162
|
+
cwd: dir,
|
|
163
|
+
timeout: 30_000,
|
|
164
|
+
});
|
|
165
|
+
await fs.utimes(path.join(dir, ".git"), new Date(), new Date());
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Every checkout on disk, for a size report or a purge. */
|
|
173
|
+
export async function listCheckouts(): Promise<string[]> {
|
|
174
|
+
try {
|
|
175
|
+
return (await fs.readdir(checkoutRoot())).filter(
|
|
176
|
+
(d) => !d.endsWith(".partial"),
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Delete every checkout. Safe at any time — none of it is installed state. */
|
|
184
|
+
export async function purgeCheckouts(): Promise<void> {
|
|
185
|
+
await fs.remove(checkoutRoot());
|
|
186
|
+
}
|
|
@@ -89,6 +89,18 @@ export interface PluginInfo {
|
|
|
89
89
|
skills?: string[];
|
|
90
90
|
mcpServers?: string[];
|
|
91
91
|
lspServers?: Record<string, unknown>;
|
|
92
|
+
/**
|
|
93
|
+
* The plugin's directory within its marketplace repo, as the manifest
|
|
94
|
+
* declares it ("./" for a repo that is one plugin, "./plugins/dev" for a
|
|
95
|
+
* multi-plugin marketplace). Needed to scope a skill listing to this
|
|
96
|
+
* plugin's subtree rather than the whole repo.
|
|
97
|
+
*/
|
|
98
|
+
source?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Set only when the plugin's files live in a repo other than its
|
|
101
|
+
* marketplace's — an object `source` naming a different repo.
|
|
102
|
+
*/
|
|
103
|
+
sourceRepo?: string;
|
|
92
104
|
isOrphaned?: boolean;
|
|
93
105
|
/**
|
|
94
106
|
* Set when the installed version equals the catalog version, but the
|
|
@@ -340,6 +352,8 @@ export async function getAvailablePlugins(
|
|
|
340
352
|
homepage: plugin.homepage,
|
|
341
353
|
tags: plugin.tags,
|
|
342
354
|
releases: plugin.releases,
|
|
355
|
+
source: plugin.source,
|
|
356
|
+
sourceRepo: plugin.sourceRepo,
|
|
343
357
|
});
|
|
344
358
|
}
|
|
345
359
|
}
|
|
@@ -376,6 +390,7 @@ export async function getAvailablePlugins(
|
|
|
376
390
|
agents: localPlugin.agents,
|
|
377
391
|
commands: localPlugin.commands,
|
|
378
392
|
skills: localPlugin.skills,
|
|
393
|
+
source: localPlugin.source,
|
|
379
394
|
mcpServers: localPlugin.mcpServers,
|
|
380
395
|
lspServers: localPlugin.lspServers,
|
|
381
396
|
releases: localPlugin.releases,
|
|
@@ -561,6 +576,8 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
561
576
|
homepage: plugin.homepage,
|
|
562
577
|
tags: plugin.tags,
|
|
563
578
|
releases: plugin.releases,
|
|
579
|
+
source: plugin.source,
|
|
580
|
+
sourceRepo: plugin.sourceRepo,
|
|
564
581
|
});
|
|
565
582
|
}
|
|
566
583
|
}
|
|
@@ -597,6 +614,7 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
|
597
614
|
agents: localPlugin.agents,
|
|
598
615
|
commands: localPlugin.commands,
|
|
599
616
|
skills: localPlugin.skills,
|
|
617
|
+
source: localPlugin.source,
|
|
600
618
|
mcpServers: localPlugin.mcpServers,
|
|
601
619
|
lspServers: localPlugin.lspServers,
|
|
602
620
|
releases: localPlugin.releases,
|