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.
@@ -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
- const skillsDir = path.join(pluginPath, "skills");
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
- const mcpDir = path.join(pluginPath, "mcp-servers");
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,