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.
- package/bin/claudeup.js +16 -3
- package/package.json +4 -4
- package/scripts/build-binaries.ts +12 -1
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/dotenv.test.ts +190 -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/main.tsx +18 -0
- package/src/services/dotenv.ts +176 -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,176 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load the project's .env into the process environment — deliberately, in our
|
|
7
|
+
* own code, with every failure downgraded to a warning.
|
|
8
|
+
*
|
|
9
|
+
* Bun's standalone-executable autoload used to do this for free. It is compiled
|
|
10
|
+
* OFF now (see scripts/build-binaries.ts): Bun 1.4.0 aborts inside that autoload
|
|
11
|
+
* when .env is a symlink pointing at a FIFO — exactly how 1Password serves
|
|
12
|
+
* secrets into a git worktree — exiting 1 with nothing on stdout or stderr,
|
|
13
|
+
* before any JavaScript runs and therefore before any error handler exists.
|
|
14
|
+
* claudeup simply vanished in every such directory.
|
|
15
|
+
*
|
|
16
|
+
* Two rules this loader keeps that the autoload did not:
|
|
17
|
+
*
|
|
18
|
+
* 1. Only regular files are read. A FIFO, socket or device is skipped, never
|
|
19
|
+
* opened. That dodges the crash, and it is right on its own terms: a FIFO
|
|
20
|
+
* hands its bytes to whoever opens it FIRST, so reading one would swallow
|
|
21
|
+
* the secrets a dev server or `op run` was waiting for.
|
|
22
|
+
* 2. Nothing here is fatal. A missing, unreadable or malformed .env produces a
|
|
23
|
+
* warning and claudeup carries on. Managing ~/.claude must never depend on
|
|
24
|
+
* the state of whatever directory you happen to be standing in.
|
|
25
|
+
*
|
|
26
|
+
* A real environment variable always beats a file, matching dotenv convention;
|
|
27
|
+
* a later file beats an earlier one.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Files loaded, in order. A later file overrides an earlier one. */
|
|
31
|
+
const ENV_FILES = [".env", ".env.local"] as const;
|
|
32
|
+
|
|
33
|
+
export interface DotenvOutcome {
|
|
34
|
+
/** Files actually parsed, in the order applied. */
|
|
35
|
+
loaded: string[];
|
|
36
|
+
/** Names newly set. A pre-existing variable is never overwritten. */
|
|
37
|
+
applied: string[];
|
|
38
|
+
/** One line per file skipped or failed. Print these; never throw them. */
|
|
39
|
+
warnings: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse dotenv text. Best-effort by design: an unparseable line is skipped
|
|
44
|
+
* rather than raised, because a broken .env must not be able to stop claudeup.
|
|
45
|
+
* Values spanning several lines are not supported.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDotenv(text: string): Record<string, string> {
|
|
48
|
+
const out: Record<string, string> = {};
|
|
49
|
+
|
|
50
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
51
|
+
const line = rawLine.trim();
|
|
52
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
53
|
+
|
|
54
|
+
const eq = line.indexOf("=");
|
|
55
|
+
if (eq <= 0) continue;
|
|
56
|
+
|
|
57
|
+
let key = line.slice(0, eq).trim();
|
|
58
|
+
if (key.startsWith("export ")) key = key.slice("export ".length).trim();
|
|
59
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
60
|
+
|
|
61
|
+
let value = line.slice(eq + 1).trim();
|
|
62
|
+
const quote = value[0];
|
|
63
|
+
const quoted =
|
|
64
|
+
(quote === '"' || quote === "'") &&
|
|
65
|
+
value.length > 1 &&
|
|
66
|
+
value.endsWith(quote);
|
|
67
|
+
|
|
68
|
+
if (quoted) {
|
|
69
|
+
value = value.slice(1, -1);
|
|
70
|
+
// Escapes are a double-quote feature; single quotes stay literal.
|
|
71
|
+
if (quote === '"') {
|
|
72
|
+
value = value
|
|
73
|
+
.replace(/\\n/g, "\n")
|
|
74
|
+
.replace(/\\r/g, "\r")
|
|
75
|
+
.replace(/\\t/g, "\t");
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// An unquoted value ends at the first " #" comment.
|
|
79
|
+
const hash = value.indexOf(" #");
|
|
80
|
+
if (hash !== -1) value = value.slice(0, hash).trimEnd();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
out[key] = value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What a non-regular entry actually is, for a warning a human can act on. */
|
|
90
|
+
function describeKind(info: Stats): string {
|
|
91
|
+
if (info.isFIFO()) {
|
|
92
|
+
return "it is a named pipe; reading it would consume another process's secrets";
|
|
93
|
+
}
|
|
94
|
+
if (info.isSocket()) return "it is a socket, not a regular file";
|
|
95
|
+
if (info.isDirectory()) return "it is a directory, not a regular file";
|
|
96
|
+
if (info.isBlockDevice() || info.isCharacterDevice()) {
|
|
97
|
+
return "it is a device, not a regular file";
|
|
98
|
+
}
|
|
99
|
+
return "it is not a regular file";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reason(error: unknown): string {
|
|
103
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read .env and .env.local from `cwd` into `env`. Never throws.
|
|
108
|
+
*
|
|
109
|
+
* Returns what was loaded and, in `warnings`, every reason a file was skipped or
|
|
110
|
+
* failed — the caller decides how to surface them.
|
|
111
|
+
*/
|
|
112
|
+
export function loadProjectDotenv(
|
|
113
|
+
cwd: string = process.cwd(),
|
|
114
|
+
env: Record<string, string | undefined> = process.env,
|
|
115
|
+
): DotenvOutcome {
|
|
116
|
+
const outcome: DotenvOutcome = { loaded: [], applied: [], warnings: [] };
|
|
117
|
+
|
|
118
|
+
// Merge every file BEFORE touching `env`. Applying file by file would make
|
|
119
|
+
// the "never overwrite" rule below fire against .env's own values, so .env
|
|
120
|
+
// would silently beat .env.local — the opposite of the intended precedence.
|
|
121
|
+
const merged: Record<string, string> = {};
|
|
122
|
+
|
|
123
|
+
for (const name of ENV_FILES) {
|
|
124
|
+
const file = path.join(cwd, name);
|
|
125
|
+
|
|
126
|
+
// lstat first, so a symlink is recognised as one: the warning should name
|
|
127
|
+
// the file the user sees, while the decision is made about its target.
|
|
128
|
+
let info: Stats;
|
|
129
|
+
try {
|
|
130
|
+
info = lstatSync(file);
|
|
131
|
+
} catch {
|
|
132
|
+
continue; // Absent. That is the ordinary case, not a problem.
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (info.isSymbolicLink()) {
|
|
136
|
+
try {
|
|
137
|
+
info = statSync(file);
|
|
138
|
+
} catch {
|
|
139
|
+
outcome.warnings.push(`skipped ${name} — the symlink does not resolve`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!info.isFile()) {
|
|
145
|
+
outcome.warnings.push(`skipped ${name} — ${describeKind(info)}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let text: string;
|
|
150
|
+
try {
|
|
151
|
+
text = readFileSync(file, "utf8");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
outcome.warnings.push(`could not read ${name} — ${reason(error)}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let parsed: Record<string, string>;
|
|
158
|
+
try {
|
|
159
|
+
parsed = parseDotenv(text);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
outcome.warnings.push(`could not parse ${name} — ${reason(error)}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
outcome.loaded.push(name);
|
|
166
|
+
Object.assign(merged, parsed); // A later file beats an earlier one.
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
170
|
+
if (env[key] !== undefined) continue; // A real variable always wins.
|
|
171
|
+
env[key] = value;
|
|
172
|
+
outcome.applied.push(key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return outcome;
|
|
176
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from "fs-extra";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
1
|
import { execSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
5
|
import type { PluginRelease } from "../types/index.js";
|
|
6
6
|
import { normalizeReleases } from "./plugin-releases.js";
|
|
7
7
|
|
|
@@ -43,6 +43,18 @@ const KNOWN_MARKETPLACES_FILE = path.join(
|
|
|
43
43
|
"known_marketplaces.json",
|
|
44
44
|
);
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Where Claude Code keeps a marketplace's clone, whether or not it exists.
|
|
48
|
+
*
|
|
49
|
+
* Exported so a reader can prefer the clone over the network. Anything already
|
|
50
|
+
* added is on disk in full, and reading it costs no GitHub request — which
|
|
51
|
+
* matters because an unauthenticated client gets 60 an hour for every repo on
|
|
52
|
+
* screen combined.
|
|
53
|
+
*/
|
|
54
|
+
export function getMarketplaceClonePath(marketplaceName: string): string {
|
|
55
|
+
return path.join(CLAUDE_PLUGINS_DIR, marketplaceName);
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
interface KnownMarketplaceEntry {
|
|
47
59
|
source: { source: string; url?: string; repo?: string };
|
|
48
60
|
installLocation: string;
|
|
@@ -97,6 +109,99 @@ function getGitRemote(marketplacePath: string): string | undefined {
|
|
|
97
109
|
/**
|
|
98
110
|
* Scan a single marketplace directory and return marketplace info
|
|
99
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* Names of the skills under `skillsDir`, recursing through grouping directories.
|
|
114
|
+
*
|
|
115
|
+
* A skill is a directory holding a SKILL.md, and it may sit one or more levels
|
|
116
|
+
* down: dev groups its 43 skills under `skills/frontend/`, `skills/backend/`
|
|
117
|
+
* and so on, and mattpocock groups its 37 under `skills/engineering/`. Reading
|
|
118
|
+
* only the top level counted the *groups* — dev reported 9 skills against 43 on
|
|
119
|
+
* disk, mattpocock 5 against 37 — and that count is what the plugin detail
|
|
120
|
+
* panel prints.
|
|
121
|
+
*
|
|
122
|
+
* Depth is capped because this runs over every plugin of every cloned
|
|
123
|
+
* marketplace on each list load, and a skill nested four levels below `skills/`
|
|
124
|
+
* does not exist in practice.
|
|
125
|
+
*/
|
|
126
|
+
export async function scanSkillDirs(
|
|
127
|
+
skillsDir: string,
|
|
128
|
+
depth = 0,
|
|
129
|
+
): Promise<string[]> {
|
|
130
|
+
if (depth > 3) return [];
|
|
131
|
+
if (!(await fs.pathExists(skillsDir))) return [];
|
|
132
|
+
|
|
133
|
+
let entries: import("node:fs").Dirent[];
|
|
134
|
+
try {
|
|
135
|
+
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const found: string[] = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (!entry.isDirectory()) continue;
|
|
143
|
+
const child = path.join(skillsDir, entry.name);
|
|
144
|
+
if (await fs.pathExists(path.join(child, "SKILL.md"))) {
|
|
145
|
+
found.push(entry.name);
|
|
146
|
+
} else {
|
|
147
|
+
found.push(...(await scanSkillDirs(child, depth + 1)));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return found;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Names of the MCP servers a plugin declares.
|
|
155
|
+
*
|
|
156
|
+
* Two locations are in use, and only one of them used to be read. An
|
|
157
|
+
* `mcp-servers/` directory of one JSON per server was the only thing scanned;
|
|
158
|
+
* a root `.mcp.json`, which is the form `plugin.json` points at with
|
|
159
|
+
* `"mcpServers": "./.mcp.json"`, was invisible. In this marketplace that is 5
|
|
160
|
+
* plugins of 7, mnemex among them — and mnemex ships *nothing else*, so it
|
|
161
|
+
* reported zero components of every kind and read as an empty plugin.
|
|
162
|
+
*
|
|
163
|
+
* Inside `.mcp.json`, both a bare `{ "<name>": {...} }` map and the
|
|
164
|
+
* `{ "mcpServers": {...} }` wrapper appear in the wild.
|
|
165
|
+
*/
|
|
166
|
+
export async function scanMcpServers(pluginPath: string): Promise<string[]> {
|
|
167
|
+
const found: string[] = [];
|
|
168
|
+
|
|
169
|
+
const mcpDir = path.join(pluginPath, "mcp-servers");
|
|
170
|
+
if (await fs.pathExists(mcpDir)) {
|
|
171
|
+
try {
|
|
172
|
+
const files = await fs.readdir(mcpDir);
|
|
173
|
+
found.push(
|
|
174
|
+
...files
|
|
175
|
+
.filter((f) => f.endsWith(".json"))
|
|
176
|
+
.map((f) => f.replace(".json", "")),
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
// Ignore scan errors
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const mcpFile = path.join(pluginPath, ".mcp.json");
|
|
184
|
+
if (await fs.pathExists(mcpFile)) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = (await fs.readJson(mcpFile)) as Record<string, unknown>;
|
|
187
|
+
const servers =
|
|
188
|
+
raw.mcpServers && typeof raw.mcpServers === "object"
|
|
189
|
+
? (raw.mcpServers as Record<string, unknown>)
|
|
190
|
+
: raw;
|
|
191
|
+
for (const name of Object.keys(servers)) {
|
|
192
|
+
if (!found.includes(name)) found.push(name);
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Unreadable or malformed. The file exists, so the plugin does declare a
|
|
196
|
+
// server — report one rather than dropping to zero, which is the exact
|
|
197
|
+
// "ships nothing" misreport this function exists to fix.
|
|
198
|
+
if (found.length === 0) found.push("mcp");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return found;
|
|
203
|
+
}
|
|
204
|
+
|
|
100
205
|
async function scanSingleMarketplace(
|
|
101
206
|
marketplacePath: string,
|
|
102
207
|
marketplaceName: string,
|
|
@@ -159,23 +264,9 @@ async function scanSingleMarketplace(
|
|
|
159
264
|
.map((f) => f.replace(".md", ""));
|
|
160
265
|
}
|
|
161
266
|
// Scan for skills
|
|
162
|
-
|
|
163
|
-
if (await fs.pathExists(skillsDir)) {
|
|
164
|
-
const skillFiles = await fs.readdir(skillsDir);
|
|
165
|
-
skills = skillFiles.filter(
|
|
166
|
-
(f) =>
|
|
167
|
-
f.endsWith(".md") ||
|
|
168
|
-
fs.statSync(path.join(skillsDir, f)).isDirectory(),
|
|
169
|
-
);
|
|
170
|
-
}
|
|
267
|
+
skills = await scanSkillDirs(path.join(pluginPath, "skills"));
|
|
171
268
|
// Scan for MCP servers
|
|
172
|
-
|
|
173
|
-
if (await fs.pathExists(mcpDir)) {
|
|
174
|
-
const mcpFiles = await fs.readdir(mcpDir);
|
|
175
|
-
mcpServers = mcpFiles
|
|
176
|
-
.filter((f) => f.endsWith(".json"))
|
|
177
|
-
.map((f) => f.replace(".json", ""));
|
|
178
|
-
}
|
|
269
|
+
mcpServers = await scanMcpServers(pluginPath);
|
|
179
270
|
} catch {
|
|
180
271
|
// Ignore scan errors
|
|
181
272
|
}
|
|
@@ -26,6 +26,17 @@ export interface MarketplacePlugin {
|
|
|
26
26
|
homepage?: string;
|
|
27
27
|
tags?: string[];
|
|
28
28
|
releases?: PluginRelease[];
|
|
29
|
+
/**
|
|
30
|
+
* The plugin's directory inside the marketplace repo, as declared. "" means
|
|
31
|
+
* the repo root, which is how every single-plugin marketplace publishes
|
|
32
|
+
* ("source": "./").
|
|
33
|
+
*/
|
|
34
|
+
source?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Set only when the plugin declares an object source naming a repo other
|
|
37
|
+
* than the marketplace's own. Its files live there, not in the marketplace.
|
|
38
|
+
*/
|
|
39
|
+
sourceRepo?: string;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
// Session-level cache for each marketplace's declared version (from
|
|
@@ -58,6 +69,28 @@ interface RawPlugin {
|
|
|
58
69
|
homepage?: string;
|
|
59
70
|
tags?: string[];
|
|
60
71
|
releases?: unknown;
|
|
72
|
+
source?: string | { source?: string; repo?: string };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split a manifest `source` into a repo-relative directory and, when the plugin
|
|
77
|
+
* points at a different repo than the marketplace, that repo.
|
|
78
|
+
*
|
|
79
|
+
* Both forms are in use: a string path ("./", "./plugins/dev") and an object
|
|
80
|
+
* ({"source":"github","repo":"owner/name"}), which addyosmani/agent-skills
|
|
81
|
+
* publishes. The object form has no path — the plugin is the whole repo.
|
|
82
|
+
*/
|
|
83
|
+
function splitSource(source: RawPlugin["source"]): {
|
|
84
|
+
source?: string;
|
|
85
|
+
sourceRepo?: string;
|
|
86
|
+
} {
|
|
87
|
+
if (typeof source === "string") {
|
|
88
|
+
return { source: source.replace(/^\.\//, "").replace(/\/+$/, "") };
|
|
89
|
+
}
|
|
90
|
+
if (source && typeof source === "object" && typeof source.repo === "string") {
|
|
91
|
+
return { source: "", sourceRepo: source.repo };
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
61
94
|
}
|
|
62
95
|
|
|
63
96
|
/** Parse a `marketplace.json` payload into plugin entries. */
|
|
@@ -87,6 +120,7 @@ export function normalizeCatalogJson(
|
|
|
87
120
|
homepage: plugin.homepage,
|
|
88
121
|
tags: plugin.tags,
|
|
89
122
|
releases: normalizeReleases(plugin.releases),
|
|
123
|
+
...splitSource(plugin.source),
|
|
90
124
|
});
|
|
91
125
|
}
|
|
92
126
|
}
|
|
@@ -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,
|