agent-skills-standard 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,12 +11,32 @@ const zod = require("zod");
11
11
  const fetch$1 = require("cross-fetch");
12
12
  const child_process = require("child_process");
13
13
  class FeedbackService {
14
+ /**
15
+ * Allowed origin prefix for the feedback API to prevent SSRF.
16
+ * Only requests to this domain (or its subdomains) are permitted.
17
+ */
18
+ static ALLOWED_ORIGIN = "https://agent-skills-feedback.vercel.app";
14
19
  /**
15
20
  * Resolves the API URL from environment variables.
16
21
  */
17
22
  getApiUrl() {
18
23
  return "https://agent-skills-standard.onrender.com/feedback";
19
24
  }
25
+ /**
26
+ * Validates if the given URL is safe and points to the allowed feedback origin.
27
+ * @param url The URL to validate
28
+ * @returns boolean
29
+ */
30
+ isSafeUrl(url) {
31
+ try {
32
+ const parsed = new URL(url);
33
+ const isHttps = parsed.protocol === "https:";
34
+ const isVercelHostname = parsed.hostname === "agent-skills-feedback.vercel.app" || parsed.hostname.endsWith(".agent-skills-feedback.vercel.app");
35
+ return isHttps && isVercelHostname;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
20
40
  /**
21
41
  * Submits feedback data to the backend for automatic GitHub Issue creation.
22
42
  * Internal proxy handles GitHub tokens, keeping client-side logic tokenless.
@@ -27,6 +47,12 @@ class FeedbackService {
27
47
  async submit(data) {
28
48
  const apiUrl = this.getApiUrl();
29
49
  if (!apiUrl) return false;
50
+ if (!this.isSafeUrl(apiUrl)) {
51
+ if (process.env.DEBUG) {
52
+ console.warn(`[FeedbackService] Disallowed API URL origin: ${apiUrl}`);
53
+ }
54
+ return false;
55
+ }
30
56
  try {
31
57
  const response = await fetch(apiUrl, {
32
58
  method: "POST",
@@ -556,8 +582,8 @@ const getAgentDefinition = (id) => {
556
582
  ruleExtension: ".mdc",
557
583
  frontmatterStyle: "cursor",
558
584
  detectionFiles: [".cursor", ".cursorrules"],
559
- workflowFormat: "none",
560
- workflowPath: ""
585
+ workflowFormat: "skill",
586
+ workflowPath: ".cursor/skills"
561
587
  };
562
588
  case Agent.Trae:
563
589
  return {
@@ -568,8 +594,8 @@ const getAgentDefinition = (id) => {
568
594
  ruleExtension: ".mdc",
569
595
  frontmatterStyle: "cursor",
570
596
  detectionFiles: [".trae"],
571
- workflowFormat: "none",
572
- workflowPath: ""
597
+ workflowFormat: "skill",
598
+ workflowPath: ".trae/skills"
573
599
  };
574
600
  case Agent.Claude:
575
601
  return {
@@ -617,8 +643,8 @@ const getAgentDefinition = (id) => {
617
643
  ruleExtension: ".md",
618
644
  frontmatterStyle: "cursor",
619
645
  detectionFiles: [".codex"],
620
- workflowFormat: "none",
621
- workflowPath: ""
646
+ workflowFormat: "skill",
647
+ workflowPath: ".codex/skills"
622
648
  };
623
649
  case Agent.OpenCode:
624
650
  return {
@@ -629,8 +655,8 @@ const getAgentDefinition = (id) => {
629
655
  ruleExtension: ".md",
630
656
  frontmatterStyle: "cursor",
631
657
  detectionFiles: [".opencode"],
632
- workflowFormat: "none",
633
- workflowPath: ""
658
+ workflowFormat: "command",
659
+ workflowPath: ".opencode/commands"
634
660
  };
635
661
  case Agent.Gemini:
636
662
  return {
@@ -653,8 +679,8 @@ const getAgentDefinition = (id) => {
653
679
  ruleExtension: ".md",
654
680
  frontmatterStyle: "cursor",
655
681
  detectionFiles: [".roo"],
656
- workflowFormat: "none",
657
- workflowPath: ""
682
+ workflowFormat: "command",
683
+ workflowPath: ".roo/commands"
658
684
  };
659
685
  case Agent.Windsurf:
660
686
  return {
@@ -1055,7 +1081,12 @@ class DetectionService {
1055
1081
  dirsToScan.push(path.join(cwd, entry.name));
1056
1082
  }
1057
1083
  }
1058
- } catch {
1084
+ } catch (error) {
1085
+ if (process.env.DEBUG) {
1086
+ console.debug(
1087
+ `[DetectionService] Failed to read directory entries: ${error}`
1088
+ );
1089
+ }
1059
1090
  }
1060
1091
  const packageDepsMaps = await Promise.all(
1061
1092
  dirsToScan.map((dir) => this.readPackageJsonDeps(dir))
@@ -1427,7 +1458,12 @@ class GithubService {
1427
1458
  if (!res.ok) return null;
1428
1459
  const data = await res.json();
1429
1460
  return data.tag_name;
1430
- } catch {
1461
+ } catch (error) {
1462
+ if (process.env.DEBUG) {
1463
+ console.warn(
1464
+ `[GithubService] Failed to fetch latest release: ${error}`
1465
+ );
1466
+ }
1431
1467
  return null;
1432
1468
  }
1433
1469
  }
@@ -1572,7 +1608,12 @@ class RegistryService {
1572
1608
  ).map((f) => f.path.split("/")[2]).filter(Boolean)
1573
1609
  )
1574
1610
  );
1575
- } catch {
1611
+ } catch (error) {
1612
+ if (process.env.DEBUG) {
1613
+ console.warn(
1614
+ `[RegistryService] Failed to get framework skills for ${framework}: ${error}`
1615
+ );
1616
+ }
1576
1617
  return [];
1577
1618
  }
1578
1619
  }
@@ -1867,74 +1908,57 @@ applyTo: '**/*'
1867
1908
  }
1868
1909
  }
1869
1910
  }
