From eb93f832a5cca8308d7879acaf24f623eb370f31 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 24 Oct 2026 11:21:33 +0200 Subject: [PATCH 1/3] fix(skill): detect local registry sources by filesystem, not string shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scheme-less source resolving to a real directory is now treated as a local registry (file:// URL) instead of a git URL. Fixes add-registry git-cloning '.', 'myfolder', '~/x' etc. into the skill cache — git clone succeeds on local dirs, so a git repo at '.' produced a stale HEAD clone that link-mode installs then pointed at. Stored value is an absolute file:// URL, so relative-source resolution no longer depends on call-time cwd. Refs #419 --- .../registry/skill-registry-source.test.ts | 40 +++++++++++++++++++ .../skill/registry/skill-registry-source.ts | 35 ++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) 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); From a005b7290727c4f5e7ad2367367bcb887570a4fa Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 24 Oct 2026 19:52:35 +0200 Subject: [PATCH 2/3] docs(skills): document filesystem-based local registry detection --- web/content/docs/7-skills.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/content/docs/7-skills.md b/web/content/docs/7-skills.md index 492dd617c..3e9ca571f 100644 --- a/web/content/docs/7-skills.md +++ b/web/content/docs/7-skills.md @@ -204,9 +204,10 @@ Register a third-party skill registry in the current project or global configura ai-devkit skill add-registry my-org/skills https://github.com/my-org/agent-skills.git ai-devkit skill add-registry my-org/skills https://github.com/my-org/agent-skills.git --global ai-devkit skill add-registry my-org/local-skills ../local-registry +ai-devkit skill add-registry my-org/local-skills . # register the current folder ``` -Local sources accept absolute paths, `./`, `../`, or explicit `file:` URLs. AI DevKit resolves them when registered, stores a canonical absolute `file:` URL, and reads the folder in place without cloning, pulling, writing, or deleting it. If the folder moves, re-add it with `--force`. +Local sources are detected by filesystem: any path that resolves to a real directory counts — absolute paths, `.`, `..`, `./`, `../`, `~/x`, bare directory names, or explicit `file:` URLs. AI DevKit resolves them when registered, stores a canonical absolute `file:` URL, and reads the folder in place without cloning, pulling, writing, or deleting it — untracked files and uncommitted edits are visible immediately. If the folder moves, re-add it with `--force`. Use `--global` to write the registry to `~/.ai-devkit/.ai-devkit.json`. If the same registry ID already points to another source, use `--force` to replace it: From 75c3b9035873e646671ad5e54f6803517973fde0 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 24 Oct 2026 19:55:58 +0200 Subject: [PATCH 3/3] Revert "docs(skills): document filesystem-based local registry detection" This reverts commit a005b7290727c4f5e7ad2367367bcb887570a4fa. --- web/content/docs/7-skills.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/content/docs/7-skills.md b/web/content/docs/7-skills.md index 3e9ca571f..492dd617c 100644 --- a/web/content/docs/7-skills.md +++ b/web/content/docs/7-skills.md @@ -204,10 +204,9 @@ Register a third-party skill registry in the current project or global configura ai-devkit skill add-registry my-org/skills https://github.com/my-org/agent-skills.git ai-devkit skill add-registry my-org/skills https://github.com/my-org/agent-skills.git --global ai-devkit skill add-registry my-org/local-skills ../local-registry -ai-devkit skill add-registry my-org/local-skills . # register the current folder ``` -Local sources are detected by filesystem: any path that resolves to a real directory counts — absolute paths, `.`, `..`, `./`, `../`, `~/x`, bare directory names, or explicit `file:` URLs. AI DevKit resolves them when registered, stores a canonical absolute `file:` URL, and reads the folder in place without cloning, pulling, writing, or deleting it — untracked files and uncommitted edits are visible immediately. If the folder moves, re-add it with `--force`. +Local sources accept absolute paths, `./`, `../`, or explicit `file:` URLs. AI DevKit resolves them when registered, stores a canonical absolute `file:` URL, and reads the folder in place without cloning, pulling, writing, or deleting it. If the folder moves, re-add it with `--force`. Use `--global` to write the registry to `~/.ai-devkit/.ai-devkit.json`. If the same registry ID already points to another source, use `--force` to replace it: