agent-skills-standard 2.1.1 → 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,86 +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 {
1878
- remoteMetadata;
1879
- /**
1880
- * Injects registry metadata so index generation does not require metadata.json
1881
- * to be present on disk. Returns `this` for chaining.
1882
- */
1883
- withMetadata(metadata) {
1884
- this.remoteMetadata = metadata;
1885
- return this;
1911
+ class IndexContentBuilder {
1912
+ metadataReader;
1913
+ constructor(metadataReader) {
1914
+ this.metadataReader = metadataReader;
1886
1915
  }
1887
1916
  /**
1888
- * Loads foundational composite rules. Uses injected remote metadata when available;
1889
- * falls back to reading metadata.json from baseDir.
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.
1890
1919
  */
1891
- async loadFoundationalRules(baseDir) {
1892
- if (this.remoteMetadata?.foundational_composite_rules !== void 0) {
1893
- return this.remoteMetadata.foundational_composite_rules;
1894
- }
1895
- try {
1896
- const metaPath = path.join(baseDir, "metadata.json");
1897
- const raw = await fs.readFile(metaPath, "utf8");
1898
- const parsed = JSON.parse(raw);
1899
- return parsed.foundational_composite_rules ?? {};
1900
- } catch {
1901
- 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
+ }
1902
1928
  }
1929
+ return derived;
1903
1930
  }
1904
1931
  /**
1905
- * Generates a markdown index of available skills across multiple categories.
1906
- * @param baseDir The base directory containing categories and skills
1907
- * @param allowedCategories List of categories/frameworks to include. If undefined, all found are included.
1908
- * @returns A formatted markdown string representing the index
1932
+ * Formats a single skill into a list item for AGENTS.md.
1909
1933
  */
1910
- async generate(baseDir, allowedCategories) {
1911
- const entries = /* @__PURE__ */ new Set();
1912
- const foundationalRules = await this.loadFoundationalRules(baseDir);
1913
- if (!await fs.pathExists(baseDir)) return this.assembleIndex([]);
1914
- let categories = await fs.readdir(baseDir);
1915
- categories = categories.filter((category) => {
1916
- if (category.startsWith(".")) return false;
1917
- if (allowedCategories && !allowedCategories.includes(category) && category !== "common") {
1918
- return false;
1919
- }
1920
- return true;
1921
- });
1922
- categories.sort();
1923
- for (const category of categories) {
1924
- const categoryPath = path.join(baseDir, category);
1925
- if (!(await fs.stat(categoryPath)).isDirectory()) continue;
1926
- const skills = await fs.readdir(categoryPath);
1927
- skills.sort();
1928
- for (const skill of skills) {
1929
- const skillPath = path.join(categoryPath, skill, "SKILL.md");
1930
- if (!await fs.pathExists(skillPath)) continue;
1931
- const metadata = await this.parseSkill(skillPath);
1932
- if (metadata) {
1933
- const entry = this.formatEntry(
1934
- category,
1935
- skill,
1936
- metadata,
1937
- foundationalRules
1938
- );
1939
- entries.add(entry);
1940
- }
1941
- }
1942
- }
1943
- 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}`;
1944
1959
  }
1945
1960
  /**
1946
- * Assembles the full index markdown including headers and Zero-Trust rules.
1947
- * @param entries List of formatted skill entries
1948
- * @param format The format of the entries ('detailed' or 'compact')
1949
- * @returns Complete markdown index string
1961
+ * Assembles the full AGENTS.md markdown including headers and Zero-Trust rules.
1950
1962
  */
1951
1963
  assembleIndex(entries) {
1952
1964
  const header = [
@@ -1999,12 +2011,53 @@ class IndexGeneratorService {
1999
2011
  "> [!TIP]",
2000
2012
  "> **Indirect phrasing still counts.** Match keywords by intent, not just exact words.",
2001
2013
  '> Examples: "make it faster" → `performance`, "broken query" → `database`, "login flow" → `auth`, "clean up this file" → `refactor`.',
2014
+ "",
2015
+ "### List of Available Skills",
2002
2016
  ""
2003
2017
  ].join("\n");
2004
2018
  return `${header}
2005
2019
  ${entries.join("\n")}
2006
2020
  `;
2007
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
+ }
2037
+ /**
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
+ }
2008
2061
  /**
2009
2062
  * Loads file routing rules. Uses injected remote metadata when available;
2010
2063
  * falls back to reading metadata.json from baseDir.
@@ -2015,12 +2068,17 @@ ${entries.join("\n")}
2015
2068
  }
2016
2069
  try {
2017
2070
  const metaPath = path.join(baseDir, "metadata.json");
2018
- const raw = await fs.readFile(metaPath, "utf8");
2019
- const parsed = JSON.parse(raw);
2020
- return parsed.file_routing ?? {};
2021
- } catch {
2022
- 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
+ }
2023
2080
  }
2081
+ return {};
2024
2082
  }
2025
2083
  /**
2026
2084
  * Loads broad glob patterns and base language skill mappings. Uses injected remote
@@ -2035,40 +2093,215 @@ ${entries.join("\n")}
2035
2093
  }
2036
2094
  try {
2037
2095
  const metaPath = path.join(baseDir, "metadata.json");
2038
- const raw = await fs.readFile(metaPath, "utf8");
2039
- 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
+ }
2040
2163
  return {
2041
- broadGlobs: parsed.broad_globs ?? [],
2042
- baseSkills: parsed.base_language_skills ?? {}
2164
+ name: typeof fm.name === "string" ? fm.name : "",
2165
+ description,
2166
+ priority,
2167
+ triggers
2043
2168
  };
2044
- } catch {
2045
- 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;
2046
2176
  }
2047
2177
  }
2048
2178
  /**
2049
- * Generates a tiered per-category _INDEX.md with two sections:
2050
- * - **File Match**: Skills with specific path patterns (auto-match when editing a file)
2051
- * - **Keyword Match**: Skills triggered by user intent (only when user mentions the concept)
2052
- *
2053
- * Broad globs (e.g., **\/*.ts) are stripped from non-base skills and those skills
2054
- * 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.
2055
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
+ }
2056
2285
  async generateCategoryIndex(baseDir, category) {
2057
2286
  const categoryPath = path.join(baseDir, category);
2058
2287
  if (!await fs.pathExists(categoryPath)) return "";
2059
- const { broadGlobs, baseSkills } = await this.loadTierConfig(baseDir);
2288
+ const { broadGlobs, baseSkills } = await this.reader.loadTierConfig(baseDir);
2060
2289
  const baseSkillForCategory = baseSkills[category];
2061
- const skills = await fs.readdir(categoryPath);
2062
- skills.sort();
2290
+ const entries = await fs.readdir(categoryPath);
2291
+ entries.sort();
2063
2292
  const fileMatchRows = [];
2064
2293
  const keywordMatchRows = [];
2065
- for (const skill of skills) {
2066
- if (skill.startsWith(".") || skill === "_INDEX.md") continue;
2067
- const skillPath = path.join(categoryPath, skill, "SKILL.md");
2068
- if (!await fs.pathExists(skillPath)) continue;
2069
- 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);
2070
2303
  if (!metadata) continue;
2071
- const isBaseSkill = skill === baseSkillForCategory;
2304
+ const isBaseSkill = skillId === baseSkillForCategory;
2072
2305
  const allFiles = metadata.triggers.files || [];
2073
2306
  const kwTrigs = (metadata.triggers.keywords || []).join(", ") || "—";
2074
2307
  const prefix = metadata.priority.startsWith("P0") ? "**" : "";
@@ -2078,15 +2311,15 @@ ${entries.join("\n")}
2078
2311
  if (isBaseSkill || specificFiles.length > 0 && !hasBroadGlob) {
2079
2312
  const displayFiles = isBaseSkill ? allFiles.map((f) => `\`${f}\``).join(", ") : specificFiles.map((f) => `\`${f}\``).join(", ");
2080
2313
  fileMatchRows.push(
2081
- `| ${prefix}${skill}${suffix} | ${displayFiles || "—"} | ${kwTrigs} |`
2314
+ `| ${prefix}${skillId}${suffix} | ${displayFiles || "—"} | ${kwTrigs} |`
2082
2315
  );
2083
2316
  } else if (specificFiles.length > 0 && hasBroadGlob) {
2084
2317
  const displayFiles = specificFiles.map((f) => `\`${f}\``).join(", ");
2085
2318
  fileMatchRows.push(
2086
- `| ${prefix}${skill}${suffix} | ${displayFiles} | ${kwTrigs} |`
2319
+ `| ${prefix}${skillId}${suffix} | ${displayFiles} | ${kwTrigs} |`
2087
2320
  );
2088
2321
  } else {
2089
- keywordMatchRows.push(`| ${prefix}${skill}${suffix} | ${kwTrigs} |`);
2322
+ keywordMatchRows.push(`| ${prefix}${skillId}${suffix} | ${kwTrigs} |`);
2090
2323
  }
