Files
oh-my-pi/packages/coding-agent/src/internal-urls/parse.ts
T
Miroslav Drbalandcan1357 4e199f93f7 feat: add marketplace plugin system
Add Claude Code-compatible marketplace plugin infrastructure:

- Registry: types, ID helpers, atomic read/write for marketplaces.json
  and installed_plugins.json (Claude Code format with version: 2)
- Fetcher: classifySource (6 ordered rules), parseMarketplaceCatalog,
  fetchMarketplace (local sources; git/http stubs for Phase 2)
- Resolver + Cache: resolvePluginSource with pathIsWithin containment,
  cachePlugin, removeCachedPlugin, cleanOrphanedCache
- MarketplaceManager: orchestrates add/remove/update marketplaces,
  install/uninstall/enable plugins, clearPluginRootsCache on mutation
- CLI: omp plugin marketplace add|remove|update|list,
  omp plugin discover, classifyInstallTarget for name@marketplace
- Discovery: listClaudePluginRoots reads OMP registry alongside
  Claude's, OMP authoritative for duplicate plugin IDs
- Args: --plugin-dir repeatable flag (parsing only, runtime wiring TBD)
- Slash command: /reload-plugins clears fs + roots cache
- Test fixtures and 130 tests across 8 test files
2026-03-30 13:44:53 +02:00

73 lines
2.3 KiB
TypeScript

/**
* Internal URL parser that handles colons in the host segment.
*
* Standard `new URL()` interprets colons as port separators, which breaks
* namespaced internal URLs like `skill://plugin:name`. This parser extracts
* components via regex first, then falls back to a minimal URL-like object
* when `new URL()` fails.
*
* All code that parses internal URLs (router, protocol handlers, tools)
* MUST use this function instead of calling `new URL()` directly.
*/
import type { InternalUrl } from "./types";
const SCHEME_HOST_RE = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i;
const PATHNAME_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i;
/**
* Parse an internal URL into an InternalUrl.
*
* Handles URLs where `new URL()` would fail (e.g., `skill://plugin:name`
* where the colon is not a port separator).
*/
export function parseInternalUrl(input: string): InternalUrl {
const hostMatch = input.match(SCHEME_HOST_RE);
const pathMatch = input.match(PATHNAME_RE);
let parsed: URL;
try {
parsed = new URL(input);
} catch {
// URL parse failed — build a minimal URL-like object from regex matches.
if (!hostMatch) {
throw new Error(`Invalid URL: ${input}`);
}
// Extract search and hash from the raw input before constructing the object.
const hashIdx = input.indexOf("#");
const hash = hashIdx !== -1 ? input.slice(hashIdx) : "";
const withoutHash = hashIdx !== -1 ? input.slice(0, hashIdx) : input;
const queryIdx = withoutHash.indexOf("?");
const search = queryIdx !== -1 ? withoutHash.slice(queryIdx) : "";
const queryString = search.slice(1); // strip leading ?
// Strip search/hash from pathname captured by regex.
let rawPathname = pathMatch?.[1] ?? "";
if (queryIdx !== -1 && rawPathname.includes("?")) {
rawPathname = rawPathname.slice(0, rawPathname.indexOf("?"));
}
parsed = {
protocol: `${hostMatch[1]}:`,
hostname: hostMatch[2] ?? "",
host: hostMatch[2] ?? "",
pathname: rawPathname,
href: input,
search,
hash,
searchParams: new URLSearchParams(queryString),
} as unknown as URL;
}
let rawHost = hostMatch ? hostMatch[2] : parsed.hostname;
try {
rawHost = decodeURIComponent(rawHost);
} catch {
// Leave rawHost as-is if decoding fails.
}
const result = parsed as InternalUrl;
result.rawHost = rawHost;
result.rawPathname = pathMatch?.[1] ?? parsed.pathname;
return result;
}