diff --git a/packages/cli/src/__tests__/services/skill/registry/skill-registry-source.test.ts b/packages/cli/src/__tests__/services/skill/registry/skill-registry-source.test.ts index a7b966b23..e75137a29 100644 --- a/packages/cli/src/__tests__/services/skill/registry/skill-registry-source.test.ts +++ b/packages/cli/src/__tests__/services/skill/registry/skill-registry-source.test.ts @@ -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"); diff --git a/packages/cli/src/services/skill/registry/skill-registry-source.ts b/packages/cli/src/services/skill/registry/skill-registry-source.ts index 53e27fbcf..2d9bfe32e 100644 --- a/packages/cli/src/services/skill/registry/skill-registry-source.ts +++ b/packages/cli/src/services/skill/registry/skill-registry-source.ts @@ -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"; @@ -30,8 +31,27 @@ 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( @@ -39,11 +59,20 @@ export async function normalizeRegistrySourceInput( baseDir: string, ): Promise { 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);