2091
2324
  }
2092
2325
  if (fileMatchRows.length === 0 && keywordMatchRows.length === 0) return "";
@@ -2121,39 +2354,8 @@ ${entries.join("\n")}
2121
2354
  );
2122
2355
  return lines.join("\n");
2123
2356
  }
2124
- /**
2125
- * Generates all _INDEX.md files for categories found in baseDir.
2126
- * @returns Map of category name to generated index content
2127
- */
2128
- async generateAllCategoryIndices(baseDir, allowedCategories) {
2129
- if (!await fs.pathExists(baseDir)) return {};
2130
- let categories = await fs.readdir(baseDir);
2131
- categories = categories.filter((c) => {
2132
- if (c.startsWith(".") || c === "_INDEX.md") return false;
2133
- if (allowedCategories && !allowedCategories.includes(c) && c !== "common") {
2134
- return false;
2135
- }
2136
- return true;
2137
- });
2138
- categories.sort();
2139
- const result = {};
2140
- for (const category of categories) {
2141
- const categoryPath = path.join(baseDir, category);
2142
- if (!(await fs.stat(categoryPath)).isDirectory()) continue;
2143
- const index = await this.generateCategoryIndex(baseDir, category);
2144
- if (index) result[category] = index;
2145
- }
2146
- return result;
2147
- }
2148
- /**
2149
- * Assembles a router-style AGENTS.md that maps file extensions to
2150
- * per-category _INDEX.md files. Much smaller than the flat list.
2151
- * @param baseDir The base directory containing skill categories
2152
- * @param allowedCategories Optional filter for which categories to include
2153
- * @returns A compact router-table markdown string
2154
- */
2155
2357
  async assembleRouterIndex(baseDir, allowedCategories) {
2156
- const fileRouting = await this.loadFileRouting(baseDir);
2358
+ const fileRouting = await this.reader.loadFileRouting(baseDir);
2157
2359
  let availableCategories = [];
2158
2360
  if (await fs.pathExists(baseDir)) {
2159
2361
  const dirs = await fs.readdir(baseDir);
@@ -2179,9 +2381,13 @@ ${entries.join("\n")}
2179
2381
  extGrouped.get(key).push(ext);
2180
2382
  filtered.forEach((c) => coveredCategories.add(c));
2181
2383
  }
2384
+ const formatRouterPattern = (ext) => {
2385
+ if (ext.startsWith("_")) return `\`*${ext}\``;
2386
+ return `\`*.${ext}\``;
2387
+ };
2182
2388
  for (const [catKey, exts] of extGrouped) {
2183
2389
  const cats = catKey.split("+");
2184
- const extDisplay = exts.map((e) => `\`*.${e}\``).join(", ");
2390
+ const extDisplay = exts.map((e) => formatRouterPattern(e)).join(", ");
2185
2391
  const catDisplay = cats.map((c) => `\`<SKILLS>/${c}/_INDEX.md\``).join(", ");
2186
2392
  routerRows.push(`| ${extDisplay} | ${catDisplay} |`);
2187
2393
  }
@@ -2205,13 +2411,13 @@ ${entries.join("\n")}
2205
2411
  "",
2206
2412
  "## Skill Resolution Protocol",
2207
2413
  "",
2208
- "Each `_INDEX.md` has two sections — follow both:",
2414
+ "Each `_INDEX.md` has two sections - follow both:",
2209
2415
  "",
2210
- "1. **Match file type** → find the category index in the router table below.",
2211
- "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:",
2212
2418
  " - **File Match**: auto-check these against the file you are editing (path pattern match).",
2213
2419
  " - **Keyword Match**: only check if the user's request mentions these concepts.",
2214
- "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.",
2215
2421
  "",
2216
2422
  "> `<SKILLS>` = your agent's skill directory (e.g., `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`).",
2217
2423
  "",
@@ -2219,129 +2425,13 @@ ${entries.join("\n")}
2219
2425
  "| --------- | ------------------- |",
2220
2426
  ...routerRows,
2221
2427
  "",
2222
- '> [!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.',
2223
2431
  ""
2224
2432
  ].join("\n");
