Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,46 @@ describe("registry sources", () => {
}
});

it("treats '.', '..', '~/x', and bare relative dirs as local sources", async () => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "registry-source-"));
const child = path.join(temp, "child");
const nested = path.join(child, "nested");
await fs.ensureDir(nested);

try {
expect(await normalizeRegistrySourceInput(".", temp)).toBe(
pathToFileURL(await fs.realpath(temp)).href,
);
expect(await normalizeRegistrySourceInput("..", child)).toBe(
pathToFileURL(await fs.realpath(temp)).href,
);
expect(await normalizeRegistrySourceInput("nested", child)).toBe(
pathToFileURL(await fs.realpath(nested)).href,
);
expect(await normalizeRegistrySourceInput("~", temp)).toBe(
pathToFileURL(await fs.realpath(os.homedir())).href,
);
} finally {
await fs.remove(temp);
}
});

it("falls through to git only for scheme-shaped or nonexistent sources", async () => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "registry-source-"));
try {
expect(await normalizeRegistrySourceInput("https://example.com/x.git", temp)).toBe(
"https://example.com/x.git",
);
expect(await normalizeRegistrySourceInput("git@example.com:x/y.git", temp)).toBe(
"git@example.com:x/y.git",
);
// scheme-less path that doesn't exist locally — git handles the error
expect(await normalizeRegistrySourceInput("nope/nothere", temp)).toBe("nope/nothere");
} finally {
await fs.remove(temp);
}
});

it("reports missing and non-directory local sources clearly", async () => {
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "registry-source-"));
const file = path.join(temp, "file");
Expand Down
35 changes: 32 additions & 3 deletions packages/cli/src/services/skill/registry/skill-registry-source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CliError } from "../../../util/errors.js";
import fs from "fs-extra";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

Expand Down Expand Up @@ -30,20 +31,48 @@ export function parseLocalRegistryPath(value: string): string | null {
}
}

/** URL-ish git sources: `proto://...` or scp-style `user@host:path`. */
const GIT_SOURCE_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/|^[\w.+-]+@[\w.-]+:/;

function isPathShorthand(value: string): boolean {
return path.isAbsolute(value) || /^\.\.?[\\/]/.test(value);
return (
path.isAbsolute(value) ||
value === "." ||
value === ".." ||
value === "~" ||
value.startsWith("~/") ||
value.startsWith(`~${path.sep}`) ||
/^\.\.?[\\/]/.test(value)
);
}

/** Resolve a candidate local path — expands `~` and joins relative to baseDir. */
function resolveLocalCandidate(value: string, baseDir: string): string {
if (value === "~" || value.startsWith("~/") || value.startsWith(`~${path.sep}`)) {
return path.join(os.homedir(), value.slice(1));
}
return path.resolve(baseDir, value);
}

export async function normalizeRegistrySourceInput(
value: string,
baseDir: string,
): Promise<string> {
const localPath = parseLocalRegistryPath(value);
if (localPath === null && !isPathShorthand(value)) {
// Git-vs-local is decided by the filesystem, not string shape: a
// scheme-less value that resolves to a real directory is a local
// registry (`.`, `myfolder`, `~/x`, ...). Only URL-shaped values or
// paths that don't exist locally fall through to git.
if (localPath === null && GIT_SOURCE_RE.test(value)) {
return value;
}

const requestedPath = localPath ?? resolveLocalCandidate(value, baseDir);
const exists = await fs.pathExists(requestedPath);
if (localPath === null && !isPathShorthand(value) && !exists) {
return value;
}

const requestedPath = localPath ?? path.resolve(baseDir, value);
let canonicalPath: string;
try {
canonicalPath = await fs.realpath(requestedPath);
Expand Down
Loading