feat(coding-agent): accept GitHub/git URLs in plugin install

Extends `omp plugin install` to accept git sources alongside npm specs and
marketplace refs. Bun's installer already understands git URLs; the blocker
was `PluginManager.install`'s strict npm-name validator and the assumption
that the actual package name could be derived from the spec.

- `git-url.ts`: `parseGitUrl` now recognizes npm-style namespaced shorthand
  (`github:user/repo`, `gitlab:`, `bitbucket:`, `codeberg:`, `sourcehut:` /
  `srht:`), with optional `#ref` and `.git` suffix. Exposes `isGitSpec` as
  `parseGitUrl(s) !== null`. Existing protocol-URL and `git:` shorthand paths
  are untouched.
- `manager.ts`: `install()` branches on `isGitSpec`. Git specs go through a
  separate `validateGitSpec` (shell-metachar rejection only — `/`, `:`, `@`,
  `#`, `+` are legal) and the real package name is discovered by snapshotting
  `plugins/package.json` deps before `bun install` and diffing afterwards.
  Falls back to value-match on force-reinstall where the key already exists.
- Help text in `plugin-cli` documents the new sources and adds a github:
  example.

Smoke tested end-to-end on Windows with both forms against the test repo:
  PluginManager.install('github:oldschoola/omp-insights')
  PluginManager.install('https://github.com/oldschoola/omp-insights')
