Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 53a3ce1

Browse files
authored
feat(skills): auto-bundle skills referenced in a skill's prose for cloud runs (#3400)
1 parent 20cad4d commit 53a3ce1

4 files changed

Lines changed: 141 additions & 10 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseSkillReferences } from "./parse-skill-references";
3+
4+
const KNOWN = new Set(["rs-review", "dep-skill", "usr", "foo", "My-Helper"]);
5+
6+
describe("parseSkillReferences", () => {
7+
it.each([
8+
["a bare slash reference", "Run /rs-review on the diff.", ["rs-review"]],
9+
["a reference at line start", "/rs-review\nthen stop", ["rs-review"]],
10+
[
11+
"backticked and quoted references",
12+
'Use `/rs-review` or "/dep-skill" here.',
13+
["rs-review", "dep-skill"],
14+
],
15+
["a parenthesized reference", "(see /dep-skill)", ["dep-skill"]],
16+
["a wiki-style link", "Details in [[dep-skill]].", ["dep-skill"]],
17+
[
18+
"mixed reference styles, deduplicated",
19+
"Run /rs-review, then [[rs-review]] again and /dep-skill.",
20+
["rs-review", "dep-skill"],
21+
],
22+
["a sentence-final reference", "First run /dep-skill.", ["dep-skill"]],
23+
[
24+
"an uppercase frontmatter name",
25+
"Use /My-Helper then [[My-Helper]].",
26+
["My-Helper"],
27+
],
28+
["an unknown skill name", "Run /not-a-skill now.", []],
29+
["a URL path segment", "See https://example.com/foo for docs.", []],
30+
["a file path segment", "Look in /usr/bin and /foo/bar.", []],
31+
["a mid-word slash", "either/foo works", []],
32+
["an empty body", "", []],
33+
])("handles %s", (_label, content, expected) => {
34+
expect(parseSkillReferences(content, KNOWN)).toEqual(expected);
35+
});
36+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const SLASH_REFERENCE_REGEX =
2+
/(?<=^|[\s(`"'[])\/([A-Za-z0-9][A-Za-z0-9._-]*)(?![A-Za-z0-9._/-])/gm;
3+
const WIKI_LINK_REGEX = /\[\[([A-Za-z0-9][A-Za-z0-9._-]*)\]\]/g;
4+
5+
/**
6+
* Finds `/skill-name` and `[[skill-name]]` references in a SKILL.md body.
7+
* Only names in `knownNames` are returned, so paths (`/usr/bin`), URL
8+
* segments, and unrelated slash-words can't match. A slash reference must
9+
* span its whole path segment — `/foo/bar` never matches a skill named `foo`.
10+
*/
11+
export function parseSkillReferences(
12+
content: string,
13+
knownNames: ReadonlySet<string>,
14+
): string[] {
15+
const references = new Set<string>();
16+
17+
for (const regex of [SLASH_REFERENCE_REGEX, WIKI_LINK_REGEX]) {
18+
for (const match of content.matchAll(regex)) {
19+
const name = match[1];
20+
if (knownNames.has(name)) {
21+
references.add(name);
22+
continue;
23+
}
24+
// dots are valid name chars, so a sentence-final "/dep-skill." captures the period
25+
const withoutTrailingDots = name.replace(/\.+$/, "");
26+
if (withoutTrailingDots !== name && knownNames.has(withoutTrailingDots)) {
27+
references.add(withoutTrailingDots);
28+
}
29+
}
30+
}
31+
32+
return [...references];
33+
}

packages/workspace-server/src/services/skills/skills.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,46 @@ describe("resolveSkillBundleDependencies", () => {
625625
.join("\n")}\n---\nbody`;
626626
}
627627

628+
it("expands prose references (/name and [[name]]) into dependencies", async () => {
629+
const primary = await createSkill(
630+
repoSkillsDir,
631+
"prose-parent",
632+
`---\nname: prose-parent\ndescription: parent\n---\nRun /prose-dep first, then see [[prose-wiki-dep]]. Ignore /usr/bin and /unknown-skill.`,
633+
);
634+
const slashDep = await createSkill(repoSkillsDir, "prose-dep");
635+
const wikiDep = await createSkill(repoSkillsDir, "prose-wiki-dep");
636+
const service = makeService();
637+
638+
const resolved = await service.resolveSkillBundleDependencies([
639+
ref("prose-parent", primary),
640+
]);
641+
642+
expect(resolved.map((r) => r.name)).toEqual([
643+
"prose-parent",
644+
"prose-dep",
645+
"prose-wiki-dep",
646+
]);
647+
expect(resolved.map((r) => r.path)).toEqual([primary, slashDep, wikiDep]);
648+
});
649+
650+
it("prefers a dependency beside the referencing skill over a same-named skill elsewhere", async () => {
651+
const primary = await createSkill(
652+
repoSkillsDir,
653+
"scoped-parent",
654+
withDeps("scoped-parent", ["helper"]),
655+
);
656+
const repoHelper = await createSkill(repoSkillsDir, "helper");
657+
await mkdir(userSkillsHome.dir, { recursive: true });
658+
await createSkill(userSkillsHome.dir, "helper");
659+
const service = makeService();
660+
661+
const resolved = await service.resolveSkillBundleDependencies([
662+
ref("scoped-parent", primary),
663+
]);
664+
665+
expect(resolved.map((r) => r.path)).toEqual([primary, repoHelper]);
666+
});
667+
628668
it("expands a tagged skill to include its transitive dependencies", async () => {
629669
const primary = await createSkill(
630670
repoSkillsDir,

packages/workspace-server/src/services/skills/skills.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
parseSkillDependencies,
1717
parseSkillFrontmatter,
1818
} from "./parse-skill-frontmatter";
19+
import { parseSkillReferences } from "./parse-skill-references";
1920
import type {
2021
BundleLocalSkillInput,
2122
BundleLocalSkillOutput,
@@ -506,27 +507,43 @@ export class SkillsService {
506507
}
507508

508509
/**
509-
* Expand a set of tagged skill refs to include their transitively-declared
510-
* dependency skills (SKILL.md `dependencies:`), so a skill that needs another
511-
* (e.g. `/rs-self-review` → `rs-adversarial-review`) pulls its dependency into
512-
* the same cloud run instead of the user having to tag every one by hand.
513-
* Only uploadable local skills are returned; a dependency that resolves to a
514-
* built-in (`bundled`) skill is already present in the sandbox and is skipped.
510+
* Expand a set of tagged skill refs to include their transitive dependency
511+
* skills, so a skill that needs another pulls it into the same cloud run.
512+
* A dependency is either declared (SKILL.md frontmatter `dependencies:`)
513+
* or referenced in the SKILL.md body as `/skill-name` or `[[skill-name]]`.
514+
* Only uploadable local skills are returned; a dependency that resolves to
515+
* a built-in (`bundled`) skill is already in the sandbox and is skipped.
515516
*/
516517
async resolveSkillBundleDependencies(
517518
refs: SkillBundleRef[],
518519
): Promise<SkillBundleRef[]> {
519520
if (refs.length === 0) return [];
520521

521522
const allSkills = await this.listSkills();
522-
const findUploadableByName = (name: string): SkillBundleRef | null => {
523-
const match = allSkills.find(
523+
// Prefer a dependency beside the referencing skill (same root), then one
524+
// from the same source, so a repo skill referencing /helper gets its
525+
// sibling rather than a same-named skill from another source.
526+
const findUploadableByName = (
527+
name: string,
528+
referencedFrom: SkillBundleRef,
529+
): SkillBundleRef | null => {
530+
const candidates = allSkills.filter(
524531
(skill) => skill.name === name && isUploadableSkillSource(skill.source),
525532
);
533+
const match =
534+
candidates.find(
535+
(skill) =>
536+
path.dirname(skill.path) === path.dirname(referencedFrom.path),
537+
) ??
538+
candidates.find((skill) => skill.source === referencedFrom.source) ??
539+
candidates[0];
526540
return match && isUploadableSkillSource(match.source)
527541
? { name: match.name, source: match.source, path: match.path }
528542
: null;
529543
};
544+
const knownSkillNames: ReadonlySet<string> = new Set(
545+
allSkills.map((skill) => skill.name),
546+
);
530547

531548
const seen = new Set<string>();
532549
const resolved: SkillBundleRef[] = [];
@@ -558,15 +575,20 @@ export class SkillsService {
558575
path.join(skillDir, "SKILL.md"),
559576
"utf-8",
560577
);
561-
dependencyNames = parseSkillDependencies(manifest);
578+
dependencyNames = [
579+
...new Set([
580+
...parseSkillDependencies(manifest),
581+
...parseSkillReferences(manifest, knownSkillNames),
582+
]),
583+
];
562584
} catch {
563585
// A ref we can't read (missing/renamed skill) still uploads on its own;
564586
// just skip its dependency expansion rather than failing the whole run.
565587
continue;
566588
}
567589

568590
for (const dependencyName of dependencyNames) {
569-
const dependencyRef = findUploadableByName(dependencyName);
591+
const dependencyRef = findUploadableByName(dependencyName, ref);
570592
if (
571593
dependencyRef &&
572594
!seen.has(`${dependencyRef.source}:${dependencyRef.path}`)

0 commit comments

Comments
 (0)