Add skill slash-command autocomplete

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 17:00:40 -05:00
parent fe61e650c2
commit 94d4a01b76
9 changed files with 271 additions and 48 deletions

View file

@ -600,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
export {
AGENT_MENTION_SCHEME,
PROJECT_MENTION_SCHEME,
SKILL_MENTION_SCHEME,
buildAgentMentionHref,
buildProjectMentionHref,
buildSkillMentionHref,
extractAgentMentionIds,
extractSkillMentionIds,
parseAgentMentionHref,
parseProjectMentionHref,
parseSkillMentionHref,
extractProjectMentionIds,
type ParsedAgentMention,
type ParsedProjectMention,
type ParsedSkillMention,
} from "./project-mentions.js";
export {

View file

@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import {
buildAgentMentionHref,
buildProjectMentionHref,
buildSkillMentionHref,
extractAgentMentionIds,
extractProjectMentionIds,
extractSkillMentionIds,
parseAgentMentionHref,
parseProjectMentionHref,
parseSkillMentionHref,
} from "./project-mentions.js";
describe("project-mentions", () => {
@ -26,4 +29,13 @@ describe("project-mentions", () => {
});
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]);
});
it("round-trips skill mentions with slug metadata", () => {
const href = buildSkillMentionHref("skill-123", "release-changelog");
expect(parseSkillMentionHref(href)).toEqual({
skillId: "skill-123",
slug: "release-changelog",
});
expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]);
});
});

View file

@ -1,5 +1,6 @@
export const PROJECT_MENTION_SCHEME = "project://";
export const AGENT_MENTION_SCHEME = "agent://";
export const SKILL_MENTION_SCHEME = "skill://";
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
@ -7,7 +8,9 @@ const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
export interface ParsedProjectMention {
projectId: string;
@ -19,6 +22,11 @@ export interface ParsedAgentMention {
icon: string | null;
}
export interface ParsedSkillMention {
skillId: string;
slug: string | null;
}
function normalizeHexColor(input: string | null | undefined): string | null {
if (!input) return null;
const trimmed = input.trim();
@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null {
};
}
export function buildSkillMentionHref(skillId: string, slug?: string | null): string {
const trimmedSkillId = skillId.trim();
const normalizedSlug = normalizeSkillSlug(slug ?? null);
if (!normalizedSlug) {
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`;
}
return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`;
}
export function parseSkillMentionHref(href: string): ParsedSkillMention | null {
if (!href.startsWith(SKILL_MENTION_SCHEME)) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "skill:") return null;
const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
if (!skillId) return null;
return {
skillId,
slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")),
};
}
export function extractProjectMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] {
return [...ids];
}
export function extractSkillMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
const re = new RegExp(SKILL_MENTION_LINK_RE);
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
const parsed = parseSkillMentionHref(match[1]);
if (parsed) ids.add(parsed.skillId);
}
return [...ids];
}
function normalizeAgentIcon(input: string | null | undefined): string | null {
if (!input) return null;
const trimmed = input.trim().toLowerCase();
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
return trimmed;
}
function normalizeSkillSlug(input: string | null | undefined): string | null {
if (!input) return null;
const trimmed = input.trim().toLowerCase();
if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null;
return trimmed;
}