@oh-my-pi/pi-coding-agent 13.16.5 → 13.17.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/CHANGELOG.md +58 -1
- 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/commit/git/index.ts +3 -4
- package/src/commit/model-selection.ts +1 -19
- package/src/config/model-registry.ts +19 -3
- package/src/config/model-resolver.ts +21 -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 +51 -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/patch/shared.ts +28 -3
- 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/auto-generated-guard.ts +1 -1
- package/src/tools/bash-skill-urls.ts +48 -5
- package/src/tools/read.ts +15 -9
- package/src/tools/render-utils.ts +2 -2
- package/src/utils/title-generator.ts +4 -8
- package/src/web/search/index.ts +2 -38
- package/src/web/search/types.ts +0 -6
- package/src/prompts/tools/code-search.md +0 -45
- package/src/web/search/code-search.ts +0 -385
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry read/write operations for the marketplace plugin system.
|
|
3
|
+
*
|
|
4
|
+
* Two registries:
|
|
5
|
+
* - marketplaces.json under getConfigRootDir() — which catalogs the user has added
|
|
6
|
+
* - installed_plugins.json under getPluginsDir() — which plugins are installed
|
|
7
|
+
*
|
|
8
|
+
* Read/write functions accept explicit file paths so callers control the
|
|
9
|
+
* location. Path helpers compute the default paths from the dir singleton.
|
|
10
|
+
*
|
|
11
|
+
* Both use atomic write (tmp + rename). On Windows, rename over existing file
|
|
12
|
+
* can fail with EPERM — fallback: unlink target then rename.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
import { getConfigRootDir, getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
InstalledPluginEntry,
|
|
22
|
+
InstalledPluginsRegistry,
|
|
23
|
+
MarketplaceRegistryEntry,
|
|
24
|
+
MarketplacesRegistry,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
// ── Path helpers ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function getMarketplacesRegistryPath(): string {
|
|
30
|
+
return path.join(getConfigRootDir(), "marketplaces.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getInstalledPluginsRegistryPath(): string {
|
|
34
|
+
return path.join(getPluginsDir(), "installed_plugins.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getMarketplacesCacheDir(): string {
|
|
38
|
+
return path.join(getPluginsDir(), "cache", "marketplaces");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getPluginsCacheDir(): string {
|
|
42
|
+
return path.join(getPluginsDir(), "cache", "plugins");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Atomic write ─────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
|
48
|
+
const content = `${JSON.stringify(data, null, 2)}\n`;
|
|
49
|
+
const tmpPath = `${filePath}.tmp`;
|
|
50
|
+
|
|
51
|
+
await Bun.write(tmpPath, content);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await fs.rename(tmpPath, filePath);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Windows EPERM fallback: unlink target, then rename
|
|
57
|
+
if ((err as NodeJS.ErrnoException).code === "EPERM") {
|
|
58
|
+
try {
|
|
59
|
+
await fs.unlink(filePath);
|
|
60
|
+
} catch {
|
|
61
|
+
// Target may not exist — that's fine
|
|
62
|
+
}
|
|
63
|
+
await fs.rename(tmpPath, filePath);
|
|
64
|
+
} else {
|
|
65
|
+
// Clean up tmp on unexpected errors
|
|
66
|
+
try {
|
|
67
|
+
await fs.unlink(tmpPath);
|
|
68
|
+
} catch {
|
|
69
|
+
// Best effort
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Marketplaces registry ────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function emptyMarketplacesRegistry(): MarketplacesRegistry {
|
|
79
|
+
return { version: 1, marketplaces: [] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function readMarketplacesRegistry(filePath: string): Promise<MarketplacesRegistry> {
|
|
83
|
+
try {
|
|
84
|
+
const content = await Bun.file(filePath).text();
|
|
85
|
+
const data = tryParseJson<MarketplacesRegistry>(content);
|
|
86
|
+
if (!data || typeof data !== "object" || data.version !== 1 || !Array.isArray(data.marketplaces)) {
|
|
87
|
+
logger.warn("Invalid marketplaces registry, returning empty", { path: filePath });
|
|
88
|
+
return emptyMarketplacesRegistry();
|
|
89
|
+
}
|
|
90
|
+
return data;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (isEnoent(err)) return emptyMarketplacesRegistry();
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function writeMarketplacesRegistry(filePath: string, reg: MarketplacesRegistry): Promise<void> {
|
|
98
|
+
await atomicWriteJson(filePath, reg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Installed plugins registry ───────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function emptyInstalledPluginsRegistry(): InstalledPluginsRegistry {
|
|
104
|
+
return { version: 2, plugins: {} };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function readInstalledPluginsRegistry(filePath: string): Promise<InstalledPluginsRegistry> {
|
|
108
|
+
try {
|
|
109
|
+
const content = await Bun.file(filePath).text();
|
|
110
|
+
const data = tryParseJson<InstalledPluginsRegistry>(content);
|
|
111
|
+
if (
|
|
112
|
+
!data ||
|
|
113
|
+
typeof data !== "object" ||
|
|
114
|
+
typeof data.version !== "number" ||
|
|
115
|
+
!data.plugins ||
|
|
116
|
+
typeof data.plugins !== "object" ||
|
|
117
|
+
Array.isArray(data.plugins)
|
|
118
|
+
) {
|
|
119
|
+
logger.warn("Invalid installed plugins registry, returning empty", { path: filePath });
|
|
120
|
+
return emptyInstalledPluginsRegistry();
|
|
121
|
+
}
|
|
122
|
+
// Accept any numeric version — forward compatible reads
|
|
123
|
+
return { ...data, version: 2 };
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (isEnoent(err)) return emptyInstalledPluginsRegistry();
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function writeInstalledPluginsRegistry(filePath: string, reg: InstalledPluginsRegistry): Promise<void> {
|
|
131
|
+
await atomicWriteJson(filePath, reg);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Marketplace CRUD ─────────────────────────────────────────────────
|
|
135
|
+
// Pure functions that transform registry state. Caller is responsible for
|
|
136
|
+
// reading, mutating, and writing back.
|
|
137
|
+
|
|
138
|
+
export function addMarketplaceEntry(reg: MarketplacesRegistry, entry: MarketplaceRegistryEntry): MarketplacesRegistry {
|
|
139
|
+
if (reg.marketplaces.some(m => m.name === entry.name)) {
|
|
140
|
+
throw new Error(`Marketplace "${entry.name}" already exists`);
|
|
141
|
+
}
|
|
142
|
+
return { ...reg, marketplaces: [...reg.marketplaces, entry] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function removeMarketplaceEntry(reg: MarketplacesRegistry, name: string): MarketplacesRegistry {
|
|
146
|
+
const filtered = reg.marketplaces.filter(m => m.name !== name);
|
|
147
|
+
if (filtered.length === reg.marketplaces.length) {
|
|
148
|
+
throw new Error(`Marketplace "${name}" not found`);
|
|
149
|
+
}
|
|
150
|
+
return { ...reg, marketplaces: filtered };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function getMarketplaceEntry(reg: MarketplacesRegistry, name: string): MarketplaceRegistryEntry | undefined {
|
|
154
|
+
return reg.marketplaces.find(m => m.name === name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Installed plugin CRUD ────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function addInstalledPlugin(
|
|
160
|
+
reg: InstalledPluginsRegistry,
|
|
161
|
+
id: string,
|
|
162
|
+
entry: InstalledPluginEntry,
|
|
163
|
+
): InstalledPluginsRegistry {
|
|
164
|
+
const existing = reg.plugins[id] ?? [];
|
|
165
|
+
return {
|
|
166
|
+
...reg,
|
|
167
|
+
plugins: { ...reg.plugins, [id]: [...existing, entry] },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function removeInstalledPlugin(reg: InstalledPluginsRegistry, id: string): InstalledPluginsRegistry {
|
|
172
|
+
if (!(id in reg.plugins)) {
|
|
173
|
+
throw new Error(`Plugin "${id}" not found in registry`);
|
|
174
|
+
}
|
|
175
|
+
const { [id]: _, ...rest } = reg.plugins;
|
|
176
|
+
return { ...reg, plugins: rest };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getInstalledPlugin(reg: InstalledPluginsRegistry, id: string): InstalledPluginEntry[] | undefined {
|
|
180
|
+
return reg.plugins[id];
|
|
181
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source resolver for marketplace plugin entries.
|
|
3
|
+
*
|
|
4
|
+
* Resolves plugin sources to absolute local directory paths:
|
|
5
|
+
* - Relative string "./plugins/foo" → path within marketplace clone
|
|
6
|
+
* - { source: "url", url: "https://...git" } → git clone
|
|
7
|
+
* - { source: "github", repo: "owner/repo" } → git clone from GitHub
|
|
8
|
+
* - { source: "git-subdir", url: "...", path: "sub/dir" } → git clone + subdir
|
|
9
|
+
* - { source: "npm", ... } → not yet supported
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as crypto from "node:crypto";
|
|
13
|
+
import * as fs from "node:fs/promises";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { isEnoent, pathIsWithin } from "@oh-my-pi/pi-utils";
|
|
17
|
+
|
|
18
|
+
import { cloneGitRepo } from "./fetcher";
|
|
19
|
+
import type { MarketplaceCatalogMetadata, MarketplacePluginEntry, PluginSource } from "./types";
|
|
20
|
+
|
|
21
|
+
export interface ResolveContext {
|
|
22
|
+
/** Absolute path to the cloned/local marketplace directory. Required for relative sources. */
|
|
23
|
+
marketplaceClonePath?: string;
|
|
24
|
+
/** Catalog metadata — used for `pluginRoot` prepend. */
|
|
25
|
+
catalogMetadata?: MarketplaceCatalogMetadata;
|
|
26
|
+
/** Scratch directory for sources that require cloning or extraction. */
|
|
27
|
+
tmpDir: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a plugin source to an absolute local directory path.
|
|
32
|
+
*
|
|
33
|
+
* The resolved path is verified to exist on disk.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolvePluginSource(
|
|
36
|
+
entry: MarketplacePluginEntry,
|
|
37
|
+
context: ResolveContext,
|
|
38
|
+
): Promise<{ dir: string; tempCloneRoot?: string }> {
|
|
39
|
+
const { source } = entry;
|
|
40
|
+
|
|
41
|
+
if (typeof source === "string") {
|
|
42
|
+
return resolveRelativeSource(source, context);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return resolveObjectSource(source, context);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Relative string source ("./plugins/foo") ────────────────────────
|
|
49
|
+
|
|
50
|
+
async function resolveRelativeSource(
|
|
51
|
+
source: string,
|
|
52
|
+
context: ResolveContext,
|
|
53
|
+
): Promise<{ dir: string; tempCloneRoot?: string }> {
|
|
54
|
+
if (!source.startsWith("./")) {
|
|
55
|
+
throw new Error(`Relative plugin source paths must start with "./" — got: "${source}"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!context.marketplaceClonePath) {
|
|
59
|
+
throw new Error(`Cannot resolve relative source "${source}": marketplaceClonePath is required`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If pluginRoot is set, prepend it to the path segment after "./"
|
|
63
|
+
const pluginRoot = context.catalogMetadata?.pluginRoot;
|
|
64
|
+
const relativePath = pluginRoot ? `./${path.join(pluginRoot, source.slice(2))}` : source;
|
|
65
|
+
|
|
66
|
+
// Resolve against marketplace root (not the .claude-plugin/ catalog subdirectory)
|
|
67
|
+
const resolved = path.resolve(context.marketplaceClonePath, relativePath);
|
|
68
|
+
|
|
69
|
+
if (!pathIsWithin(context.marketplaceClonePath, resolved)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Plugin source "${source}" resolves outside marketplace root ("${context.marketplaceClonePath}")`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await verifyDirExists(resolved, `Plugin source directory does not exist: "${resolved}"`);
|
|
76
|
+
return { dir: resolved };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Object source variants ──────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
async function resolveObjectSource(
|
|
82
|
+
source: Exclude<PluginSource, string>,
|
|
83
|
+
context: ResolveContext,
|
|
84
|
+
): Promise<{ dir: string; tempCloneRoot?: string }> {
|
|
85
|
+
switch (source.source) {
|
|
86
|
+
case "url": {
|
|
87
|
+
// { source: "url", url: "https://github.com/owner/repo.git" }
|
|
88
|
+
// Despite the name, this is typically a git clone URL
|
|
89
|
+
const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
|
|
90
|
+
await cloneGitRepo(source.url, targetDir, { ref: source.ref, sha: source.sha });
|
|
91
|
+
return { dir: targetDir, tempCloneRoot: targetDir };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case "github": {
|
|
95
|
+
// { source: "github", repo: "owner/repo" }
|
|
96
|
+
const url = `https://github.com/${source.repo}.git`;
|
|
97
|
+
const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
|
|
98
|
+
await cloneGitRepo(url, targetDir, { ref: source.ref, sha: source.sha });
|
|
99
|
+
return { dir: targetDir, tempCloneRoot: targetDir };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case "git-subdir": {
|
|
103
|
+
// { source: "git-subdir", url: "owner/repo" | "https://...", path: "plugins/foo" }
|
|
104
|
+
const url =
|
|
105
|
+
source.url.includes("://") || source.url.startsWith("git@")
|
|
106
|
+
? source.url
|
|
107
|
+
: `https://github.com/${source.url}.git`;
|
|
108
|
+
const cloneDir = path.join(context.tmpDir, `plugin-repo-${crypto.randomUUID()}`);
|
|
109
|
+
await cloneGitRepo(url, cloneDir, { ref: source.ref, sha: source.sha });
|
|
110
|
+
|
|
111
|
+
const subdirPath = path.resolve(cloneDir, source.path);
|
|
112
|
+
if (!pathIsWithin(cloneDir, subdirPath)) {
|
|
113
|
+
await fs.rm(cloneDir, { recursive: true, force: true });
|
|
114
|
+
throw new Error(`git-subdir path "${source.path}" escapes the cloned repository`);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await verifyDirExists(subdirPath, `git-subdir path "${source.path}" does not exist in cloned repository`);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
await fs.rm(cloneDir, { recursive: true, force: true });
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
return { dir: subdirPath, tempCloneRoot: cloneDir };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case "npm":
|
|
126
|
+
throw new Error("npm plugin sources are not yet supported. Use git-based sources instead.");
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`Unknown plugin source type: "${(source as { source: string }).source}"`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async function verifyDirExists(dirPath: string, errorMessage: string): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
const stat = await fs.stat(dirPath);
|
|
138
|
+
if (!stat.isDirectory()) {
|
|
139
|
+
throw new Error(errorMessage);
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (isEnoent(err)) {
|
|
143
|
+
throw new Error(errorMessage);
|
|
144
|
+
}
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace plugin system types.
|
|
3
|
+
*
|
|
4
|
+
* Two registries:
|
|
5
|
+
* - MarketplacesRegistry: which marketplace catalogs the user has added (config)
|
|
6
|
+
* - InstalledPluginsRegistry: which plugins are installed (data, Claude Code-compatible)
|
|
7
|
+
*
|
|
8
|
+
* The installed registry MUST pass `parseClaudePluginsRegistry()` validation —
|
|
9
|
+
* it uses `version: 2` (numeric) and `plugins: Record<string, ...[]>`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ── Plugin ID helpers ────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
15
|
+
const MAX_NAME_LENGTH = 64;
|
|
16
|
+
const MAX_ID_LENGTH = 128;
|
|
17
|
+
|
|
18
|
+
/** Validate a plugin or marketplace name segment. */
|
|
19
|
+
export function isValidNameSegment(s: string): boolean {
|
|
20
|
+
return s.length > 0 && s.length <= MAX_NAME_LENGTH && NAME_RE.test(s);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Build canonical plugin ID: `"name@marketplace"`. Both segments are validated. */
|
|
24
|
+
export function buildPluginId(name: string, marketplace: string): string {
|
|
25
|
+
if (!isValidNameSegment(name)) {
|
|
26
|
+
throw new Error(`Invalid plugin name: "${name}"`);
|
|
27
|
+
}
|
|
28
|
+
if (!isValidNameSegment(marketplace)) {
|
|
29
|
+
throw new Error(`Invalid marketplace name: "${marketplace}"`);
|
|
30
|
+
}
|
|
31
|
+
const id = `${name}@${marketplace}`;
|
|
32
|
+
if (id.length > MAX_ID_LENGTH) {
|
|
33
|
+
throw new Error(`Plugin ID exceeds ${MAX_ID_LENGTH} characters: "${id}"`);
|
|
34
|
+
}
|
|
35
|
+
return id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse `"name@marketplace"` → `{ name, marketplace }` or `null`. */
|
|
39
|
+
export function parsePluginId(id: string): { name: string; marketplace: string } | null {
|
|
40
|
+
const atIndex = id.lastIndexOf("@");
|
|
41
|
+
if (atIndex <= 0 || atIndex === id.length - 1) return null;
|
|
42
|
+
|
|
43
|
+
const name = id.slice(0, atIndex);
|
|
44
|
+
const marketplace = id.slice(atIndex + 1);
|
|
45
|
+
|
|
46
|
+
if (!isValidNameSegment(name) || !isValidNameSegment(marketplace)) return null;
|
|
47
|
+
|
|
48
|
+
return { name, marketplace };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Marketplace catalog (from marketplace.json in a marketplace repo) ─
|
|
52
|
+
|
|
53
|
+
export interface MarketplaceCatalogOwner {
|
|
54
|
+
name: string;
|
|
55
|
+
email?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MarketplaceCatalogMetadata {
|
|
59
|
+
description?: string;
|
|
60
|
+
version?: string;
|
|
61
|
+
/** If set, prepended to relative plugin source paths. */
|
|
62
|
+
pluginRoot?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MarketplaceCatalog {
|
|
66
|
+
name: string;
|
|
67
|
+
owner: MarketplaceCatalogOwner;
|
|
68
|
+
metadata?: MarketplaceCatalogMetadata;
|
|
69
|
+
plugins: MarketplacePluginEntry[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MarketplacePluginAuthor {
|
|
73
|
+
name: string;
|
|
74
|
+
email?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MarketplacePluginEntry {
|
|
78
|
+
name: string;
|
|
79
|
+
source: PluginSource;
|
|
80
|
+
description?: string;
|
|
81
|
+
version?: string;
|
|
82
|
+
author?: MarketplacePluginAuthor;
|
|
83
|
+
homepage?: string;
|
|
84
|
+
repository?: string;
|
|
85
|
+
license?: string;
|
|
86
|
+
keywords?: string[];
|
|
87
|
+
category?: string;
|
|
88
|
+
tags?: string[];
|
|
89
|
+
strict?: boolean;
|
|
90
|
+
commands?: string | string[];
|
|
91
|
+
agents?: string | string[];
|
|
92
|
+
hooks?: string | Record<string, unknown>;
|
|
93
|
+
mcpServers?: string | Record<string, unknown>;
|
|
94
|
+
lspServers?: string | Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Plugin source variants ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export type PluginSource =
|
|
100
|
+
| string // relative path "./plugins/foo"
|
|
101
|
+
| PluginSourceGitHub
|
|
102
|
+
| PluginSourceUrl
|
|
103
|
+
| PluginSourceGitSubdir
|
|
104
|
+
| PluginSourceNpm;
|
|
105
|
+
|
|
106
|
+
export interface PluginSourceGitHub {
|
|
107
|
+
source: "github";
|
|
108
|
+
repo: string;
|
|
109
|
+
ref?: string;
|
|
110
|
+
sha?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PluginSourceUrl {
|
|
114
|
+
source: "url";
|
|
115
|
+
url: string;
|
|
116
|
+
ref?: string;
|
|
117
|
+
sha?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface PluginSourceGitSubdir {
|
|
121
|
+
source: "git-subdir";
|
|
122
|
+
url: string;
|
|
123
|
+
path: string;
|
|
124
|
+
ref?: string;
|
|
125
|
+
sha?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PluginSourceNpm {
|
|
129
|
+
source: "npm";
|
|
130
|
+
package: string;
|
|
131
|
+
version?: string;
|
|
132
|
+
registry?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Marketplaces registry (stored in <configRoot>/marketplaces.json) ─
|
|
136
|
+
|
|
137
|
+
export interface MarketplacesRegistry {
|
|
138
|
+
version: 1;
|
|
139
|
+
marketplaces: MarketplaceRegistryEntry[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type MarketplaceSourceType = "github" | "git" | "url" | "local";
|
|
143
|
+
|
|
144
|
+
export interface MarketplaceRegistryEntry {
|
|
145
|
+
name: string;
|
|
146
|
+
sourceType: MarketplaceSourceType;
|
|
147
|
+
sourceUri: string;
|
|
148
|
+
catalogPath: string;
|
|
149
|
+
addedAt: string;
|
|
150
|
+
updatedAt: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Installed plugins registry ───────────────────────────────────────
|
|
154
|
+
// MUST match ClaudePluginsRegistry shape for parseClaudePluginsRegistry()
|
|
155
|
+
// compatibility: `version: number`, `plugins: Record<string, entry[]>`.
|
|
156
|
+
|
|
157
|
+
export interface InstalledPluginsRegistry {
|
|
158
|
+
/** MUST be 2 — parseClaudePluginsRegistry rejects non-numeric version. */
|
|
159
|
+
version: 2;
|
|
160
|
+
plugins: Record<string, InstalledPluginEntry[]>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface InstalledPluginEntry {
|
|
164
|
+
/** v1 is user-only — always "user". */
|
|
165
|
+
scope: "user";
|
|
166
|
+
/** Absolute path to cached plugin directory. */
|
|
167
|
+
installPath: string;
|
|
168
|
+
version: string;
|
|
169
|
+
/** ISO 8601 date string. */
|
|
170
|
+
installedAt: string;
|
|
171
|
+
/** ISO 8601 date string. */
|
|
172
|
+
lastUpdated: string;
|
|
173
|
+
/** For git-sourced plugins. */
|
|
174
|
+
gitCommitSha?: string;
|
|
175
|
+
/** OMP extension — not in Claude Code's type. CLI/UI concern only in v1. */
|
|
176
|
+
enabled?: boolean;
|
|
177
|
+
}
|
|
@@ -27,6 +27,7 @@ export * from "./json-query";
|
|
|
27
27
|
export * from "./local-protocol";
|
|
28
28
|
export * from "./mcp-protocol";
|
|
29
29
|
export * from "./memory-protocol";
|
|
30
|
+
export * from "./parse";
|
|
30
31
|
export * from "./pi-protocol";
|
|
31
32
|
export * from "./router";
|
|
32
33
|
export * from "./rule-protocol";
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { parseInternalUrl } from "./parse";
|
|
5
6
|
import { validateRelativePath } from "./skill-protocol";
|
|
6
7
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
7
8
|
|
|
@@ -11,25 +12,7 @@ export interface LocalProtocolOptions {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
function parseLocalUrl(input: string): InternalUrl {
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
parsed = new URL(input);
|
|
17
|
-
} catch {
|
|
18
|
-
throw new Error(`Invalid URL: ${input}`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i);
|
|
22
|
-
let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
|
|
23
|
-
try {
|
|
24
|
-
rawHost = decodeURIComponent(rawHost);
|
|
25
|
-
} catch {
|
|
26
|
-
// Leave rawHost as-is if decoding fails.
|
|
27
|
-
}
|
|
28
|
-
(parsed as InternalUrl).rawHost = rawHost;
|
|
29
|
-
|
|
30
|
-
const pathMatch = input.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i);
|
|
31
|
-
(parsed as InternalUrl).rawPathname = pathMatch?.[1] ?? parsed.pathname;
|
|
32
|
-
return parsed as InternalUrl;
|
|
15
|
+
return parseInternalUrl(input);
|
|
33
16
|
}
|
|
34
17
|
|
|
35
18
|
function ensureWithinRoot(targetPath: string, rootPath: string): void {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal URL parser that handles colons in the host segment.
|
|
3
|
+
*
|
|
4
|
+
* Standard `new URL()` interprets colons as port separators, which breaks
|
|
5
|
+
* namespaced internal URLs like `skill://plugin:name`. This parser extracts
|
|
6
|
+
* components via regex first, then falls back to a minimal URL-like object
|
|
7
|
+
* when `new URL()` fails.
|
|
8
|
+
*
|
|
9
|
+
* All code that parses internal URLs (router, protocol handlers, tools)
|
|
10
|
+
* MUST use this function instead of calling `new URL()` directly.
|
|
11
|
+
*/
|
|
12
|
+
import type { InternalUrl } from "./types";
|
|
13
|
+
|
|
14
|
+
const SCHEME_HOST_RE = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i;
|
|
15
|
+
const PATHNAME_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an internal URL into an InternalUrl.
|
|
19
|
+
*
|
|
20
|
+
* Handles URLs where `new URL()` would fail (e.g., `skill://plugin:name`
|
|
21
|
+
* where the colon is not a port separator).
|
|
22
|
+
*/
|
|
23
|
+
export function parseInternalUrl(input: string): InternalUrl {
|
|
24
|
+
const hostMatch = input.match(SCHEME_HOST_RE);
|
|
25
|
+
const pathMatch = input.match(PATHNAME_RE);
|
|
26
|
+
|
|
27
|
+
let parsed: URL;
|
|
28
|
+
try {
|
|
29
|
+
parsed = new URL(input);
|
|
30
|
+
} catch {
|
|
31
|
+
// URL parse failed — build a minimal URL-like object from regex matches.
|
|
32
|
+
if (!hostMatch) {
|
|
33
|
+
throw new Error(`Invalid URL: ${input}`);
|
|
34
|
+
}
|
|
35
|
+
// Extract search and hash from the raw input before constructing the object.
|
|
36
|
+
const hashIdx = input.indexOf("#");
|
|
37
|
+
const hash = hashIdx !== -1 ? input.slice(hashIdx) : "";
|
|
38
|
+
const withoutHash = hashIdx !== -1 ? input.slice(0, hashIdx) : input;
|
|
39
|
+
const queryIdx = withoutHash.indexOf("?");
|
|
40
|
+
const search = queryIdx !== -1 ? withoutHash.slice(queryIdx) : "";
|
|
41
|
+
const queryString = search.slice(1); // strip leading ?
|
|
42
|
+
|
|
43
|
+
// Strip search/hash from pathname captured by regex.
|
|
44
|
+
let rawPathname = pathMatch?.[1] ?? "";
|
|
45
|
+
if (queryIdx !== -1 && rawPathname.includes("?")) {
|
|
46
|
+
rawPathname = rawPathname.slice(0, rawPathname.indexOf("?"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
parsed = {
|
|
50
|
+
protocol: `${hostMatch[1]}:`,
|
|
51
|
+
hostname: hostMatch[2] ?? "",
|
|
52
|
+
host: hostMatch[2] ?? "",
|
|
53
|
+
pathname: rawPathname,
|
|
54
|
+
href: input,
|
|
55
|
+
search,
|
|
56
|
+
hash,
|
|
57
|
+
searchParams: new URLSearchParams(queryString),
|
|
58
|
+
} as unknown as URL;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
|
|
62
|
+
try {
|
|
63
|
+
rawHost = decodeURIComponent(rawHost);
|
|
64
|
+
} catch {
|
|
65
|
+
// Leave rawHost as-is if decoding fails.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = parsed as InternalUrl;
|
|
69
|
+
result.rawHost = rawHost;
|
|
70
|
+
result.rawPathname = pathMatch?.[1] ?? parsed.pathname;
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Internal URL router for internal protocols (agent://, artifact://, memory://, skill://, rule://, mcp://, pi://, local://).
|
|
3
3
|
*/
|
|
4
|
+
import { parseInternalUrl } from "./parse";
|
|
4
5
|
import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -37,24 +38,7 @@ export class InternalUrlRouter {
|
|
|
37
38
|
* @throws Error if scheme is not registered or resolution fails
|
|
38
39
|
*/
|
|
39
40
|
async resolve(input: string): Promise<InternalResource> {
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
parsed = new URL(input);
|
|
43
|
-
} catch {
|
|
44
|
-
throw new Error(`Invalid URL: ${input}`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const hostMatch = input.match(/^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i);
|
|
48
|
-
let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
|
|
49
|
-
try {
|
|
50
|
-
rawHost = decodeURIComponent(rawHost);
|
|
51
|
-
} catch {
|
|
52
|
-
// Leave rawHost as-is if decoding fails.
|
|
53
|
-
}
|
|
54
|
-
(parsed as InternalUrl).rawHost = rawHost;
|
|
55
|
-
const pathMatch = input.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i);
|
|
56
|
-
(parsed as InternalUrl).rawPathname = pathMatch?.[1] ?? parsed.pathname;
|
|
57
|
-
|
|
41
|
+
const parsed = parseInternalUrl(input);
|
|
58
42
|
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
59
43
|
const handler = this.#handlers.get(scheme);
|
|
60
44
|
|
package/src/lsp/config.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { isRecord, logger } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { YAML } from "bun";
|
|
6
6
|
import { getConfigDirPaths } from "../config";
|
|
7
|
+
import { getPreloadedPluginRoots } from "../discovery/helpers";
|
|
7
8
|
import { BiomeClient } from "./clients/biome-client";
|
|
8
9
|
import { SwiftLintClient } from "./clients/swiftlint-client";
|
|
9
10
|
import DEFAULTS from "./defaults.json" with { type: "json" };
|
|
@@ -248,6 +249,14 @@ function getConfigPaths(cwd: string): string[] {
|
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
// Plugin LSP configs (from marketplace/--plugin-dir roots)
|
|
253
|
+
const pluginRoots = getPreloadedPluginRoots();
|
|
254
|
+
for (const root of pluginRoots) {
|
|
255
|
+
for (const filename of filenames) {
|
|
256
|
+
paths.push(path.join(root.path, filename));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
251
260
|
// User home root files (lowest priority fallback)
|
|
252
261
|
for (const filename of filenames) {
|
|
253
262
|
paths.push(path.join(os.homedir(), filename));
|