2225
2433
  return header;
2226
2434
  }
2227
- async parseSkill(skillPath) {
2228
- try {
2229
- const content = await fs.readFile(skillPath, "utf8");
2230
- const frontmatterMatch = content.match(
2231
- /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
2232
- );
2233
- if (!frontmatterMatch) return null;
2234
- const fm = yaml.load(frontmatterMatch[1]);
2235
- const body = frontmatterMatch[2];
2236
- const priorityMatch = body.match(/## \*\*Priority:\s*([^*]+)\*\*/);
2237
- const priority = priorityMatch ? priorityMatch[1].trim() : "P1";
2238
- let triggers = fm.metadata?.triggers || {};
2239
- const hasStructured = triggers.files && triggers.files.length > 0 || triggers.keywords && triggers.keywords.length > 0;
2240
- if (!hasStructured && fm.description) {
2241
- const descTrigs = fm.description.match(/\(triggers:\s*`?(.*?)`?\)\s*$/);
2242
- if (descTrigs) {
2243
- const rawParts = descTrigs[1].split(",");
2244
- const parts = [];
2245
- let current = "";
2246
- let braceCount = 0;
2247
- for (let i = 0; i < rawParts.length; i++) {
2248
- const p = rawParts[i];
2249
- braceCount += (p.match(/{/g) || []).length;
2250
- braceCount -= (p.match(/}/g) || []).length;
2251
- current += (current ? "," : "") + p;
2252
- if (braceCount === 0) {
2253
- parts.push(current.trim());
2254
- current = "";
2255
- }
2256
- }
2257
- if (current) parts.push(current.trim());
2258
- const files = parts.filter(
2259
- (p) => p.includes("*") || p.includes("/") || p.includes("{") || /\.\w+$/.test(p)
2260
- );
2261
- const keywords = parts.filter(
2262
- (p) => !p.includes("*") && !p.includes("/") && !p.includes("{") && !/\.\w+$/.test(p)
2263
- );
2264
- triggers = {
2265
- ...triggers,
2266
- files: files.length > 0 ? files : triggers.files,
2267
- keywords: keywords.length > 0 ? keywords : triggers.keywords
2268
- };
2269
- }
2270
- }
2271
- return {
2272
- name: fm.name || "",
2273
- description: fm.description || "",
2274
- priority,
2275
- triggers
2276
- };
2277
- } catch {
2278
- return null;
2279
- }
2280
- }
2281
- /**
2282
- * Derives foundational composite IDs for a skill based on registry-owned naming rules.
2283
- * Only applies to non-common skills. Deduplicates against explicit composites.
2284
- */
2285
- deriveFoundationalComposites(category, skill, explicitComposites, rules) {
2286
- if (category === "common") return [];
2287
- const derived = [];
2288
- for (const [foundational, patterns] of Object.entries(rules)) {
2289
- const matches = patterns.some((p) => skill.includes(p));
2290
- if (matches && !explicitComposites.includes(foundational)) {
2291
- derived.push(foundational);
2292
- }
2293
- }
2294
- return derived;
2295
- }
2296
- /**
2297
- * Sanitizes a skill description to prevent indirect prompt injection.
2298
- * Strips known instruction-hijack patterns and replaces them with [REDACTED].
2299
- * Logs a stderr warning for every skill that required sanitisation.
2300
- * @param desc The raw description string from skill frontmatter
2301
- * @param skillId The skill identifier (category/skill) for log context
2302
- * @returns Sanitised description safe to embed in AGENTS.md
2303
- */
2304
- sanitizeDescription(desc, skillId = "unknown") {
2305
- let sanitized = desc;
2306
- let wasModified = false;
2307
- for (const pattern of INJECTION_PATTERNS) {
2308
- if (pattern.test(sanitized)) {
2309
- wasModified = true;
2310
- sanitized = sanitized.replace(pattern, "[REDACTED]");
2311
- }
2312
- pattern.lastIndex = 0;
2313
- }
2314
- if (wasModified) {
2315
- process.stderr.write(
2316
- `[SECURITY] Prompt injection pattern stripped from skill description: ${skillId}
2317
- `
2318
- );
2319
- }
2320
- return sanitized;
2321
- }
2322
- formatEntry(category, skill, metadata, foundationalRules = {}) {
2323
- const id = `${category}/${skill}`;
2324
- const prefix = metadata.priority.startsWith("P0") ? "🚨 " : "";
2325
- const explicitComposites = metadata.triggers.composite || [];
2326
- const autoComposites = this.deriveFoundationalComposites(
2327
- category,
2328
- skill,
2329
- explicitComposites,
2330
- foundationalRules
2331
- );
2332
- const allComposites = [...explicitComposites, ...autoComposites];
2333
- const triggers = [
2334
- ...metadata.triggers.files || [],
2335
- ...metadata.triggers.keywords || [],
2336
- ...allComposites.length ? allComposites.map((c) => `+${c}`) : [],
2337
- ...metadata.triggers.exclude ? metadata.triggers.exclude.map((e) => `!${e}`) : []
2338
- ].join(", ");
2339
- const triggerText = triggers ? ` (triggers: \`${triggers}\`)` : "";
2340
- let desc = this.sanitizeDescription(metadata.description || "", id);
2341
- desc = desc.replace(/\(triggers:\s*`?(.*?)`?\)/g, "(triggers: `$1`)");
2342
- const content = `${prefix}${desc}`.trim();
2343
- return `- **[${id}]**: ${content}${triggerText}`;
2344
- }
2345
2435
  }