1870
- const INJECTION_PATTERNS = [
1871
- /ignore\s+(?:all\s+|previous\s+|prior\s+)*(?:instructions?|rules?|guidance)/gi,
1872
- /you\s+(?:must|should|shall|will)\s+(?:now|immediately)\b/gi,
1873
- /^(?:system|user|assistant)\s*:/gim,
1874
- /(?:^|\n)\s*-{3,}\s*(?:\n|$)/g,
1875
- /<(?:script|iframe|style)[^>]*\s*>[\s\S]*?<\/(?:script|iframe|style)[^>]*\s*>/gi
1876
- ];
1877
- class IndexGeneratorService {
1911
+ class IndexContentBuilder {
1912
+ metadataReader;
1913
+ constructor(metadataReader) {
1914
+ this.metadataReader = metadataReader;
1915
+ }
1878
1916
  /**
1879
- * Loads foundational composite rules from the registry's metadata.json.
1880
- * Falls back to an empty map if the file is missing or malformed.
1917
+ * Derives foundational composite IDs for a skill based on registry-owned naming rules.
1918
+ * Only applies to non-common skills. Deduplicates against explicit composites.
1881
1919
  */
1882
- async loadFoundationalRules(baseDir) {
1883
- try {
1884
- const metaPath = path.join(baseDir, "metadata.json");
1885
- const raw = await fs.readFile(metaPath, "utf8");
1886
- const parsed = JSON.parse(raw);
1887
- return parsed.foundational_composite_rules ?? {};
1888
- } catch {
1889
- return {};
1920
+ deriveFoundationalComposites(category, skill, explicitComposites, rules) {
1921
+ if (category === "common") return [];
1922
+ const derived = [];
1923
+ for (const [foundational, patterns] of Object.entries(rules)) {
1924
+ const matches = patterns.some((p) => skill.includes(p));
1925
+ if (matches && !explicitComposites.includes(foundational)) {
1926
+ derived.push(foundational);
1927
+ }
1890
1928
  }
1929
+ return derived;
1891
1930
  }
1892
1931
  /**
1893
- * Generates a markdown index of available skills across multiple categories.
1894
- * @param baseDir The base directory containing categories and skills
1895
- * @param allowedCategories List of categories/frameworks to include. If undefined, all found are included.
1896
- * @returns A formatted markdown string representing the index
1932
+ * Formats a single skill into a list item for AGENTS.md.
1897
1933
  */
1898
- async generate(baseDir, allowedCategories) {
1899
- const entries = /* @__PURE__ */ new Set();
1900
- const foundationalRules = await this.loadFoundationalRules(baseDir);
1901
- if (!await fs.pathExists(baseDir)) return this.assembleIndex([]);
1902
- let categories = await fs.readdir(baseDir);
1903
- categories = categories.filter((category) => {
1904
- if (category.startsWith(".")) return false;
1905
- if (allowedCategories && !allowedCategories.includes(category) && category !== "common") {
1906
- return false;
1907
- }
1908
- return true;
1909
- });
1910
- categories.sort();
1911
- for (const category of categories) {
1912
- const categoryPath = path.join(baseDir, category);
1913
- if (!(await fs.stat(categoryPath)).isDirectory()) continue;
1914
- const skills = await fs.readdir(categoryPath);
1915
- skills.sort();
1916
- for (const skill of skills) {
1917
- const skillPath = path.join(categoryPath, skill, "SKILL.md");
1918
- if (!await fs.pathExists(skillPath)) continue;
1919
- const metadata = await this.parseSkill(skillPath);
1920
- if (metadata) {
1921
- const entry = this.formatEntry(
1922
- category,
1923
- skill,
1924
- metadata,
1925
- foundationalRules
1926
- );
1927
- entries.add(entry);
1928
- }
1929
- }
1930
- }
1931
- return this.assembleIndex(Array.from(entries));
1934
+ formatEntry(category, skill, metadata, foundationalRules = {}) {
1935
+ const id = `${category}/${skill}`;
1936
+ const explicitComposites = metadata.triggers.composite || [];
1937
+ const autoComposites = this.deriveFoundationalComposites(
1938
+ category,
1939
+ skill,
1940
+ explicitComposites,
1941
+ foundationalRules
1942
+ );
1943
+ const allComposites = [...explicitComposites, ...autoComposites];
1944
+ const triggers = [
1945
+ ...metadata.triggers.files || [],
1946
+ ...metadata.triggers.keywords || [],
1947
+ ...allComposites.length ? allComposites.map((c) => `+${c}`) : [],
1948
+ ...metadata.triggers.exclude ? metadata.triggers.exclude.map((e) => `!${e}`) : []
1949
+ ].join(", ");
1950
+ const triggerText = triggers ? ` (triggers: \`${triggers}\`)` : "";
1951
+ let desc = this.metadataReader.sanitizeDescription(
1952
+ metadata.description || "",
1953
+ id
1954
+ );
1955
+ desc = desc.replace(/\s*\(triggers:.*?\)\s*$/, "");
1956
+ const prefixIcon = metadata.priority.startsWith("P0") ? "🚨 " : "";
1957
+ const content = `${prefixIcon}${desc}${triggerText}`.trim();
1958
+ return `- **[${id}]**: ${content}`;
1932
1959
  }
1933
1960
  /**
1934
- * Assembles the full index markdown including headers and Zero-Trust rules.
1935
- * @param entries List of formatted skill entries
1936
- * @param format The format of the entries ('detailed' or 'compact')
1937
- * @returns Complete markdown index string
1961
+ * Assembles the full AGENTS.md markdown including headers and Zero-Trust rules.
1938
1962
  */
1939
1963
  assembleIndex(entries) {
1940
1964
  const header = [
@@ -1987,67 +2011,297 @@ class IndexGeneratorService {
1987
2011
  "> [!TIP]",
1988
2012
  "> **Indirect phrasing still counts.** Match keywords by intent, not just exact words.",
1989
2013
  '> Examples: "make it faster" → `performance`, "broken query" → `database`, "login flow" → `auth`, "clean up this file" → `refactor`.',
2014
+ "",
2015
+ "### List of Available Skills",
1990
2016
  ""
1991
2017
  ].join("\n");
1992
2018
  return `${header}
1993
2019
  ${entries.join("\n")}
1994
2020
  `;
1995
2021
  }
2022
+ }
2023
+ class IndexGeneratorService {
2024
+ }
2025
+ const INJECTION_PATTERNS = [
2026
+ /ignore\s+(?:all\s+|previous\s+|prior\s+)*(?:instructions?|rules?|guidance)/gi,
2027
+ /you\s+(?:must|should|shall|will)\s+(?:now|immediately)\b/gi,
2028
+ /^(?:system|user|assistant)\s*:/gim,
2029
+ /(?:^|\n)\s*-{3,}\s*(?:\n|$)/g,
2030
+ /<(?:script|iframe|style)[^>]*\s*>[\s\S]*?<\/(?:script|iframe|style)[^>]*\s*>/gi
2031
+ ];
2032
+ class MetadataReader {
2033
+ remoteMetadata;
2034
+ constructor(remote) {
2035
+ this.remoteMetadata = remote;
2036
+ }
1996
2037
  /**
1997
- * Loads file routing rules from the registry's metadata.json.
1998
- * Maps file extensions to category arrays for router table generation.
2038
+ * Loads foundational composite rules. Uses injected remote metadata when available;
2039
+ * falls back to reading metadata.json from baseDir.
2040
+ */
2041
+ async loadFoundationalRules(baseDir) {
2042
+ if (this.remoteMetadata?.foundational_composite_rules !== void 0) {
2043
+ return this.remoteMetadata.foundational_composite_rules;
2044
+ }
2045
+ try {
2046
+ const metaPath = path.join(baseDir, "metadata.json");
2047
+ if (await fs.pathExists(metaPath)) {
2048
+ const raw = await fs.readFile(metaPath, "utf8");
2049
+ const parsed = JSON.parse(raw);
2050
+ return parsed.foundational_composite_rules ?? {};
2051
+ }
2052
+ } catch (error) {
2053
+ if (process.env.DEBUG) {
2054
+ console.warn(
2055
+ `[MetadataReader] Failed to load foundational rules: ${error}`
2056
+ );
2057
+ }
2058
+ }
2059
+ return {};
2060
+ }
2061
+ /**
2062
+ * Loads file routing rules. Uses injected remote metadata when available;
2063
+ * falls back to reading metadata.json from baseDir.
1999
2064
  */
2000
2065
  async loadFileRouting(baseDir) {
2066
+ if (this.remoteMetadata?.file_routing !== void 0) {
2067
+ return this.remoteMetadata.file_routing;
2068
+ }
2001
2069
  try {
2002
2070
  const metaPath = path.join(baseDir, "metadata.json");
2003
- const raw = await fs.readFile(metaPath, "utf8");
2004
- const parsed = JSON.parse(raw);
2005
- return parsed.file_routing ?? {};
2006
- } catch {
2007
- return {};
2071
+ if (await fs.pathExists(metaPath)) {
2072
+ const raw = await fs.readFile(metaPath, "utf8");
2073
+ const parsed = JSON.parse(raw);
2074
+ return parsed.file_routing ?? {};
2075
+ }
2076
+ } catch (error) {
2077
+ if (process.env.DEBUG) {
2078
+ console.warn(`[MetadataReader] Failed to load file routing: ${error}`);
2079
+ }
2008
2080
  }
2081
+ return {};
2009
2082
  }
2010
2083
  /**
2011
- * Loads broad glob patterns and base language skill mappings from metadata.json.
2012
- * Used to classify triggers into tiers (file match vs keyword match).
2084
+ * Loads broad glob patterns and base language skill mappings. Uses injected remote
2085
+ * metadata when available; falls back to reading metadata.json from baseDir.
2013
2086
  */
2014
2087
  async loadTierConfig(baseDir) {
2088
+ if (this.remoteMetadata !== void 0) {
2089
+ return {
2090
+ broadGlobs: this.remoteMetadata.broad_globs ?? [],
2091
+ baseSkills: this.remoteMetadata.base_language_skills ?? {}
2092
+ };
2093
+ }
2015
2094
  try {
2016
2095
  const metaPath = path.join(baseDir, "metadata.json");
2017
- const raw = await fs.readFile(metaPath, "utf8");
2018
- const parsed = JSON.parse(raw);
2096
+ if (await fs.pathExists(metaPath)) {
2097
+ const raw = await fs.readFile(metaPath, "utf8");
2098
+ const parsed = JSON.parse(raw);
2099
+ return {
2100
+ broadGlobs: parsed.broad_globs ?? [],
2101
+ baseSkills: parsed.base_language_skills ?? {}
2102
+ };
2103
+ }
2104
+ } catch (error) {
2105
+ if (process.env.DEBUG) {
2106
+ console.warn(`[MetadataReader] Failed to load tier config: ${error}`);
2107
+ }
2108
+ }
2109
+ return { broadGlobs: [], baseSkills: {} };
2110
+ }
2111
+ /**
2112
+ * Parses a SKILL.md file to extract its metadata (frontmatter + priority).
2113
+ */
2114
+ async parseSkill(skillPath) {
2115
+ try {
2116
+ const content = await fs.readFile(skillPath, "utf8");
2117
+ const frontmatterMatch = content.match(
2118
+ /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
2119
+ );
2120
+ if (!frontmatterMatch) return null;
2121
+ const fm = yaml.load(frontmatterMatch[1]);
2122
+ const body = frontmatterMatch[2];
2123
+ const priorityMatch = body.match(/## \*\*Priority:\s*([^*]+)\*\*/);
2124
+ const priority = priorityMatch ? priorityMatch[1].trim() : "P1";
2125
+ const metadata = fm?.metadata || {};
2126
+ const rawTriggers = metadata?.triggers || {};
2127
+ const triggers = {
2128
+ files: Array.isArray(rawTriggers.files) ? rawTriggers.files : [],
2129
+ keywords: Array.isArray(rawTriggers.keywords) ? rawTriggers.keywords : [],
2130
+ composite: Array.isArray(rawTriggers.composite) ? rawTriggers.composite : [],
2131
+ exclude: Array.isArray(rawTriggers.exclude) ? rawTriggers.exclude : []
2132
+ };
2133
+ const hasStructured = (triggers.files?.length || 0) > 0 || (triggers.keywords?.length || 0) > 0;
2134
+ const description = typeof fm.description === "string" ? fm.description : "";
2135
+ if (!hasStructured && description) {
2136
+ const descTrigs = description.match(/\(triggers:\s*`?(.*?)`?\)\s*$/);
2137
+ if (descTrigs) {
2138
+ const rawParts = descTrigs[1].split(",");
2139
+ const parts = [];
2140
+ let current = "";
2141
+ let braceCount = 0;
2142
+ for (let i = 0; i < rawParts.length; i++) {
2143
+ const p = rawParts[i];
2144
+ braceCount += (p.match(/{/g) || []).length;
2145
+ braceCount -= (p.match(/}/g) || []).length;
2146
+ current += (current ? "," : "") + p;
2147
+ if (braceCount === 0) {
2148
+ parts.push(current.trim());
2149
+ current = "";
2150
+ }
2151
+ }
2152
+ if (current) parts.push(current.trim());
2153
+ const files = parts.filter(
2154
+ (p) => p.includes("*") || p.includes("/") || p.includes("{") || /\.\w+$/.test(p)
2155
+ );
2156
+ const keywords = parts.filter(
2157
+ (p) => !p.includes("*") && !p.includes("/") && !p.includes("{") && !/\.\w+$/.test(p)
2158
+ );
2159
+ if (files.length > 0) triggers.files = files;
2160
+ if (keywords.length > 0) triggers.keywords = keywords;
2161
+ }
2162
+ }
2019
2163
  return {
2020
- broadGlobs: parsed.broad_globs ?? [],
2021
- baseSkills: parsed.base_language_skills ?? {}
2164
+ name: typeof fm.name === "string" ? fm.name : "",
2165
+ description,
2166
+ priority,
2167
+ triggers
2022
2168
  };
2023
- } catch {
2024
- return { broadGlobs: [], baseSkills: {} };
2169
+ } catch (error) {
2170
+ if (process.env.DEBUG) {
2171
+ console.warn(
2172
+ `[MetadataReader] Failed to parse skill at ${skillPath}: ${error}`
2173
+ );
2174
+ }
2175
+ return null;
2025
2176
  }
2026
2177
  }
2027
2178
  /**
2028
- * Generates a tiered per-category _INDEX.md with two sections:
2029
- * - **File Match**: Skills with specific path patterns (auto-match when editing a file)
2030
- * - **Keyword Match**: Skills triggered by user intent (only when user mentions the concept)
2031
- *
2032
- * Broad globs (e.g., **\/*.ts) are stripped from non-base skills and those skills
2033
- * are moved to Keyword Match only. This prevents 30+ skills from matching a single file.
2179
+ * Sanitizes a skill description to prevent indirect prompt injection.
2034
2180
  */
2181
+ sanitizeDescription(desc, skillId = "unknown") {
2182
+ let sanitized = desc;
2183
+ let wasModified = false;
2184
+ for (const pattern of INJECTION_PATTERNS) {
2185
+ if (pattern.test(sanitized)) {
2186
+ wasModified = true;
2187
+ sanitized = sanitized.replace(pattern, "[REDACTED]");
2188
+ }
2189
+ pattern.lastIndex = 0;
2190
+ }
2191
+ if (wasModified) {
2192
+ process.stderr.write(
2193
+ `[SECURITY] Prompt injection pattern stripped from skill description: ${skillId}
2194
+ `
2195
+ );
2196
+ }
2197
+ return sanitized;
2198
+ }
2199
+ }
2200
+ class IndexGeneratorServiceImpl extends IndexGeneratorService {
2201
+ reader;
2202
+ builder;
2203
+ constructor(remoteMetadata) {
2204
+ super();
2205
+ this.reader = new MetadataReader(remoteMetadata);
2206
+ this.builder = new IndexContentBuilder(this.reader);
2207
+ }
2208
+ /** Overridable metadata injection */
2209
+ withMetadata(metadata) {
2210
+ this.reader = new MetadataReader(metadata);
2211
+ this.builder = new IndexContentBuilder(this.reader);
2212
+ return this;
2213
+ }
2214
+ async generate(baseDir, allowedCategories) {
2215
+ const entries = /* @__PURE__ */ new Set();
2216
+ const foundationalRules = await this.reader.loadFoundationalRules(baseDir);
2217
+ if (!await fs.pathExists(baseDir)) return this.assembleIndex([]);
2218
+ let categories = await fs.readdir(baseDir);
2219
+ categories = categories.filter((category) => {
2220
+ if (category.startsWith(".")) return false;
2221
+ if (allowedCategories && !allowedCategories.includes(category) && category !== "common") {
2222
+ return false;
2223
+ }
2224
+ return true;
2225
+ });
2226
+ categories.sort();
2227
+ for (const category of categories) {
2228
+ const categoryPath = path.join(baseDir, category);
2229
+ if (!(await fs.stat(categoryPath)).isDirectory()) continue;
2230
+ const skills = await fs.readdir(categoryPath);
2231
+ skills.sort();
2232
+ for (const skill of skills) {
2233
+ const skillPath = path.join(categoryPath, skill, "SKILL.md");
2234
+ const isRegistrySkill = await fs.pathExists(skillPath);
2235
+ const isStandaloneSkill = !isRegistrySkill && skill.endsWith(".md") && skill !== "_INDEX.md" && !(await fs.stat(path.join(categoryPath, skill))).isDirectory();
2236
+ const targetPath = isRegistrySkill ? skillPath : path.join(categoryPath, skill);
2237
+ if (!isRegistrySkill && !isStandaloneSkill) continue;
2238
+ const metadata = await this.reader.parseSkill(targetPath);
2239
+ if (metadata) {
2240
+ const skillId = isStandaloneSkill ? skill.replace(".md", "") : skill;
2241
+ const entry = this.builder.formatEntry(
2242
+ category,
2243
+ skillId,
2244
+ metadata,
2245
+ foundationalRules
2246
+ );
2247
+ entries.add(entry);
2248
+ }
2249
+ }
2250
+ }
2251
+ return this.assembleIndex(Array.from(entries));
2252
+ }
2253
+ assembleIndex(entries) {
2254
+ return this.builder.assembleIndex(entries);
2255
+ }
2256
+ /**
2257
+ * Sanitizes a skill description to prevent indirect prompt injection.
2258
+ */
2259
+ sanitizeDescription(desc, skillId = "unknown") {
2260
+ return this.reader.sanitizeDescription(desc, skillId);
2261
+ }
2262
+ /**
2263
+ * Generates all _INDEX.md files for categories found in baseDir.
2264
+ */
2265
+ async generateAllCategoryIndices(baseDir, allowedCategories) {
2266
+ if (!await fs.pathExists(baseDir)) return {};
2267
+ let categories = await fs.readdir(baseDir);
2268
+ categories = categories.filter((c) => {
2269
+ if (c.startsWith(".") || c === "_INDEX.md") return false;
2270
+ if (allowedCategories && !allowedCategories.includes(c) && c !== "common") {
2271
+ return false;
2272
+ }
2273
+ return true;
2274
+ });
2275
+ categories.sort();
2276
+ const result = {};
2277
+ for (const category of categories) {
2278
+ const categoryPath = path.join(baseDir, category);
2279
+ if (!(await fs.stat(categoryPath)).isDirectory()) continue;
2280
+ const index = await this.generateCategoryIndex(baseDir, category);
2281
+ if (index) result[category] = index;
2282
+ }
2283
+ return result;
2284
+ }
2035
2285
  async generateCategoryIndex(baseDir, category) {
2036
2286
  const categoryPath = path.join(baseDir, category);
2037
2287
  if (!await fs.pathExists(categoryPath)) return "";
2038
- const { broadGlobs, baseSkills } = await this.loadTierConfig(baseDir);
2288
+ const { broadGlobs, baseSkills } = await this.reader.loadTierConfig(baseDir);
2039
2289
  const baseSkillForCategory = baseSkills[category];
2040
- const skills = await fs.readdir(categoryPath);
2041
- skills.sort();
2290
+ const entries = await fs.readdir(categoryPath);
2291
+ entries.sort();
2042
2292
  const fileMatchRows = [];
2043
2293
  const keywordMatchRows = [];
2044
- for (const skill of skills) {
2045
- if (skill.startsWith(".") || skill === "_INDEX.md") continue;
2046
- const skillPath = path.join(categoryPath, skill, "SKILL.md");
2047
- if (!await fs.pathExists(skillPath)) continue;
2048
- const metadata = await this.parseSkill(skillPath);
2294
+ for (const entryName of entries) {
2295
+ if (entryName.startsWith(".") || entryName === "_INDEX.md") continue;
2296
+ const skillPath = path.join(categoryPath, entryName, "SKILL.md");
2297
+ const isRegistrySkill = await fs.pathExists(skillPath);
2298
+ const isStandaloneSkill = !isRegistrySkill && entryName.endsWith(".md") && !(await fs.stat(path.join(categoryPath, entryName))).isDirectory();
2299
+ if (!isRegistrySkill && !isStandaloneSkill) continue;
2300
+ const targetPath = isRegistrySkill ? skillPath : path.join(categoryPath, entryName);
2301
+ const skillId = isStandaloneSkill ? entryName.replace(".md", "") : entryName;
2302
+ const metadata = await this.reader.parseSkill(targetPath);
2049
2303
  if (!metadata) continue;
2050
- const isBaseSkill = skill === baseSkillForCategory;
2304
+ const isBaseSkill = skillId === baseSkillForCategory;
2051
2305
  const allFiles = metadata.triggers.files || [];
2052
2306
  const kwTrigs = (metadata.triggers.keywords || []).join(", ") || "—";
2053
2307
  const prefix = metadata.priority.startsWith("P0") ? "**" : "";
@@ -2057,15 +2311,15 @@ ${entries.join("\n")}
2057
2311
  if (isBaseSkill || specificFiles.length > 0 && !hasBroadGlob) {
2058
2312
  const displayFiles = isBaseSkill ? allFiles.map((f) => `\`${f}\``).join(", ") : specificFiles.map((f) => `\`${f}\``).join(", ");
2059
2313
  fileMatchRows.push(
2060
- `| ${prefix}${skill}${suffix} | ${displayFiles || "—"} | ${kwTrigs} |`
2314
+ `| ${prefix}${skillId}${suffix} | ${displayFiles || "—"} | ${kwTrigs} |`
2061
2315
  );
2062
2316
  } else if (specificFiles.length > 0 && hasBroadGlob) {
2063
2317
  const displayFiles = specificFiles.map((f) => `\`${f}\``).join(", ");
2064
2318
  fileMatchRows.push(
2065
- `| ${prefix}${skill}${suffix} | ${displayFiles} | ${kwTrigs} |`
2319
+ `| ${prefix}${skillId}${suffix} | ${displayFiles} | ${kwTrigs} |`
2066
2320
  );
2067
2321
  } else {
2068
- keywordMatchRows.push(`| ${prefix}${skill}${suffix} | ${kwTrigs} |`);
2322
+ keywordMatchRows.push(`| ${prefix}${skillId}${suffix} | ${kwTrigs} |`);
2069
2323
  }
2070
2324
  }
2071
2325
  if (fileMatchRows.length === 0 && keywordMatchRows.length === 0) return "";
@@ -2100,39 +2354,8 @@ ${entries.join("\n")}
2100
2354
  );
2101
2355
  return lines.join("\n");
2102
2356
  }
2103
- /**
2104
- * Generates all _INDEX.md files for categories found in baseDir.
2105
- * @returns Map of category name to generated index content
2106
- */
2107
- async generateAllCategoryIndices(baseDir, allowedCategories) {
2108
- if (!await fs.pathExists(baseDir)) return {};
2109
- let categories = await fs.readdir(baseDir);
2110
- categories = categories.filter((c) => {
2111
- if (c.startsWith(".") || c === "_INDEX.md") return false;
2112
- if (allowedCategories && !allowedCategories.includes(c) && c !== "common") {
2113
- return false;
2114
- }
2115
- return true;
2116
- });
2117
- categories.sort();
2118
- const result = {};
2119
- for (const category of categories) {
2120
- const categoryPath = path.join(baseDir, category);
2121
- if (!(await fs.stat(categoryPath)).isDirectory()) continue;
2122
- const index = await this.generateCategoryIndex(baseDir, category);
2123
- if (index) result[category] = index;
2124
- }
2125
- return result;
2126
- }
2127
- /**
2128
- * Assembles a router-style AGENTS.md that maps file extensions to
2129
- * per-category _INDEX.md files. Much smaller than the flat list.
2130
- * @param baseDir The base directory containing skill categories
2131
- * @param allowedCategories Optional filter for which categories to include
2132
- * @returns A compact router-table markdown string
2133
- */
2134
2357
  async assembleRouterIndex(baseDir, allowedCategories) {
2135
- const fileRouting = await this.loadFileRouting(baseDir);
2358
+ const fileRouting = await this.reader.loadFileRouting(baseDir);
2136
2359
  let availableCategories = [];
2137
2360
  if (await fs.pathExists(baseDir)) {
2138
2361
  const dirs = await fs.readdir(baseDir);
@@ -2158,9 +2381,13 @@ ${entries.join("\n")}
2158
2381
  extGrouped.get(key).push(ext);
2159
2382
  filtered.forEach((c) => coveredCategories.add(c));
2160
2383
  }
2384
+ const formatRouterPattern = (ext) => {
2385
+ if (ext.startsWith("_")) return `\`*${ext}\``;
2386
+ return `\`*.${ext}\``;
2387
+ };
2161
2388
  for (const [catKey, exts] of extGrouped) {
2162
2389
  const cats = catKey.split("+");
2163
- const extDisplay = exts.map((e) => `\`*.${e}\``).join(", ");
2390
+ const extDisplay = exts.map((e) => formatRouterPattern(e)).join(", ");
2164
2391
  const catDisplay = cats.map((c) => `\`<SKILLS>/${c}/_INDEX.md\``).join(", ");
2165
2392
  routerRows.push(`| ${extDisplay} | ${catDisplay} |`);
2166
2393
  }
@@ -2184,13 +2411,13 @@ ${entries.join("\n")}
2184
2411
  "",
2185
2412
  "## Skill Resolution Protocol",
2186
2413
  "",
2187
- "Each `_INDEX.md` has two sections — follow both:",
2414
+ "Each `_INDEX.md` has two sections - follow both:",
2188
2415
  "",
2189
- "1. **Match file type** → find the category index in the router table below.",
2190
- "2. **Read the `_INDEX.md`** → it has two sections:",
2416
+ "1. **Match file type** -> find the category index in the router table below.",
2417
+ "2. **Read the `_INDEX.md`** -> it has two sections:",
2191
2418
  " - **File Match**: auto-check these against the file you are editing (path pattern match).",
2192
2419
  " - **Keyword Match**: only check if the user's request mentions these concepts.",
2193
- "3. **Load ALL matched `SKILL.md`** → read every matched skill before writing code. The tier model keeps matches focused.",
2420
+ "3. **Load ALL matched `SKILL.md`** -> read every matched skill before writing code. The tier model keeps matches focused.",
2194
2421
  "",
2195
2422
  "> `<SKILLS>` = your agent's skill directory (e.g., `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`).",
2196
2423
  "",
@@ -2198,129 +2425,13 @@ ${entries.join("\n")}
2198
2425
  "| --------- | ------------------- |",
2199
2426
  ...routerRows,
2200
2427
  "",
2201
- '> [!TIP] **Indirect phrasing counts.** "make it faster" → performance, "broken query" → database, "login flow" → auth.',
2428
+ "> [!NOTE] **Test/spec file precedence:** `.spec.ts`, `.test.ts` -> use the `common` row (takes precedence over the generic `*.ts` row). `.spec.tsx`, `.test.tsx` -> use the `react` row (takes precedence over the generic `*.tsx` row).",
2429
+ "",
2430
+ '> [!TIP] **Indirect phrasing counts.** "make it faster" -> performance, "broken query" -> database, "login flow" -> auth.',
2202
2431
  ""
2203
2432
  ].join("\n");
2204
2433
  return header;
2205
2434
  }
2206
- async parseSkill(skillPath) {
2207
- try {
2208
- const content = await fs.readFile(skillPath, "utf8");
2209
- const frontmatterMatch = content.match(
2210
- /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
2211
- );
2212
- if (!frontmatterMatch) return null;
2213
- const fm = yaml.load(frontmatterMatch[1]);
2214
- const body = frontmatterMatch[2];
2215
- const priorityMatch = body.match(/## \*\*Priority:\s*([^*]+)\*\*/);
2216
- const priority = priorityMatch ? priorityMatch[1].trim() : "P1";
2217
- let triggers = fm.metadata?.triggers || {};
2218
- const hasStructured = triggers.files && triggers.files.length > 0 || triggers.keywords && triggers.keywords.length > 0;
2219
- if (!hasStructured && fm.description) {
2220
- const descTrigs = fm.description.match(/\(triggers:\s*`?(.*?)`?\)\s*$/);
2221
- if (descTrigs) {
2222
- const rawParts = descTrigs[1].split(",");
2223
- const parts = [];
2224
- let current = "";
2225
- let braceCount = 0;
2226
- for (let i = 0; i < rawParts.length; i++) {
2227
- const p = rawParts[i];
2228
- braceCount += (p.match(/{/g) || []).length;
2229
- braceCount -= (p.match(/}/g) || []).length;
2230
- current += (current ? "," : "") + p;
2231
- if (braceCount === 0) {
2232
- parts.push(current.trim());
2233
- current = "";
2234
- }
2235
- }
2236
- if (current) parts.push(current.trim());
2237
- const files = parts.filter(
2238
- (p) => p.includes("*") || p.includes("/") || p.includes("{") || /\.\w+$/.test(p)
2239
- );
2240
- const keywords = parts.filter(
2241
- (p) => !p.includes("*") && !p.includes("/") && !p.includes("{") && !/\.\w+$/.test(p)
2242
- );
2243
- triggers = {
2244
- ...triggers,
2245
- files: files.length > 0 ? files : triggers.files,
2246
- keywords: keywords.length > 0 ? keywords : triggers.keywords
2247
- };
2248
- }
2249
- }
2250
- return {
2251
- name: fm.name || "",
2252
- description: fm.description || "",
2253
- priority,
2254
- triggers
2255
- };
2256
- } catch {
2257
- return null;
2258
- }
2259
- }
2260
- /**
2261
- * Derives foundational composite IDs for a skill based on registry-owned naming rules.
2262
- * Only applies to non-common skills. Deduplicates against explicit composites.
2263
- */
2264
- deriveFoundationalComposites(category, skill, explicitComposites, rules) {
2265
- if (category === "common") return [];
2266
- const derived = [];
2267
- for (const [foundational, patterns] of Object.entries(rules)) {
2268
- const matches = patterns.some((p) => skill.includes(p));
2269
- if (matches && !explicitComposites.includes(foundational)) {
2270
- derived.push(foundational);
2271
- }
2272
- }
2273
- return derived;
2274
- }
2275
- /**
2276
- * Sanitizes a skill description to prevent indirect prompt injection.
2277
- * Strips known instruction-hijack patterns and replaces them with [REDACTED].
2278
- * Logs a stderr warning for every skill that required sanitisation.
2279
- * @param desc The raw description string from skill frontmatter
2280
- * @param skillId The skill identifier (category/skill) for log context
2281
- * @returns Sanitised description safe to embed in AGENTS.md
2282
- */
2283
- sanitizeDescription(desc, skillId = "unknown") {
2284
- let sanitized = desc;
2285
- let wasModified = false;
2286
- for (const pattern of INJECTION_PATTERNS) {
2287
- if (pattern.test(sanitized)) {
2288
- wasModified = true;
2289
- sanitized = sanitized.replace(pattern, "[REDACTED]");
2290
- }
2291
- pattern.lastIndex = 0;
2292
- }
2293
- if (wasModified) {
2294
- process.stderr.write(
2295
- `[SECURITY] Prompt injection pattern stripped from skill description: ${skillId}
2296
- `
2297
- );
2298
- }
2299
- return sanitized;
2300
- }
2301
- formatEntry(category, skill, metadata, foundationalRules = {}) {
2302
- const id = `${category}/${skill}`;
2303
- const prefix = metadata.priority.startsWith("P0") ? "🚨 " : "";
2304
- const explicitComposites = metadata.triggers.composite || [];
2305
- const autoComposites = this.deriveFoundationalComposites(
2306
- category,
2307
- skill,
2308
- explicitComposites,
2309
- foundationalRules
2310
- );
2311
- const allComposites = [...explicitComposites, ...autoComposites];
2312
- const triggers = [
2313
- ...metadata.triggers.files || [],
2314
- ...metadata.triggers.keywords || [],
2315
- ...allComposites.length ? allComposites.map((c) => `+${c}`) : [],
2316
- ...metadata.triggers.exclude ? metadata.triggers.exclude.map((e) => `!${e}`) : []
2317
- ].join(", ");
2318
- const triggerText = triggers ? ` (triggers: \`${triggers}\`)` : "";
2319
- let desc = this.sanitizeDescription(metadata.description || "", id);
2320
- desc = desc.replace(/\(triggers:\s*`?(.*?)`?\)/g, "(triggers: `$1`)");
2321
- const content = `${prefix}${desc}`.trim();
2322
- return `- **[${id}]**: ${content}${triggerText}`;
2323
- }
2324
2435
  }
2325
2436
  class SkillSyncService {
2326
2437
  constructor(githubService) {
@@ -2540,7 +2651,7 @@ class WorkflowTransformer {
2540
2651
  case "command":
2541
2652
  return {
2542
2653
  name: `${baseName}.md`,
2543
- content: this.toClaudeCommand(baseName, description, body)
2654
+ content: this.toMarkdownCommand(baseName, description, body)
2544
2655
  };
2545
2656
  case "toml":
2546
2657
  return {
@@ -2556,6 +2667,11 @@ class WorkflowTransformer {
2556
2667
  name: `${baseName}.prompt.md`,
2557
2668
  content: this.toCopilotPrompt(description, body)
2558
2669
  };
2670
+ case "skill":
2671
+ return {
2672
+ name: "SKILL.md",
2673
+ content: this.toSkillMarkdown(baseName, description, body)
2674
+ };
2559
2675
  }
2560
2676
  }
2561
2677
  static parseSource(content) {
@@ -2568,12 +2684,12 @@ class WorkflowTransformer {
2568
2684
  };
2569
2685
  }
2570
2686
  /**
2571
- * Claude Code custom slash command format.
2572
- * Lives at .claude/commands/<name>.md
2687
+ * Markdown-based custom slash command format.
2688
+ * Used by Claude Code (.claude/commands/), Roo Code (.roo/commands/), and OpenCode (.opencode/commands/).
2573
2689
  * User invokes via /<name> [arguments]
2574
- * $ARGUMENTS placeholder for user input.
2690
+ * Use $ARGUMENTS as a universal placeholder for user input instructions.
2575
2691
  */
2576
- static toClaudeCommand(name, description, body) {
2692
+ static toMarkdownCommand(name, description, body) {
2577
2693
  const title = name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
2578
2694
  return `# ${title}
2579
2695
 
@@ -2614,6 +2730,26 @@ description: "${description}"
2614
2730
  ---
2615
2731
  ${body}`;
2616
2732
  }
2733
+ /**
2734
+ * Agent Skill (SKILL.md) format.
2735
+ * Used by Cursor, Trae, and Codex.
2736
+ * Lives at <workflowPath>/<name>/SKILL.md
2737
+ * User invokes via /<name> in Agent/Skill interface.
2738
+ */
2739
+ static toSkillMarkdown(name, description, body) {
2740
+ const title = name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
2741
+ return `# ${title} Skill
2742
+
2743
+ > [!IMPORTANT]
2744
+ > ${description}
2745
+
2746
+ ## Instructions
2747
+
2748
+ When the user asks to perform this workflow, execute the following steps:
2749
+
2750
+ ${body}
2751
+ `;
2752
+ }
2617
2753
  }
2618
2754
  class WorkflowSyncService {
2619
2755
  constructor(githubService) {
@@ -2733,10 +2869,20 @@ class WorkflowSyncService {
2733
2869
  skillsRelative
2734
2870
  );
2735
2871
  if (!transformed) continue;
2736
- const targetFilePath = path.join(workflowDir, transformed.name);
2872
+ let targetFilePath;
2873
+ if (agentDef.workflowFormat === "skill") {
2874
+ const workflowName = fileItem.name.replace(/\.md$/, "");
2875
+ targetFilePath = path.join(
2876
+ workflowDir,
2877
+ workflowName,
2878
+ transformed.name
2879
+ );
2880
+ } else {
2881
+ targetFilePath = path.join(workflowDir, transformed.name);
2882
+ }
2737
2883
  if (!this.isPathSafe(targetFilePath, workflowDir)) {
2738
2884
  console.log(
2739
- pc.red(` ❌ Security Error: Invalid path ${transformed.name}`)
2885
+ pc.red(` ❌ Security Error: Invalid path ${targetFilePath}`)
2740
2886
  );
2741
2887
  continue;
2742
2888
  }
@@ -2871,9 +3017,27 @@ class SyncService {
2871
3017
  );
2872
3018
  }
2873
3019
  try {
2874
- const generator = new IndexGeneratorService();
3020
+ const generator = new IndexGeneratorServiceImpl();
2875
3021
  const baseDir = agentDef ? path.join(process.cwd(), agentDef.path) : path.join(process.cwd(), ".cursor/skills");
2876
3022
  const allowedCategories = Object.keys(config.skills || {});
3023
+ const githubMatch = config.registry ? GithubService.parseGitHubUrl(config.registry) : null;
3024
+ if (githubMatch) {
3025
+ const { owner, repo } = githubMatch;
3026
+ const repoInfo = await this.githubService.getRepoInfo(owner, repo);
3027
+ const ref = repoInfo?.default_branch || "main";
3028
+ const metaRaw = await this.githubService.getRawFile(
3029
+ owner,
3030
+ repo,
3031
+ ref,
3032
+ "skills/metadata.json"
3033
+ );
3034
+ if (metaRaw) {
3035
+ try {
3036
+ generator.withMetadata(JSON.parse(metaRaw));
3037
+ } catch {
3038
+ }
3039
+ }
3040
+ }
2877
3041
  const categoryIndices = await generator.generateAllCategoryIndices(
2878
3042
  baseDir,
2879
3043
  allowedCategories
@@ -3043,7 +3207,7 @@ class SyncCommand {
3043
3207
  }
3044
3208
  }
3045
3209
  }
3046
- const version = "2.1.0";
3210
+ const version = "2.1.2";
3047
3211
  const pkg = {
3048
3212
  version
3049
3213
  };
@@ -3170,24 +3334,36 @@ class GitService {
3170
3334
  */
3171
3335
  getChangedFiles(rootDir) {
3172
3336
  try {
3173
- let gitCommand;
3174
3337
  if (process.env.GITHUB_BASE_REF) {
3338
+ const baseRef = process.env.GITHUB_BASE_REF;
3339
+ if (!/^[\w./:-]+$/.test(baseRef)) {
3340
+ if (process.env.DEBUG) {
3341
+ console.warn(
3342
+ "GITHUB_BASE_REF contains unsafe characters; skipping CI diff."
3343
+ );
3344
+ }
3345
+ return [];
3346
+ }
3175
3347
  try {
3176
- child_process.execSync(
3177
- `git fetch origin ${process.env.GITHUB_BASE_REF} --depth=1`,
3178
- {
3179
- cwd: rootDir,
3180
- stdio: "ignore"
3181
- }
3182
- );
3348
+ child_process.execFileSync("git", ["fetch", "origin", baseRef, "--depth=1"], {
3349
+ cwd: rootDir,
3350
+ stdio: "ignore"
3351
+ });
3183
3352
  } catch {
3184
3353
  }
3185
- gitCommand = `git diff --name-only origin/${process.env.GITHUB_BASE_REF}...HEAD`;
3354
+ const output = child_process.execFileSync(
3355
+ "git",
3356
+ ["diff", "--name-only", `origin/${baseRef}...HEAD`],
3357
+ { cwd: rootDir, encoding: "utf8" }
3358
+ );
3359
+ return output.split("\n").map((f) => f.trim()).filter((f) => f !== "" && fs.existsSync(path.join(rootDir, f)));
3186
3360
  } else {
3187
- gitCommand = "git diff --name-only HEAD";
3361
+ const output = child_process.execSync("git diff --name-only HEAD", {
3362
+ cwd: rootDir,
3363
+ encoding: "utf8"
3364
+ });
3365
+ return output.split("\n").map((f) => f.trim()).filter((f) => f !== "" && fs.existsSync(path.join(rootDir, f)));
3188
3366
  }
3189
- const output = child_process.execSync(gitCommand, { cwd: rootDir, encoding: "utf8" });
3190
- return output.split("\n").map((f) => f.trim()).filter((f) => f !== "" && fs.existsSync(path.join(rootDir, f)));
3191
3367
  } catch (error) {
3192
3368
  if (process.env.DEBUG) {
3193
3369
  console.warn("Git failure while getting changed files:", error);
@@ -3561,7 +3737,7 @@ dotenv.config();
3561
3737
  const program = new commander.Command();
3562
3738
  program.name("agent-skills-standard").description(
3563
3739
  "A CLI to manage and sync AI agent skills for Cursor, Claude, Copilot, Windsurf, and more."
3564
- ).version("2.1.0");
3740
+ ).version("2.1.2");
3565
3741
  program.command("init").description("Initialize a .skillsrc configuration file interactively").action(async () => {
3566
3742
  const init = new InitCommand();
3567
3743
  await init.run();