both resolve `@oldschoola/omp-insights@1.2.3` and write a correct lock entry.
Shell-injection probe (`github:foo/bar; rm -rf /`) is rejected.
This commit is contained in:
oldschoola
2026-05-29 21:57:00 -07:00
committed by can1357
parent 4ab0360f65
commit 22e564a85d
6 changed files with 431 additions and 10 deletions
+3
View File
@@ -123,6 +123,9 @@
- Fixed the bash (and `recipe`) tool result footer not rendering for failed commands. A non-zero exit threw a `ToolError`, which dropped the result details, so the styled `⟨Wall … | Timeout …⟩` footer was replaced by the raw `Wall time: … seconds` / `Command exited with code N` lines. Non-zero exits now resolve as a non-throwing error result that keeps `wallTimeMs`/`timeoutSeconds`/`exitCode`, and the footer shows `⟨Wall … | Timeout … | Exit: N⟩` with the textual notices folded out of the output pane. Aborts, timeouts, and missing-exit-status still throw as before.
- Fixed selector-style UI components to honor `tui.select.up` and `tui.select.down` keybindings instead of hard-coding raw Up/Down arrow bytes ([#1535](https://github.com/can1357/oh-my-pi/issues/1535)).
### Added
- `omp plugin install` now accepts GitHub/GitLab/Bitbucket shorthand (`github:user/repo`, `gitlab:user/repo`, …) and full git URLs (`https://github.com/user/repo`, `git@github.com:user/repo`, …) in addition to npm specs and marketplace refs.
## [15.5.15] - 2026-05-30
### Changed
+11 -2
View File
@@ -348,10 +348,12 @@ async function handleInstall(
flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" },
): Promise<void> {
if (packages.length === 0) {
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <package[@version]>[features] ...`));
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <source>[features] ...`));
console.error(chalk.dim("Examples:"));
console.error(chalk.dim(` ${APP_NAME} plugin install @oh-my-pi/exa`));
console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`));
console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`));
console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`));
process.exit(1);
}
@@ -898,7 +900,7 @@ export function printPluginHelp(): void {
console.log(`${chalk.bold(`${APP_NAME} plugin`)} - Plugin lifecycle management
${chalk.bold("Commands:")}
install <pkg[@ver]>[features] Install plugins from npm
install <source>[features] Install plugins from npm, GitHub, or git URL
uninstall <pkg> Remove plugins
list Show installed plugins
link <path> Link local plugin for development
@@ -916,6 +918,12 @@ ${chalk.bold("Feature Syntax:")}
pkg[*] Install with all features
pkg[] Install with no optional features
${chalk.bold("Sources:")}
pkg, pkg@1.2.3 npm package (optionally pinned)
github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:)
https://github.com/user/repo Full git URL (https, ssh, or git protocol)
name@marketplace Marketplace plugin (see marketplace command)
${chalk.bold("Config Subcommands:")}
config list <pkg> List all settings
config get <pkg> <key> Get a setting value
@@ -938,5 +946,6 @@ ${chalk.bold("Examples:")}
${APP_NAME} plugin config set my-plugin apiKey sk-xxx
${APP_NAME} plugin doctor --fix
${APP_NAME} plugin install --scope project name@marketplace
${APP_NAME} plugin install github:oldschoola/omp-insights
`);
}
@@ -25,6 +25,29 @@ const KNOWN_HOSTS: Record<string, (pathname: string, hash: string) => { user: st
"codeberg.org": extractStandard,
};
/**
* Namespaced shorthand prefixes recognised by bun's `bun install <spec>`, mapped
* to their canonical host. Mirrors npm's `github:`, `gitlab:`, `bitbucket:` etc.
* shorthand so callers can pass them through to bun verbatim.
*/
const SHORTHAND_PREFIXES: Record<string, string> = {
github: "github.com",
gitlab: "gitlab.com",
bitbucket: "bitbucket.org",
codeberg: "codeberg.org",
sourcehut: "git.sr.ht",
srht: "git.sr.ht",
};
/**
* `<prefix>:<user>/<repo>[.git][#<ref>]` shape. `<repo>` is non-greedy so the
* optional `.git` suffix and `#ref` tail bind tightly; `<repo>` may itself
* contain `/` to support nested GitLab groups (`gitlab:group/sub/project`).
* `<user>` rejects `/`, `:`, `#` to keep protocol URLs (`https://…`) and
* scp-like SSH (`git@github.com:user/repo`) out of this path.
*/
const SHORTHAND_RE = /^([a-z]+):([^/:#]+)\/([^#]+?)(?:\.git)?(?:#(.+))?$/i;
function stripUrlCredentials(url: string): string {
if (!url.includes("://")) return url;
try {
@@ -209,14 +232,55 @@ function parseGenericGitUrl(url: string): GitSource | null {
return { type: "git", repo, host, path: normalizedPath, ref, pinned: Boolean(ref) };
}
/**
* Match an npm/bun-style namespaced shorthand (`github:user/repo`, optionally
* `…#ref` or `….git`). Returns null for protocol URLs and any prefix not in
* `SHORTHAND_PREFIXES` so the caller can fall through to the generic paths.
*/
function tryNamespacedShorthand(trimmed: string): GitSource | null {
// Cheap gate: bail out before touching protocol URLs (`https://`, `ssh://`,
// `git://`) where the char after the colon is always `/`. The shorthand we
// care about never starts with `<scheme>://`.
if (!/^[a-z]+:[^/]/i.test(trimmed)) return null;
const match = trimmed.match(SHORTHAND_RE);
if (!match) return null;
const prefix = (match[1] ?? "").toLowerCase();
const host = SHORTHAND_PREFIXES[prefix];
if (!host) return null;
const user = match[2] ?? "";
const repoPath = match[3] ?? "";
if (!user || !repoPath) return null;
const ref = match[4];
if (ref) {
try {
decodeURIComponent(ref);
} catch {
return null;
}
}
const fullPath = `${user}/${repoPath}`;
return {
type: "git",
repo: `https://${host}/${fullPath}`,
host,
path: fullPath,
ref: ref || undefined,
pinned: Boolean(ref),
};
}
/**
* Parse git source into a GitSource.
*
* Rules:
* - With `git:` prefix, accept shorthand forms.
* - Namespaced shorthand (`github:user/repo`, `gitlab:`, `bitbucket:`,
* `codeberg:`, `sourcehut:`/`srht:`) is accepted directly — these mirror
* bun/npm's built-in install shorthands and skip the rest of the pipeline.
* - With `git:` prefix, accept generic shorthand forms.
* - Without `git:` prefix, only accept explicit protocol URLs.
*
* Handles:
* - `github:user/repo[#ref]`-style namespaced shorthand
* - `git:` prefixed URLs (`git:github.com/user/repo`)
* - SSH SCP-like URLs (`git:git@github.com:user/repo`)
* - HTTPS/HTTP/SSH/git protocol URLs
@@ -227,6 +291,10 @@ function parseGenericGitUrl(url: string): GitSource | null {
*/
export function parseGitUrl(source: string): GitSource | null {
const trimmed = source.trim();
const shorthand = tryNamespacedShorthand(trimmed);
if (shorthand) return shorthand;
const hasGitPrefix = /^git:(?!\/\/)/i.test(trimmed);
const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
@@ -279,3 +347,12 @@ export function parseGitUrl(source: string): GitSource | null {
return parseGenericGitUrl(url);
}
/**
* Returns true if the spec is parseable as a git source (protocol URL,
* scp-like SSH wrapped in `git:`, plain `git:` shorthand, or namespaced
* shorthand like `github:user/repo`). The inverse of "this is an npm spec".
*/
export function isGitSpec(spec: string): boolean {
return parseGitUrl(spec) !== null;
}
@@ -10,6 +10,7 @@ import {
isEnoent,
logger,
} from "@oh-my-pi/pi-utils";
import { isGitSpec } from "./git-url";
import { extractPackageName, parsePluginSpec } from "./parser";
import type {
DoctorCheck,
@@ -29,8 +30,14 @@ import type {
/** Valid npm package name pattern (scoped and unscoped, with optional version) */
const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i;
/** Characters that are never valid in any plugin install spec — git or npm. */
const SHELL_METACHARS = /[;&|`$(){}<>\\\n\r\t]/;
/**
* Validate package name to prevent command injection.
* Validate package name to prevent command injection. npm specs only — git
* specs (`github:user/repo`, `https://github.com/...`, ...) MUST go through
* {@link validateGitSpec} instead because they contain characters npm rejects
* (`:`, `/`, `#`, `+`, `@` in non-version positions).
*/
function validatePackageName(name: string): void {
// Remove version specifier for validation
@@ -44,6 +51,19 @@ function validatePackageName(name: string): void {
}
}
/**
* Validate a git install spec — accepts `:`, `/`, `#`, `+`, `.`, `-`, `_`,
* `~`, `@` (which would all fail {@link validatePackageName}) but rejects
* shell metacharacters so the spec stays safe when forwarded to bun install.
* `Bun.spawn` does not invoke a shell, but defense-in-depth keeps things
* obvious for future readers.
*/
function validateGitSpec(spec: string): void {
if (SHELL_METACHARS.test(spec)) {
throw new Error(`Invalid characters in plugin source: ${spec}`);
}
}
// =============================================================================
// Plugin Manager
// =============================================================================
@@ -127,12 +147,39 @@ export class PluginManager {
}
}
/**
* Read the `dependencies` map from `plugins/package.json`. Returns an empty
* object when the file does not exist yet so callers can diff `before`
* against `after` to discover the package bun just installed under its
* real name (git specs do not encode the package name in the spec itself).
*/
async #readDeps(pkgJsonPath: string): Promise<Record<string, string>> {
try {
const json = await Bun.file(pkgJsonPath).json();
return (json.dependencies as Record<string, string>) ?? {};
} catch (err) {
if (isEnoent(err)) return {};
throw err;
}
}
// ==========================================================================
// Install / Uninstall
// ==========================================================================
/**
* Install a plugin from npm with optional feature selection.
* Install a plugin with optional feature selection.
*
* Accepts:
* - npm specs: `pkg`, `pkg@1.2.3`, `@scope/pkg`, `pkg[features]`
* - namespaced git shorthand: `github:user/repo[#ref]`, `gitlab:`, `bitbucket:`,
* `codeberg:`, `sourcehut:`/`srht:`
* - full git URLs: `https://github.com/user/repo`, `git@github.com:user/repo`,
* `ssh://…`, `git+https://…`
*
* For git specs the package name is not knowable from the spec, so the
* installer diffs `plugins/package.json` `dependencies` before and after
* to find the newly added key.
*
* @param specString - Package specifier with optional features: "pkg", "pkg[feat]", "pkg[*]", "pkg[]"
* @param options - Install options
@@ -140,7 +187,12 @@ export class PluginManager {
*/
async install(specString: string, options: InstallOptions = {}): Promise<InstalledPlugin> {
const spec = parsePluginSpec(specString);
validatePackageName(spec.packageName);
const gitSource = isGitSpec(spec.packageName);
if (gitSource) {
validateGitSpec(spec.packageName);
} else {
validatePackageName(spec.packageName);
}
await this.#ensurePackageJson();
@@ -154,6 +206,8 @@ export class PluginManager {
enabled: true,
};
}
const pkgJsonPath = getPluginsPackageJson();
const depsBefore = gitSource ? await this.#readDeps(pkgJsonPath) : {};
// Run npm install
const proc = Bun.spawn(["bun", "install", spec.packageName], {
@@ -169,9 +223,39 @@ export class PluginManager {
const stderr = await new Response(proc.stderr).text();
throw new Error(`npm install failed: ${stderr}`);
}
// Resolve actual package name (strip version specifier)
const actualName = extractPackageName(spec.packageName);
// Resolve actual package name. npm specs encode the name (strip version);
// git specs do not, so diff plugins/package.json deps to find the new entry.
let actualName: string;
if (gitSource) {
const depsAfter = await this.#readDeps(pkgJsonPath);
let resolved: string | undefined;
for (const key of Object.keys(depsAfter)) {
if (!(key in depsBefore)) {
resolved = key;
break;
}
}
// Fallback: a force-reinstall of an already-present git plugin will not
// add a new key, just rewrite the existing one to the new spec value.
// Match by the value containing the original spec instead.
if (!resolved) {
const needle = spec.packageName.replace(/^git\+/, "");
for (const [key, value] of Object.entries(depsAfter)) {
if (typeof value === "string" && value.includes(needle)) {
resolved = key;
break;
}
}
}
if (!resolved) {
throw new Error(
`Installed ${spec.packageName} but could not determine package name from plugins/package.json`,
);
}
actualName = resolved;
} else {
actualName = extractPackageName(spec.packageName);
}
const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json");
let pkg: { name: string; version: string; omp?: PluginManifest; pi?: PluginManifest };
+126 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { parseGitUrl } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/git-url";
import { isGitSpec, parseGitUrl } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/git-url";
describe("parseGitUrl", () => {
describe("protocol URLs (accepted without git: prefix)", () => {
@@ -76,4 +76,129 @@ describe("parseGitUrl", () => {
expect(parseGitUrl("vendor/github.enterprise/tools")).toBeNull();
});
});
describe("namespaced shorthand (github:user/repo, gitlab:, …)", () => {
test("parses github: shorthand", () => {
expect(parseGitUrl("github:user/repo")).toEqual({
type: "git",
host: "github.com",
path: "user/repo",
repo: "https://github.com/user/repo",
ref: undefined,
pinned: false,
});
});
test("captures #ref and marks pinned", () => {
const result = parseGitUrl("github:user/repo#v1.0");
expect(result).toMatchObject({
type: "git",
host: "github.com",
path: "user/repo",
repo: "https://github.com/user/repo",
ref: "v1.0",
pinned: true,
});
});
test("strips trailing .git from the path", () => {
const result = parseGitUrl("github:user/repo.git");
expect(result).toMatchObject({
type: "git",
host: "github.com",
path: "user/repo",
repo: "https://github.com/user/repo",
pinned: false,
});
});
test("maps gitlab: to gitlab.com", () => {
expect(parseGitUrl("gitlab:user/repo")).toMatchObject({
type: "git",
host: "gitlab.com",
path: "user/repo",
repo: "https://gitlab.com/user/repo",
});
});
test("maps bitbucket: to bitbucket.org", () => {
expect(parseGitUrl("bitbucket:user/repo")).toMatchObject({
type: "git",
host: "bitbucket.org",
path: "user/repo",
repo: "https://bitbucket.org/user/repo",
});
});
test("maps codeberg: to codeberg.org", () => {
expect(parseGitUrl("codeberg:user/repo")).toMatchObject({
type: "git",
host: "codeberg.org",
path: "user/repo",
repo: "https://codeberg.org/user/repo",
});
});
test("maps sourcehut: and srht: to git.sr.ht", () => {
expect(parseGitUrl("sourcehut:user/repo")).toMatchObject({
type: "git",
host: "git.sr.ht",
path: "user/repo",
repo: "https://git.sr.ht/user/repo",
});
expect(parseGitUrl("srht:user/repo")).toMatchObject({
type: "git",
host: "git.sr.ht",
path: "user/repo",
repo: "https://git.sr.ht/user/repo",
});
});
test("rejects missing repo segment", () => {
expect(parseGitUrl("github:user")).toBeNull();
});
test("rejects empty body", () => {
expect(parseGitUrl("github:")).toBeNull();
});
test("rejects unknown shorthand prefix", () => {
expect(parseGitUrl("notahost:user/repo")).toBeNull();
});
test("does not swallow protocol URLs (regression)", () => {
expect(parseGitUrl("https://github.com/user/repo")).toMatchObject({
type: "git",
host: "github.com",
path: "user/repo",
repo: "https://github.com/user/repo",
});
});
});
});
describe("isGitSpec", () => {
test("returns true for namespaced shorthand", () => {
expect(isGitSpec("github:user/repo")).toBe(true);
});
test("returns true for https git URLs", () => {
expect(isGitSpec("https://github.com/user/repo")).toBe(true);
});
test("returns false for unprefixed scp-like ssh (parseGitUrl rejects it)", () => {
expect(isGitSpec("git@github.com:user/repo")).toBe(false);
});
test("returns false for bare npm name", () => {
expect(isGitSpec("my-plugin")).toBe(false);
});
test("returns false for scoped npm name", () => {
expect(isGitSpec("@scope/pkg")).toBe(false);
});
test("returns false for scoped npm name with version", () => {
expect(isGitSpec("@scope/pkg@1.2.3")).toBe(false);
});
});
@@ -0,0 +1,123 @@
/**
* Install-from-git tests for `PluginManager.install`.
*
* Strategy: spy on the six `@oh-my-pi/pi-utils` plugin-path getters so the
* manager points at a temp directory tree, then spy on `Bun.spawn` so we can
* simulate `bun install <git-spec>`'s side effects (writing the dep into
* `plugins/package.json` under its real name, and dropping a matching
* `node_modules/<name>/package.json`). This exercises the real
* `PluginManager.install` end-to-end without hitting the network.
*
* `vi.spyOn` + `vi.restoreAllMocks()` is the same pattern used by
* `test/tools/report-tool-issue.test.ts` (which spies on
* `piUtils.getInstallId`), so we know namespace spying on `pi-utils` exports
* propagates through to consumers of the barrel re-exports. The
* `vi.spyOn(Bun, "spawn")` mock follows `test/git-process-config.test.ts`.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { PluginManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/manager";
import * as piUtils from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
function emptyStream(): ReadableStream<Uint8Array> {
const body = new Response("").body;
if (!body) {
throw new Error("Failed to create empty response stream");
}
return body;
}
describe("PluginManager.install with git sources", () => {
let tmpRoot: string;
let pluginsDir: string;
let pluginsNodeModules: string;
let pluginsPkgJson: string;
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-plugin-git-"));
pluginsDir = path.join(tmpRoot, "plugins");
pluginsNodeModules = path.join(pluginsDir, "node_modules");
pluginsPkgJson = path.join(pluginsDir, "package.json");
await fs.mkdir(pluginsNodeModules, { recursive: true });
vi.spyOn(piUtils, "getPluginsDir").mockReturnValue(pluginsDir);
vi.spyOn(piUtils, "getPluginsNodeModules").mockReturnValue(pluginsNodeModules);
vi.spyOn(piUtils, "getPluginsPackageJson").mockReturnValue(pluginsPkgJson);
vi.spyOn(piUtils, "getPluginsLockfile").mockReturnValue(path.join(tmpRoot, "omp-plugins.lock.json"));
vi.spyOn(piUtils, "getProjectDir").mockReturnValue(tmpRoot);
vi.spyOn(piUtils, "getProjectPluginOverridesPath").mockReturnValue(path.join(tmpRoot, "plugin-overrides.json"));
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tmpRoot, { recursive: true, force: true });
});
test("installs from github: shorthand and resolves real package name from deps diff", async () => {
// Seed the plugins manifest so install()'s `depsBefore` snapshot is empty
// rather than triggering #ensurePackageJson's bootstrap path.
await Bun.write(
pluginsPkgJson,
JSON.stringify({ name: "omp-plugins", private: true, dependencies: {} }, null, 2),
);
vi.spyOn(Bun, "spawn").mockImplementation(((cmd: string[]) => {
// Verify the manager forwards the spec verbatim to bun install.
expect(cmd[0]).toBe("bun");
expect(cmd[1]).toBe("install");
expect(cmd[2]).toBe("github:foo/bar");
// Simulate the on-disk side effects bun install produces for a git
// source: a new dep keyed by the package's own `name` field, plus
// the corresponding entry under node_modules.
const prepare = (async () => {
await Bun.write(
pluginsPkgJson,
JSON.stringify(
{
name: "omp-plugins",
private: true,
dependencies: { "real-name": "github:foo/bar" },
},
null,
2,
),
);
const installedDir = path.join(pluginsNodeModules, "real-name");
await fs.mkdir(installedDir, { recursive: true });
await Bun.write(
path.join(installedDir, "package.json"),
JSON.stringify({ name: "real-name", version: "0.1.0" }, null, 2),
);
})();
return {
pid: 1,
stdout: emptyStream(),
stderr: emptyStream(),
exited: prepare.then(() => 0),
} as Subprocess;
}) as typeof Bun.spawn);
const mgr = new PluginManager(tmpRoot);
const result = await mgr.install("github:foo/bar");
expect(result.name).toBe("real-name");
expect(result.version).toBe("0.1.0");
expect(result.enabled).toBe(true);
expect(result.path).toBe(path.join(pluginsNodeModules, "real-name"));
});
test("rejects git specs containing shell metacharacters", async () => {
const mgr = new PluginManager(tmpRoot);
await expect(mgr.install("github:foo/bar; rm -rf /")).rejects.toThrow(/Invalid characters in plugin source/);
});
test("still rejects invalid npm names with the original error", async () => {
const mgr = new PluginManager(tmpRoot);
await expect(mgr.install("Invalid Name With Spaces")).rejects.toThrow(/Invalid (package name|characters)/);
});
});