2346
2436
  class SkillSyncService {
2347
2437
  constructor(githubService) {
@@ -2561,7 +2651,7 @@ class WorkflowTransformer {
2561
2651
  case "command":
2562
2652
  return {
2563
2653
  name: `${baseName}.md`,
2564
- content: this.toClaudeCommand(baseName, description, body)
2654
+ content: this.toMarkdownCommand(baseName, description, body)
2565
2655
  };
2566
2656
  case "toml":
2567
2657
  return {
@@ -2577,6 +2667,11 @@ class WorkflowTransformer {
2577
2667
  name: `${baseName}.prompt.md`,
2578
2668
  content: this.toCopilotPrompt(description, body)
2579
2669
  };
2670
+ case "skill":
2671
+ return {
2672
+ name: "SKILL.md",
2673
+ content: this.toSkillMarkdown(baseName, description, body)
2674
+ };
2580
2675
  }
2581
2676
  }
2582
2677
  static parseSource(content) {
@@ -2589,12 +2684,12 @@ class WorkflowTransformer {
2589
2684
  };
2590
2685
  }
2591
2686
  /**
2592
- * Claude Code custom slash command format.
2593
- * 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/).
2594
2689
  * User invokes via /<name> [arguments]
2595
- * $ARGUMENTS placeholder for user input.
2690
+ * Use $ARGUMENTS as a universal placeholder for user input instructions.
2596
2691
  */
2597
- static toClaudeCommand(name, description, body) {
2692
+ static toMarkdownCommand(name, description, body) {
2598
2693
  const title = name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
2599
2694
  return `# ${title}
2600
2695
 
@@ -2635,6 +2730,26 @@ description: "${description}"
2635
2730
  ---
2636
2731
  ${body}`;
2637
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
+ }
2638
2753
  }
2639
2754
  class WorkflowSyncService {
2640
2755
  constructor(githubService) {
@@ -2754,10 +2869,20 @@ class WorkflowSyncService {
2754
2869
  skillsRelative
2755
2870
  );
2756
2871
  if (!transformed) continue;
2757
- 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
+ }
2758
2883
  if (!this.isPathSafe(targetFilePath, workflowDir)) {
2759
2884
  console.log(
2760
- pc.red(` ❌ Security Error: Invalid path ${transformed.name}`)
2885
+ pc.red(` ❌ Security Error: Invalid path ${targetFilePath}`)
2761
2886
  );
2762
2887
  continue;
2763
2888
  }
@@ -2892,7 +3017,7 @@ class SyncService {
2892
3017
  );
2893
3018
  }
2894
3019
  try {
2895
- const generator = new IndexGeneratorService();
3020
+ const generator = new IndexGeneratorServiceImpl();
2896
3021
  const baseDir = agentDef ? path.join(process.cwd(), agentDef.path) : path.join(process.cwd(), ".cursor/skills");
2897
3022
  const allowedCategories = Object.keys(config.skills || {});
2898
3023
  const githubMatch = config.registry ? GithubService.parseGitHubUrl(config.registry) : null;
@@ -3082,7 +3207,7 @@ class SyncCommand {
3082
3207
  }
3083
3208
  }
3084
3209
  }
3085
- const version = "2.1.1";
3210
+ const version = "2.1.2";
3086
3211
  const pkg = {
3087
3212
  version
3088
3213
  };
@@ -3209,24 +3334,36 @@ class GitService {
3209
3334
  */
3210
3335
  getChangedFiles(rootDir) {
3211
3336
  try {
3212
- let gitCommand;
3213
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
+ }
3214
3347
  try {
3215
- child_process.execSync(
3216
- `git fetch origin ${process.env.GITHUB_BASE_REF} --depth=1`,
3217
- {
3218
- cwd: rootDir,
3219
- stdio: "ignore"
3220
- }
3221
- );
3348
+ child_process.execFileSync("git", ["fetch", "origin", baseRef, "--depth=1"], {
3349
+ cwd: rootDir,
3350
+ stdio: "ignore"
3351
+ });
3222
3352
  } catch {
3223
3353
  }
3224
- 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)));
3225
3360
  } else {
3226
- 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)));
3227
3366
  }
3228
- const output = child_process.execSync(gitCommand, { cwd: rootDir, encoding: "utf8" });
3229
- return output.split("\n").map((f) => f.trim()).filter((f) => f !== "" && fs.existsSync(path.join(rootDir, f)));
3230
3367
  } catch (error) {
3231
3368
  if (process.env.DEBUG) {
3232
3369
  console.warn("Git failure while getting changed files:", error);
@@ -3600,7 +3737,7 @@ dotenv.config();
3600
3737
  const program = new commander.Command();
3601
3738
  program.name("agent-skills-standard").description(
3602
3739
  "A CLI to manage and sync AI agent skills for Cursor, Claude, Copilot, Windsurf, and more."
3603
- ).version("2.1.1");
3740
+ ).version("2.1.2");
3604
3741
  program.command("init").description("Initialize a .skillsrc configuration file interactively").action(async () => {
3605
3742
  const init = new InitCommand();
3606
3743
  await init.run();