@oh-my-pi/pi-coding-agent 13.16.5 → 13.17.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/CHANGELOG.md +45 -0
- package/package.json +7 -7
- package/src/cli/args.ts +7 -0
- package/src/cli/classify-install-target.ts +50 -0
- package/src/cli/plugin-cli.ts +245 -31
- package/src/commands/plugin.ts +3 -0
- package/src/config/settings-schema.ts +12 -13
- package/src/cursor.ts +66 -1
- package/src/discovery/claude-plugins.ts +95 -5
- package/src/discovery/helpers.ts +168 -41
- package/src/discovery/plugin-dir-roots.ts +28 -0
- package/src/discovery/substitute-plugin-root.ts +29 -0
- package/src/extensibility/plugins/index.ts +1 -0
- package/src/extensibility/plugins/marketplace/cache.ts +136 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +354 -0
- package/src/extensibility/plugins/marketplace/index.ts +6 -0
- package/src/extensibility/plugins/marketplace/manager.ts +528 -0
- package/src/extensibility/plugins/marketplace/registry.ts +181 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +147 -0
- package/src/extensibility/plugins/marketplace/types.ts +177 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/local-protocol.ts +2 -19
- package/src/internal-urls/parse.ts +72 -0
- package/src/internal-urls/router.ts +2 -18
- package/src/lsp/config.ts +9 -0
- package/src/main.ts +50 -1
- package/src/modes/components/plugin-selector.ts +86 -0
- package/src/modes/components/settings-defs.ts +0 -4
- package/src/modes/controllers/mcp-command-controller.ts +14 -0
- package/src/modes/controllers/selector-controller.ts +104 -13
- package/src/modes/interactive-mode.ts +4 -0
- package/src/modes/types.ts +1 -0
- package/src/prompts/agents/reviewer.md +3 -4
- package/src/sdk.ts +0 -7
- package/src/slash-commands/builtin-registry.ts +273 -0
- package/src/tools/bash-skill-urls.ts +48 -5
- package/src/tools/read.ts +15 -9
- package/src/web/search/code-search.ts +2 -179
- package/src/web/search/index.ts +2 -3
- package/src/web/search/types.ts +1 -5
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace catalog fetcher.
|
|
3
|
+
*
|
|
4
|
+
* Classifies a source string, resolves it, and loads the catalog.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import { $ } from "bun";
|
|
12
|
+
|
|
13
|
+
import type { MarketplaceCatalog, MarketplaceSourceType } from "./types";
|
|
14
|
+
import { isValidNameSegment } from "./types";
|
|
15
|
+
|
|
16
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface FetchResult {
|
|
19
|
+
catalog: MarketplaceCatalog;
|
|
20
|
+
/** For git sources: path to the cloned marketplace directory. */
|
|
21
|
+
clonePath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── classifySource ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Detects Windows-style absolute paths cross-platform:
|
|
28
|
+
* C:\path, C:/path → drive-letter + colon + separator
|
|
29
|
+
* \\server\share → UNC path
|
|
30
|
+
*
|
|
31
|
+
* Needed because path.isAbsolute("C:\...") returns false on POSIX.
|
|
32
|
+
*/
|
|
33
|
+
const WIN_ABS_RE = /^[A-Za-z]:[/\\]|^\\\\/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* GitHub owner/repo shorthand: lowercase alphanumeric + hyphens/dots, one slash.
|
|
37
|
+
* Must NOT start with a protocol — that is ruled out by earlier checks.
|
|
38
|
+
*/
|
|
39
|
+
const GITHUB_SHORTHAND_RE = /^[a-z0-9-]+\/[a-z0-9._-]+$/i;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Classify a marketplace source string into one of the four source types.
|
|
43
|
+
*
|
|
44
|
+
* Rules are ordered; the first match wins. Protocol/pattern checks (rules 1-3)
|
|
45
|
+
* run before any path.isAbsolute() check so that SCP-style git@ URLs are
|
|
46
|
+
* never misclassified as local paths on Windows.
|
|
47
|
+
*
|
|
48
|
+
* @throws if the source format is unrecognized.
|
|
49
|
+
*/
|
|
50
|
+
export function classifySource(source: string): MarketplaceSourceType {
|
|
51
|
+
// Rule 1: HTTP(S) URLs — .json suffix → url, everything else → git
|
|
52
|
+
if (source.startsWith("https://") || source.startsWith("http://")) {
|
|
53
|
+
try {
|
|
54
|
+
const { pathname } = new URL(source);
|
|
55
|
+
return pathname.endsWith(".json") ? "url" : "git";
|
|
56
|
+
} catch {
|
|
57
|
+
// Malformed URL — treat as git
|
|
58
|
+
return "git";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Rule 2: SCP-style SSH git URLs
|
|
63
|
+
if (source.startsWith("git@") || source.startsWith("ssh://")) {
|
|
64
|
+
return "git";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Rule 3: GitHub owner/repo shorthand (no protocol, no leading slash)
|
|
68
|
+
if (GITHUB_SHORTHAND_RE.test(source)) {
|
|
69
|
+
return "github";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Rule 4: Explicit relative or home-relative paths
|
|
73
|
+
if (source.startsWith("./") || source.startsWith("~/")) {
|
|
74
|
+
return "local";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Rule 5: Absolute paths — POSIX via path.isAbsolute, Windows via regex
|
|
78
|
+
if (path.isAbsolute(source) || WIN_ABS_RE.test(source)) {
|
|
79
|
+
return "local";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(`Unrecognized source format. Did you mean './${source}' (local) or 'owner/repo' (GitHub)?`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── parseMarketplaceCatalog ───────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function assertField(condition: boolean, field: string, filePath: string): void {
|
|
88
|
+
if (!condition) {
|
|
89
|
+
throw new Error(`Missing or invalid field "${field}" in catalog: ${filePath}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse and validate a marketplace.json catalog from raw JSON content.
|
|
95
|
+
*
|
|
96
|
+
* Required fields: name (valid name segment), owner.name, plugins array.
|
|
97
|
+
* Each plugin entry requires name (string) and source (string or object
|
|
98
|
+
* with a "source" field). Extra fields are preserved via spread.
|
|
99
|
+
*
|
|
100
|
+
* @throws on JSON parse failure or missing/invalid required fields.
|
|
101
|
+
*/
|
|
102
|
+
export function parseMarketplaceCatalog(content: string, filePath: string): MarketplaceCatalog {
|
|
103
|
+
let raw: unknown;
|
|
104
|
+
try {
|
|
105
|
+
raw = JSON.parse(content);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new Error(`Failed to parse marketplace catalog at ${filePath}: ${(err as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
111
|
+
throw new Error(`Marketplace catalog at ${filePath} must be a JSON object`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const obj = raw as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
// name: required, must be a valid name segment
|
|
117
|
+
assertField(typeof obj.name === "string" && isValidNameSegment(obj.name), "name", filePath);
|
|
118
|
+
|
|
119
|
+
// owner: required object with name string
|
|
120
|
+
assertField(typeof obj.owner === "object" && obj.owner !== null && !Array.isArray(obj.owner), "owner", filePath);
|
|
121
|
+
const owner = obj.owner as Record<string, unknown>;
|
|
122
|
+
assertField(typeof owner.name === "string", "owner.name", filePath);
|
|
123
|
+
|
|
124
|
+
// plugins: required array
|
|
125
|
+
assertField(Array.isArray(obj.plugins), "plugins", filePath);
|
|
126
|
+
|
|
127
|
+
const plugins = obj.plugins as unknown[];
|
|
128
|
+
for (let i = 0; i < plugins.length; i++) {
|
|
129
|
+
const entry = plugins[i];
|
|
130
|
+
assertField(typeof entry === "object" && entry !== null && !Array.isArray(entry), `plugins[${i}]`, filePath);
|
|
131
|
+
const p = entry as Record<string, unknown>;
|
|
132
|
+
assertField(typeof p.name === "string" && isValidNameSegment(p.name), `plugins[${i}].name`, filePath);
|
|
133
|
+
// source can be a string path or a typed object (github/url/git-subdir/npm)
|
|
134
|
+
// all typed objects carry a "source" discriminant string field
|
|
135
|
+
assertField(
|
|
136
|
+
typeof p.source === "string" ||
|
|
137
|
+
(typeof p.source === "object" &&
|
|
138
|
+
p.source !== null &&
|
|
139
|
+
!Array.isArray(p.source) &&
|
|
140
|
+
typeof (p.source as Record<string, unknown>).source === "string"),
|
|
141
|
+
`plugins[${i}].source`,
|
|
142
|
+
filePath,
|
|
143
|
+
);
|
|
144
|
+
// String sources must be relative paths starting with "./"
|
|
145
|
+
if (typeof p.source === "string") {
|
|
146
|
+
assertField((p.source as string).startsWith("./"), `plugins[${i}].source (must start with "./")`, filePath);
|
|
147
|
+
}
|
|
148
|
+
// Validate required fields for typed source variants
|
|
149
|
+
if (typeof p.source === "object" && p.source !== null) {
|
|
150
|
+
const src = p.source as Record<string, unknown>;
|
|
151
|
+
const variant = src.source as string;
|
|
152
|
+
if (variant === "github") {
|
|
153
|
+
assertField(typeof src.repo === "string" && src.repo.length > 0, `plugins[${i}].source.repo`, filePath);
|
|
154
|
+
} else if (variant === "url" || variant === "git-subdir") {
|
|
155
|
+
assertField(typeof src.url === "string" && src.url.length > 0, `plugins[${i}].source.url`, filePath);
|
|
156
|
+
if (variant === "git-subdir") {
|
|
157
|
+
assertField(typeof src.path === "string" && src.path.length > 0, `plugins[${i}].source.path`, filePath);
|
|
158
|
+
}
|
|
159
|
+
} else if (variant === "npm") {
|
|
160
|
+
assertField(
|
|
161
|
+
typeof src.package === "string" && src.package.length > 0,
|
|
162
|
+
`plugins[${i}].source.package`,
|
|
163
|
+
filePath,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
assertField(false, `plugins[${i}].source.source (unknown variant: "${variant}")`, filePath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Extra fields are preserved — cast through unknown for type safety
|
|
172
|
+
return obj as unknown as MarketplaceCatalog;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── fetchMarketplace ──────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/** Relative path from a marketplace root to its catalog file. */
|
|
178
|
+
const CATALOG_RELATIVE_PATH = path.join(".claude-plugin", "marketplace.json");
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Expand a `~/...` path to an absolute path using os.homedir().
|
|
182
|
+
* Other paths are returned unchanged.
|
|
183
|
+
*/
|
|
184
|
+
function expandHome(p: string): string {
|
|
185
|
+
if (p.startsWith("~/")) {
|
|
186
|
+
return path.join(os.homedir(), p.slice(2));
|
|
187
|
+
}
|
|
188
|
+
return p;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fetch a marketplace catalog from a source.
|
|
193
|
+
*
|
|
194
|
+
* Dispatches on the source type: local filesystem paths are read directly;
|
|
195
|
+
* GitHub/git sources are cloned with `git`; URL sources are fetched over HTTP.
|
|
196
|
+
*
|
|
197
|
+
* @param source Source identifier: path, GitHub shorthand, git URL, or HTTP URL.
|
|
198
|
+
* @param cacheDir Cache directory root for non-local sources.
|
|
199
|
+
*/
|
|
200
|
+
export async function fetchMarketplace(source: string, cacheDir: string): Promise<FetchResult> {
|
|
201
|
+
const type = classifySource(source);
|
|
202
|
+
|
|
203
|
+
if (type === "local") {
|
|
204
|
+
const resolved = path.resolve(expandHome(source));
|
|
205
|
+
const catalogPath = path.join(resolved, CATALOG_RELATIVE_PATH);
|
|
206
|
+
|
|
207
|
+
let content: string;
|
|
208
|
+
try {
|
|
209
|
+
content = await Bun.file(catalogPath).text();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (isEnoent(err)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Marketplace catalog not found at "${catalogPath}". ` +
|
|
214
|
+
`Ensure the directory exists and contains a .claude-plugin/marketplace.json file.`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const catalog = parseMarketplaceCatalog(content, catalogPath);
|
|
221
|
+
return { catalog };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (type === "github") {
|
|
225
|
+
const url = `https://github.com/${source}.git`;
|
|
226
|
+
return cloneAndReadCatalog(url, cacheDir);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (type === "git") {
|
|
230
|
+
return cloneAndReadCatalog(source, cacheDir);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// type === "url"
|
|
234
|
+
const response = await fetch(source, { signal: AbortSignal.timeout(60_000) });
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Failed to fetch marketplace catalog from ${source}: HTTP ${response.status} ${response.statusText}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
const text = await response.text();
|
|
241
|
+
const catalog = parseMarketplaceCatalog(text, source);
|
|
242
|
+
|
|
243
|
+
const catalogDir = path.join(cacheDir, catalog.name);
|
|
244
|
+
await Bun.write(path.join(catalogDir, "marketplace.json"), text);
|
|
245
|
+
|
|
246
|
+
return { catalog };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── cloneAndReadCatalog ───────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Clone a git repository and read its marketplace catalog.
|
|
253
|
+
*
|
|
254
|
+
* Clones to a temporary directory and reads the catalog. The caller is
|
|
255
|
+
* responsible for promoting the clone to its final cache location via
|
|
256
|
+
* `promoteCloneToCache` after any duplicate/drift checks pass.
|
|
257
|
+
*/
|
|
258
|
+
async function cloneAndReadCatalog(url: string, cacheDir: string): Promise<FetchResult> {
|
|
259
|
+
if (!Bun.which("git")) {
|
|
260
|
+
throw new Error("git is not installed. Install git to use git-based marketplace sources.");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const tmpDir = path.join(cacheDir, `.tmp-clone-${Date.now()}`);
|
|
264
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
265
|
+
|
|
266
|
+
logger.debug(`[marketplace] cloning ${url} → ${tmpDir}`);
|
|
267
|
+
|
|
268
|
+
const result = await $`git clone --depth 1 --single-branch ${url} ${tmpDir}`.quiet().nothrow();
|
|
269
|
+
if (result.exitCode !== 0) {
|
|
270
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
271
|
+
const stderr = result.stderr.toString().trim();
|
|
272
|
+
throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const catalogPath = path.join(tmpDir, CATALOG_RELATIVE_PATH);
|
|
276
|
+
let content: string;
|
|
277
|
+
try {
|
|
278
|
+
content = await Bun.file(catalogPath).text();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
281
|
+
if (isEnoent(err)) {
|
|
282
|
+
throw new Error(`Cloned repository has no marketplace catalog at ${CATALOG_RELATIVE_PATH}`);
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let catalog: MarketplaceCatalog;
|
|
288
|
+
try {
|
|
289
|
+
catalog = parseMarketplaceCatalog(content, catalogPath);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return { catalog, clonePath: tmpDir };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Promote a temporary clone directory to its final cache location.
|
|
300
|
+
*
|
|
301
|
+
* Callers should invoke this only after duplicate/drift checks pass.
|
|
302
|
+
* Removes any existing directory at the target path before renaming.
|
|
303
|
+
*/
|
|
304
|
+
export async function promoteCloneToCache(tmpDir: string, cacheDir: string, name: string): Promise<string> {
|
|
305
|
+
const finalDir = path.join(cacheDir, name);
|
|
306
|
+
await fs.rm(finalDir, { recursive: true, force: true });
|
|
307
|
+
await fs.rename(tmpDir, finalDir);
|
|
308
|
+
return finalDir;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Clone a git repository to a target directory. Shared by fetcher (marketplace clones)
|
|
313
|
+
* and source-resolver (plugin source clones).
|
|
314
|
+
*
|
|
315
|
+
* @param url - Git clone URL (HTTPS, SSH, or GitHub shorthand expanded to HTTPS)
|
|
316
|
+
* @param targetDir - Directory to clone into (must not exist)
|
|
317
|
+
* @param options.ref - Optional branch/tag to clone
|
|
318
|
+
* @param options.sha - Optional commit SHA to checkout after clone
|
|
319
|
+
*/
|
|
320
|
+
export async function cloneGitRepo(
|
|
321
|
+
url: string,
|
|
322
|
+
targetDir: string,
|
|
323
|
+
options?: { ref?: string; sha?: string },
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
if (!Bun.which("git")) {
|
|
326
|
+
throw new Error("git is not installed. Install git to use git-based plugin sources.");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const cloneArgs = ["git", "clone", "--depth", "1"];
|
|
330
|
+
if (options?.ref) {
|
|
331
|
+
cloneArgs.push("--branch", options.ref, "--single-branch");
|
|
332
|
+
} else {
|
|
333
|
+
cloneArgs.push("--single-branch");
|
|
334
|
+
}
|
|
335
|
+
cloneArgs.push(url, targetDir);
|
|
336
|
+
|
|
337
|
+
logger.debug("[marketplace] cloning plugin source", { url, targetDir });
|
|
338
|
+
|
|
339
|
+
const result = await $`${cloneArgs}`.quiet().nothrow();
|
|
340
|
+
if (result.exitCode !== 0) {
|
|
341
|
+
await fs.rm(targetDir, { recursive: true, force: true });
|
|
342
|
+
const stderr = result.stderr.toString().trim();
|
|
343
|
+
throw new Error(`git clone failed (exit ${result.exitCode}): ${stderr || "unknown error"}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// If a specific SHA is requested, checkout that commit
|
|
347
|
+
if (options?.sha) {
|
|
348
|
+
const checkout = await $`git -C ${targetDir} checkout ${options.sha}`.quiet().nothrow();
|
|
349
|
+
if (checkout.exitCode !== 0) {
|
|
350
|
+
await fs.rm(targetDir, { recursive: true, force: true });
|
|
351
|
+
throw new Error(`Failed to checkout SHA ${options.sha} — shallow clone may not contain this commit`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|