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.
@@ -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,