agent-skills-standard 2.6.0 → 2.6.3

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
@@ -16,7 +16,7 @@ var __copyProps = (to, from, except, desc) => {
16
16
  }
17
17
  return to;
18
18
  };
19
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
20
  value: mod,
21
21
  enumerable: true
22
22
  }) : target, mod));
@@ -26,6 +26,7 @@ let dotenv = require("dotenv");
26
26
  dotenv = __toESM(dotenv);
27
27
  let picocolors = require("picocolors");
28
28
  picocolors = __toESM(picocolors);
29
+ let node_crypto = require("node:crypto");
29
30
  let fs_extra = require("fs-extra");
30
31
  fs_extra = __toESM(fs_extra);
31
32
  let path = require("path");
@@ -43,7 +44,7 @@ let child_process = require("child_process");
43
44
  let fs = require("fs");
44
45
  var package_default = {
45
46
  name: "agent-skills-standard",
46
- version: "2.6.0",
47
+ version: "2.6.3",
47
48
  description: "A CLI to manage and sync AI agent skills standard for Cursor, Claude, Copilot, Windsurf, and more.",
48
49
  main: "dist/index.js",
49
50
  bin: {
@@ -77,39 +78,121 @@ var package_default = {
77
78
  ],
78
79
  author: "Hoang Nguyen",
79
80
  license: "MIT",
81
+ repository: {
82
+ "type": "git",
83
+ "url": "https://github.com/HoangNguyen0403/agent-skills-standard",
84
+ "directory": "cli"
85
+ },
86
+ bugs: { "url": "https://github.com/HoangNguyen0403/agent-skills-standard/issues" },
87
+ homepage: "https://github.com/HoangNguyen0403/agent-skills-standard#readme",
80
88
  dependencies: {
81
- "commander": "^11.1.0",
89
+ "commander": "^15.0.0",
82
90
  "cross-fetch": "^4.1.0",
83
- "dotenv": "^17.2.3",
84
- "fs-extra": "^11.2.0",
85
- "inquirer": "^8.2.5",
86
- "js-yaml": "^4.2.0",
91
+ "dotenv": "^17.4.2",
92
+ "fs-extra": "^11.4.0",
93
+ "inquirer": "^14.2.2",
94
+ "js-yaml": "^5.4.2",
87
95
  "picocolors": "^1.0.0",
88
- "zod": "^3.22.4"
96
+ "zod": "^4.6.5"
89
97
  },
90
98
  devDependencies: {
91
- "@vitest/coverage-v8": "^4.0.17",
92
- "vite": "^7.0.0",
93
- "vitest": "^4.0.17"
99
+ "@vitest/coverage-v8": "^5.0.1",
100
+ "vite": "^8.3.0",
101
+ "vitest": "^5.0.1"
94
102
  }
95
103
  };
96
104
  //#endregion
97
- //#region src/services/EvalsVerifier.ts
98
- function checkAssertion(assertion, transcript) {
105
+ //#region src/services/assertion-semantics.ts
106
+ var SEMANTIC_STOP_WORDS = /* @__PURE__ */ new Set([
107
+ "a",
108
+ "an",
109
+ "and",
110
+ "as",
111
+ "at",
112
+ "by",
113
+ "for",
114
+ "from",
115
+ "in",
116
+ "into",
117
+ "is",
118
+ "it",
119
+ "of",
120
+ "on",
121
+ "or",
122
+ "the",
123
+ "to",
124
+ "use",
125
+ "via",
126
+ "with"
127
+ ]);
128
+ function normalizedText(value) {
129
+ return value.toLowerCase().replace(/[`*_~]/g, "").replace(/[’]/g, "'").replace(/\s+/g, " ").trim();
130
+ }
131
+ function compactText(value) {
132
+ return normalizedText(value).replace(/\s+/g, "");
133
+ }
134
+ function semanticTokens(value) {
135
+ return (normalizedText(value).replace(/[-_/]/g, " ").match(/@[a-z][a-z0-9]*|[a-z][a-z0-9]*|\d+/g) ?? []).filter((token) => !SEMANTIC_STOP_WORDS.has(token) && token.length > 2);
136
+ }
137
+ function tokenVariants(value) {
138
+ const variants = /* @__PURE__ */ new Set([value]);
139
+ const stemmed = value.replace(/(ing|ed|es|s)$/, "");
140
+ variants.add(stemmed);
141
+ if (value.endsWith("ing")) variants.add(`${stemmed}e`);
142
+ if (stemmed.endsWith("e")) variants.add(stemmed.slice(0, -1));
143
+ return variants;
144
+ }
145
+ function containsV2(value, transcript) {
146
+ const needle = normalizedText(value);
147
+ if (normalizedText(transcript).includes(needle)) return true;
148
+ if (/[{}[\]@/:?<>$%()]/.test(value)) {
149
+ if (compactText(transcript).includes(compactText(value))) return true;
150
+ }
151
+ if (/\d/.test(value)) return false;
152
+ if (/[{}[\]@/:?<>$%()]/.test(value)) {
153
+ const genericFunction = value.match(/^([a-z_$][a-z0-9_$]*)\s*<[^>]+>\s*\(\s*\)$/i);
154
+ if (genericFunction) return new RegExp(`${genericFunction[1]}\\s*(?:<[^>]+>)?\\s*\\(`, "i").test(transcript);
155
+ const constructorShape = value.match(/\b([A-Z][A-Za-z0-9_]*)\s*\(\s*val\s+([A-Za-z_][A-Za-z0-9_]*)/);
156
+ if (constructorShape) return new RegExp(`${constructorShape[1]}\\s*\\([\\s\\S]*?${constructorShape[2]}`, "i").test(transcript);
157
+ if (/^@for\s*\(/i.test(value)) return /@for\s*\(/i.test(transcript) && /\btrack\b/i.test(transcript) && /(?:\.\s*id|\bid\b)/i.test(transcript);
158
+ if (/^@if\s*\(/i.test(value)) return /@if\s*\(/i.test(transcript);
159
+ if (/^@empty\b/i.test(value)) return /@empty\b/i.test(transcript);
160
+ const functionName = value.match(/^([a-z_$][a-z0-9_$]*)\(\s*\)$/i)?.[1];
161
+ if (functionName) return new RegExp(`${functionName}\\s*\\(`, "i").test(transcript);
162
+ }
163
+ const required = semanticTokens(value);
164
+ const available = new Set(semanticTokens(transcript).flatMap((token) => [...tokenVariants(token)]));
165
+ if (required.length === 1) return [...tokenVariants(required[0])].some((variant) => available.has(variant));
166
+ if (required.length === 0) return false;
167
+ return required.every((token) => {
168
+ return [...tokenVariants(token)].some((variant) => available.has(variant) || [...available].some((candidate) => candidate.startsWith(variant) || variant.startsWith(candidate)));
169
+ });
170
+ }
171
+ /** Basename without importing `path`, so the module stays runtime-agnostic. */
172
+ function basename(value) {
173
+ const parts = value.split(/[\\/]/);
174
+ return parts[parts.length - 1] ?? value;
175
+ }
176
+ function checkAssertion(assertion, transcript, semanticsVersion = 1) {
99
177
  const haystack = transcript.toLowerCase();
100
- if (assertion.type === "contains_any") return (Array.isArray(assertion.value) ? assertion.value : [assertion.value]).some((value) => haystack.includes(value.toLowerCase()));
101
- if (assertion.type === "not_contains") return !haystack.includes(String(assertion.value).toLowerCase());
102
- if (assertion.type === "regex") try {
103
- return new RegExp(String(assertion.value), "i").test(transcript);
104
- } catch {
105
- return false;
106
- }
107
- if (assertion.type === "file_reference") {
108
- const value = String(assertion.value).toLowerCase();
109
- return haystack.includes(value) || haystack.includes(path.basename(value));
178
+ switch (assertion.type) {
179
+ case "contains": return semanticsVersion === 2 ? containsV2(String(assertion.value), transcript) : haystack.includes(String(assertion.value).toLowerCase());
180
+ case "contains_any": return (Array.isArray(assertion.value) ? assertion.value : [assertion.value]).some((value) => semanticsVersion === 2 ? containsV2(String(value), transcript) : haystack.includes(String(value).toLowerCase()));
181
+ case "not_contains": return !haystack.includes(String(assertion.value).toLowerCase());
182
+ case "regex": try {
183
+ return new RegExp(String(assertion.value), "i").test(transcript);
184
+ } catch {
185
+ return false;
186
+ }
187
+ case "file_reference": {
188
+ const value = String(assertion.value).toLowerCase();
189
+ return haystack.includes(value) || haystack.includes(basename(value));
190
+ }
191
+ default: return false;
110
192
  }
111
- return haystack.includes(String(assertion.value).toLowerCase());
112
193
  }
194
+ //#endregion
195
+ //#region src/services/EvalsVerifier.ts
113
196
  function answerPath(runDir, manifest, skill, caseId, arm) {
114
197
  const categoryParts = manifest.category === "all" ? [skill.category] : [];
115
198
  const filename = arm ? `${caseId}.${arm}.md` : `${caseId}.md`;
@@ -123,6 +206,75 @@ function readInputs(runDir) {
123
206
  const inputsPath = path.join(runDir, "inputs.json");
124
207
  return fs_extra.default.existsSync(inputsPath) ? fs_extra.default.readJSONSync(inputsPath) : null;
125
208
  }
209
+ function isStringRecord(value) {
210
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
211
+ }
212
+ function isBase64(value) {
213
+ return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
214
+ }
215
+ function resourceFingerprintDiffs(manifest, inputs) {
216
+ if (manifest.schemaVersion !== 2 || manifest.resourceFingerprints === void 0) return [];
217
+ if (typeof manifest.resourceFingerprints !== "object" || manifest.resourceFingerprints === null || Array.isArray(manifest.resourceFingerprints)) return ["Invalid immutable resource fingerprint manifest"];
218
+ const diffs = [];
219
+ for (const key of manifest.skills.map((skill) => `${skill.category}/${skill.skillName}`).sort()) if (!Object.hasOwn(manifest.resourceFingerprints, key)) diffs.push(`Missing immutable resource provenance for ${key}`);
220
+ for (const key of Object.keys(manifest.resourceFingerprints).sort()) {
221
+ const fingerprint = manifest.resourceFingerprints[key];
222
+ if (!fingerprint || fingerprint.version !== 1 || !isStringRecord(fingerprint.resources)) {
223
+ diffs.push(`Invalid immutable resource fingerprint for ${key}`);
224
+ continue;
225
+ }
226
+ const resources = inputs?.sources[key]?.resources;
227
+ if (!isStringRecord(resources)) {
228
+ diffs.push(`Missing immutable resource provenance for ${key}`);
229
+ continue;
230
+ }
231
+ const expectedPaths = Object.keys(fingerprint.resources).sort();
232
+ const actualPaths = Object.keys(resources).sort();
233
+ for (const resourcePath of expectedPaths) {
234
+ const content = resources[resourcePath];
235
+ if (content === void 0) {
236
+ diffs.push(`Missing immutable resource provenance for ${key}/${resourcePath}`);
237
+ continue;
238
+ }
239
+ if (!isBase64(content) || (0, node_crypto.createHash)("sha256").update(Buffer.from(content, "base64")).digest("hex") !== fingerprint.resources[resourcePath]) diffs.push(`Immutable resource mismatch for ${key}/${resourcePath}`);
240
+ }
241
+ for (const resourcePath of actualPaths) if (!(resourcePath in fingerprint.resources)) diffs.push(`Immutable resource mismatch for ${key}/${resourcePath}`);
242
+ }
243
+ return diffs;
244
+ }
245
+ function rawSourceDiffs(manifest, inputs) {
246
+ if (manifest.schemaVersion !== 2 || manifest.inputProvenanceVersion !== 1) return [];
247
+ const diffs = [];
248
+ for (const skill of manifest.skills) {
249
+ const key = `${skill.category}/${skill.skillName}`;
250
+ const expected = manifest.sourceHashes?.[key];
251
+ const source = inputs?.sources[key];
252
+ if (!expected || !source) {
253
+ diffs.push(`Missing immutable raw source provenance for ${key}`);
254
+ continue;
255
+ }
256
+ const skillBase64 = source.skillMarkdownBase64;
257
+ if (skillBase64 === void 0 || !isBase64(skillBase64)) diffs.push(`Missing immutable raw skill provenance for ${key}`);
258
+ else {
259
+ const skillBytes = Buffer.from(skillBase64, "base64");
260
+ if ((0, node_crypto.createHash)("sha256").update(skillBytes).digest("hex") !== expected.skill) diffs.push(`Raw skill snapshot hash mismatch for ${key}`);
261
+ if (source.skillMarkdown !== skillBytes.toString("utf8")) diffs.push(`Raw skill snapshot mismatch for ${key}`);
262
+ }
263
+ const evalsBase64 = source.evalsBase64;
264
+ if (evalsBase64 === void 0 || !isBase64(evalsBase64)) {
265
+ diffs.push(`Missing immutable raw eval provenance for ${key}`);
266
+ continue;
267
+ }
268
+ const evalsBytes = Buffer.from(evalsBase64, "base64");
269
+ if ((0, node_crypto.createHash)("sha256").update(evalsBytes).digest("hex") !== expected.evals) diffs.push(`Raw eval snapshot hash mismatch for ${key}`);
270
+ try {
271
+ if (JSON.stringify(JSON.parse(evalsBytes.toString("utf8"))) !== JSON.stringify(source.evals)) diffs.push(`Raw eval snapshot mismatch for ${key}`);
272
+ } catch {
273
+ diffs.push(`Invalid immutable raw eval JSON for ${key}`);
274
+ }
275
+ }
276
+ return diffs;
277
+ }
126
278
  function evalDataFor(projectRoot, runDir, skill) {
127
279
  const inputs = readInputs(runDir);
128
280
  if (inputs) {
@@ -147,6 +299,7 @@ function summarizeSkill(projectRoot, runDir, manifest, skill) {
147
299
  const evalsData = evalDataFor(projectRoot, runDir, skill);
148
300
  const evalById = new Map((evalsData.evals ?? []).map((evaluation) => [String(evaluation.id), evaluation]));
149
301
  const pressureByIndex = evalsData.pressure_scenarios ?? [];
302
+ const semanticsVersion = manifest.schemaVersion === 2 ? manifest.provenance?.[`${skill.category}/${skill.skillName}`]?.assertionSemanticsVersion ?? manifest.assertionSemanticsVersion ?? 1 : 1;
150
303
  const baseline = [];
151
304
  const withSkill = [];
152
305
  const baselineAssertions = [];
@@ -170,7 +323,7 @@ function summarizeSkill(projectRoot, runDir, manifest, skill) {
170
323
  if (!(arm in currentCase.arms)) continue;
171
324
  const transcript = readAnswer(runDir, manifest, skill, currentCase, arm);
172
325
  if (transcript === null) throw new Error(`missing answer: ${skill.skillName}/${currentCase.id}.${arm}`);
173
- const checks = assertions.map((assertion) => checkAssertion(assertion, transcript));
326
+ const checks = assertions.map((assertion) => checkAssertion(assertion, transcript, semanticsVersion));
174
327
  const passed = checks.every(Boolean);
175
328
  (arm === "baseline" ? baseline : withSkill).push(passed);
176
329
  (arm === "baseline" ? baselineAssertions : withSkillAssertions).push({
@@ -220,11 +373,19 @@ function verifyEvalRun(projectRoot, runId) {
220
373
  reason: "run is missing manifest.json or results.json"
221
374
  };
222
375
  const manifest = fs_extra.default.readJSONSync(manifestPath);
223
- if (manifest.schemaVersion === 2 && readInputs(runDir) === null) return {
376
+ const inputs = readInputs(runDir);
377
+ if (manifest.schemaVersion === 2 && inputs === null) return {
224
378
  runId,
225
379
  ok: false,
226
380
  reason: "v2 run is missing immutable inputs.json"
227
381
  };
382
+ const provenanceDiffs = [...resourceFingerprintDiffs(manifest, inputs), ...rawSourceDiffs(manifest, inputs)];
383
+ if (provenanceDiffs.length > 0) return {
384
+ runId,
385
+ ok: false,
386
+ reason: "immutable source provenance differs from manifest",
387
+ diffs: provenanceDiffs
388
+ };
228
389
  const committed = fs_extra.default.readJSONSync(resultsPath);
229
390
  const diffs = [];
230
391
  for (const skill of manifest.skills) {
@@ -450,7 +611,7 @@ var FeedbackCommand = class {
450
611
  when: !loadedSkills
451
612
  },
452
613
  {
453
- type: "list",
614
+ type: "rawlist",
454
615
  name: "rootCause",
455
616
  message: "Root cause of the violation (optional)",
456
617
  when: !rootCause,
@@ -934,8 +1095,7 @@ var COMMON_SKILL_EXCLUDES = {
934
1095
  mobile: [
935
1096
  "common-accessibility",
936
1097
  "common-api-design",
937
- "common-observability",
938
- "common-system-design"
1098
+ "common-observability"
939
1099
  ]
940
1100
  };
941
1101
  /**
@@ -954,6 +1114,8 @@ var DEFAULT_WORKFLOWS = [
954
1114
  "brainstorm-feature",
955
1115
  "code-review",
956
1116
  "codebase-review",
1117
+ "system-design-session",
1118
+ "review-system-design",
957
1119
  "design-solution",
958
1120
  "deploy-release",
959
1121
  "plan-feature",
@@ -969,9 +1131,11 @@ var DEFAULT_WORKFLOWS = [
969
1131
  "implement-feature",
970
1132
  "verify-work",
971
1133
  "verify-bug",
1134
+ "test-loop",
972
1135
  "security-test",
973
1136
  "uat-signoff",
974
- "incident-hotfix"
1137
+ "incident-hotfix",
1138
+ "monitor-respond"
975
1139
  ];
976
1140
  /**
977
1141
  * Workflows that must never be synced to a consumer project, regardless of
@@ -1288,7 +1452,8 @@ var SkillConfigSchema = zod.z.object({
1288
1452
  })),
1289
1453
  custom_overrides: zod.z.array(zod.z.string()).optional(),
1290
1454
  workflows: zod.z.union([zod.z.boolean(), zod.z.array(zod.z.string())]).optional(),
1291
- mcp: McpConfigSchema.optional()
1455
+ mcp: McpConfigSchema.optional(),
1456
+ telemetry: zod.z.boolean().optional()
1292
1457
  });
1293
1458
  var DEFAULT_SDLC_SUPPORT_CATEGORIES = ["quality-engineering"];
1294
1459
  var PRIMARY_CONFIG_FILE = ".skillsrc";
@@ -1312,7 +1477,7 @@ var ConfigService = class {
1312
1477
  return null;
1313
1478
  }
1314
1479
  dumpConfig(config) {
1315
- return js_yaml.default.dump(config, {
1480
+ return js_yaml.dump(config, {
1316
1481
  noRefs: true,
1317
1482
  lineWidth: -1
1318
1483
  });
@@ -1357,7 +1522,7 @@ var ConfigService = class {
1357
1522
  agents.push(match[1].trim());
1358
1523
  }
1359
1524
  config.agents = agents;
1360
- const sectionNames = new Set([
1525
+ const sectionNames = /* @__PURE__ */ new Set([
1361
1526
  "custom_overrides",
1362
1527
  "workflows",
1363
1528
  "mcp"
@@ -1487,7 +1652,7 @@ var ConfigService = class {
1487
1652
  let rawConfig;
1488
1653
  let migratedFromText = false;
1489
1654
  try {
1490
- rawConfig = js_yaml.default.load(content);
1655
+ rawConfig = js_yaml.load(content);
1491
1656
  } catch (parseError) {
1492
1657
  rawConfig = this.migrateLegacyTextConfig(content);
1493
1658
  migratedFromText = rawConfig !== null;
@@ -1500,7 +1665,7 @@ var ConfigService = class {
1500
1665
  if (shouldRenameLegacyYamlFile) await fs_extra.default.remove(configPath);
1501
1666
  return parsed.data;
1502
1667
  } catch (error) {
1503
- throw new Error(`Failed to load config: ${error}`);
1668
+ throw new Error(`Failed to load config: ${error}`, { cause: error });
1504
1669
  }
1505
1670
  }
1506
1671
  /**
@@ -1647,16 +1812,35 @@ var ConfigService = class {
1647
1812
  //#endregion
1648
1813
  //#region src/services/HookService.ts
1649
1814
  /**
1815
+ * Version marker embedded as the hook script's first line after the shebang.
1816
+ * On install, an existing script carrying an *older* marker is understood to
1817
+ * be our own previous auto-generated version (not a user customization) and
1818
+ * is refreshed; a script with no marker at all predates this feature and is
1819
+ * left untouched, matching this hook's original "create once, preserve if
1820
+ * customized" contract. A script that keeps the marker line but edits the
1821
+ * body around it will still be treated as ours and overwritten on the next
1822
+ * version bump — that residual case can't be told apart from a stale file
1823
+ * without a full diff/backup mechanism, which is out of scope here.
1824
+ */
1825
+ var HOOK_SCRIPT_VERSION = package_default.version;
1826
+ var HOOK_VERSION_MARKER_RE = /^\/\/ ags-hook-version: (\S+)$/m;
1827
+ /**
1650
1828
  * Universal PreToolUse hook script (Node.js / JavaScript).
1651
1829
  * Reminds AI agents to call load_skills_for_files() before any code edit.
1652
1830
  * Guaranteed to execute on any machine running agent-skills-standard.
1653
1831
  * Always exits 0 to never block work.
1654
1832
  */
1655
1833
  var UNIVERSAL_SKILL_LOADER_JS = `#!/usr/bin/env node
1834
+ // ags-hook-version: ${HOOK_SCRIPT_VERSION}
1656
1835
  /**
1657
1836
  * PreToolUse hook: remind AI agent to call load_skills_for_files() before any code edit.
1658
1837
  * Installed by agent-skills-standard (ags sync). Remove via: ags hooks uninstall
1659
- * Guaranteed to execute on any system with Node.js. Always exits 0 to never block work.
1838
+ * Guaranteed to execute on any system with Node.js. Always exits 0 to never block work,
1839
+ * UNLESS AGS_HOOK_ENFORCE=1 is set in the environment (opt-in via
1840
+ * \`ags hooks install --enforce\`) AND the target file matches the small,
1841
+ * fixed, skill-agnostic deny-list below — see installStandardHookConfig's
1842
+ * doc comment in HookService.ts for why this can't be a per-skill
1843
+ * permissions.filesystem.deny check.
1660
1844
  */
1661
1845
  const fs = require('fs');
1662
1846
  const path = require('path');
@@ -1693,6 +1877,19 @@ function shouldSkip(filePath) {
1693
1877
  }
1694
1878
  }
1695
1879
 
1880
+ const ENFORCE = process.env.AGS_HOOK_ENFORCE === '1';
1881
+ const DENY_BASENAMES = new Set(['SOUL.md', 'MEMORY.md']);
1882
+ const DENY_PATTERNS = [
1883
+ /(^|[\\\\/])\\.env(\\.[^\\\\/]*)?$/i,
1884
+ /(^|[\\\\/])\\.ssh([\\\\/]|$)/i,
1885
+ /credentials[^\\\\/]*\\.(json|ya?ml)$/i,
1886
+ ];
1887
+
1888
+ function isDenied(filePath) {
1889
+ if (DENY_BASENAMES.has(path.basename(filePath))) return true;
1890
+ return DENY_PATTERNS.some(re => re.test(filePath));
1891
+ }
1892
+
1696
1893
  let input = '';
1697
1894
  process.stdin.setEncoding('utf8');
1698
1895
  process.stdin.on('data', chunk => { input += chunk; });
@@ -1704,6 +1901,15 @@ process.stdin.on('end', () => {
1704
1901
  const filePath = data.tool_input?.file_path || '';
1705
1902
  if (!filePath || shouldSkip(filePath)) process.exit(0);
1706
1903
 
1904
+ if (ENFORCE && isDenied(filePath)) {
1905
+ console.error(
1906
+ '[AGS BLOCKED] Refusing to edit ' + path.basename(filePath) + ': ' +
1907
+ 'matches the default identity/secret deny-list (SOUL.md, MEMORY.md, .env*, .ssh/, credentials*.json|yaml).\\n' +
1908
+ 'If this edit is intentional, re-run \`ags hooks install\` (without --enforce) to return to advisory-only mode.'
1909
+ );
1910
+ process.exit(2);
1911
+ }
1912
+
1707
1913
  const fileName = path.basename(filePath);
1708
1914
  console.log(
1709
1915
  '[SKILL TRIGGER] Editing: ' + fileName + '\\n' +
@@ -1771,15 +1977,18 @@ var HookService = class {
1771
1977
  continue;
1772
1978
  }
1773
1979
  const def = getAgentDefinition(agent);
1774
- if (def.hookScriptPath && def.hookConfigPath) await this.installStandardHookConfig({
1775
- rootDir: opts.rootDir,
1776
- agent,
1777
- scriptRelPath: def.hookScriptPath,
1778
- configRelPath: def.hookConfigPath,
1779
- hookCmd: agent === Agent.Claude ? "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/preedit-skill-loader.js\"" : `node "${def.hookScriptPath}"`,
1780
- report
1781
- });
1782
- else report.unsupported.push(agent);
1980
+ if (def.hookScriptPath && def.hookConfigPath) {
1981
+ const enforcePrefix = opts.enforce && agent === Agent.Claude ? "AGS_HOOK_ENFORCE=1 " : "";
1982
+ await this.installStandardHookConfig({
1983
+ rootDir: opts.rootDir,
1984
+ agent,
1985
+ scriptRelPath: def.hookScriptPath,
1986
+ configRelPath: def.hookConfigPath,
1987
+ hookCmd: agent === Agent.Claude ? `${enforcePrefix}node "$CLAUDE_PROJECT_DIR/.claude/hooks/preedit-skill-loader.js"` : `node "${def.hookScriptPath}"`,
1988
+ report,
1989
+ promptPermission: opts.promptPermission
1990
+ });
1991
+ } else report.unsupported.push(agent);
1783
1992
  }
1784
1993
  return report;
1785
1994
  }
@@ -1838,24 +2047,37 @@ var HookService = class {
1838
2047
  file: opts.scriptRelPath,
1839
2048
  action: "added"
1840
2049
  });
1841
- } else opts.report.writes.push({
1842
- agent: opts.agent,
1843
- file: opts.scriptRelPath,
1844
- action: "skipped-existing"
1845
- });
2050
+ } else {
2051
+ const installedVersion = (await fs_extra.default.readFile(scriptAbs, "utf8")).match(HOOK_VERSION_MARKER_RE)?.[1];
2052
+ if (installedVersion && installedVersion !== HOOK_SCRIPT_VERSION) {
2053
+ await fs_extra.default.writeFile(scriptAbs, UNIVERSAL_SKILL_LOADER_JS);
2054
+ opts.report.writes.push({
2055
+ agent: opts.agent,
2056
+ file: opts.scriptRelPath,
2057
+ action: "updated"
2058
+ });
2059
+ } else opts.report.writes.push({
2060
+ agent: opts.agent,
2061
+ file: opts.scriptRelPath,
2062
+ action: "skipped-existing"
2063
+ });
2064
+ }
1846
2065
  const configAbs = path.default.join(opts.rootDir, opts.configRelPath);
1847
2066
  const configExists = await fs_extra.default.pathExists(configAbs);
1848
2067
  const existing = await this.readJson(configAbs);
1849
2068
  let changed = false;
1850
2069
  const permissions = this.ensureObject(existing, "permissions");
1851
2070
  const allow = this.ensureArray(permissions, "allow");
1852
- if (!allow.includes(CLAUDE_MCP_PERMISSION)) {
1853
- permissions["allow"] = [CLAUDE_MCP_PERMISSION, ...allow];
1854
- changed = true;
2071
+ if (!allow.includes("mcp__agent-skills-standard__*")) {
2072
+ if (opts.promptPermission ? await opts.promptPermission(opts.agent) : true) {
2073
+ permissions["allow"] = [CLAUDE_MCP_PERMISSION, ...allow];
2074
+ changed = true;
2075
+ }
1855
2076
  }
1856
2077
  const hooks = this.ensureObject(existing, "hooks");
1857
2078
  const preToolUse = this.ensureArray(hooks, "PreToolUse");
1858
- if (!this.claudeHookEntryExists(preToolUse)) {
2079
+ const existingEntry = preToolUse.find((entry) => this.claudeHookEntryMatches(entry));
2080
+ if (!existingEntry) {
1859
2081
  preToolUse.unshift({
1860
2082
  matcher: "Edit|Write|MultiEdit|NotebookEdit",
1861
2083
  hooks: [{
@@ -1865,6 +2087,12 @@ var HookService = class {
1865
2087
  }]
1866
2088
  });
1867
2089
  changed = true;
2090
+ } else {
2091
+ const matchingHook = (existingEntry["hooks"] ?? []).find((h) => h["command"]?.includes("preedit-skill-loader"));
2092
+ if (matchingHook && matchingHook["command"] !== opts.hookCmd) {
2093
+ matchingHook["command"] = opts.hookCmd;
2094
+ changed = true;
2095
+ }
1868
2096
  }
1869
2097
  if (changed) {
1870
2098
  await this.writeAtomic(configAbs, existing);
@@ -1887,7 +2115,7 @@ var HookService = class {
1887
2115
  let changed = false;
1888
2116
  const permissions = this.ensureObject(data, "permissions");
1889
2117
  const allow = this.ensureArray(permissions, "allow");
1890
- if (allow.includes(CLAUDE_MCP_PERMISSION)) {
2118
+ if (allow.includes("mcp__agent-skills-standard__*")) {
1891
2119
  permissions["allow"] = allow.filter((p) => p !== CLAUDE_MCP_PERMISSION);
1892
2120
  changed = true;
1893
2121
  }
@@ -1992,7 +2220,7 @@ var HooksCommand = class {
1992
2220
  this.configService = configService ?? new ConfigService();
1993
2221
  this.hookService = hookService ?? new HookService();
1994
2222
  }
1995
- async run(action) {
2223
+ async run(action, options = {}) {
1996
2224
  const config = await this.configService.loadConfig();
1997
2225
  if (!config) {
1998
2226
  console.log(picocolors.default.red("❌ .skillsrc not found. Run `agent-skills-standard init` first."));
@@ -2002,7 +2230,7 @@ var HooksCommand = class {
2002
2230
  const agents = config.agents ?? [];
2003
2231
  switch (action) {
2004
2232
  case "status": return this.actionStatus(cwd, agents);
2005
- case "install": return this.actionInstall(cwd, agents);
2233
+ case "install": return this.actionInstall(cwd, agents, options.enforce ?? false);
2006
2234
  case "uninstall": return this.actionUninstall(cwd, agents);
2007
2235
  default: console.log(picocolors.default.red(`Unknown action: ${action}`), "\nValid actions: status, install, uninstall");
2008
2236
  }
@@ -2023,11 +2251,13 @@ var HooksCommand = class {
2023
2251
  for (const f of row.files) console.log(` ${picocolors.default.gray(f)}`);
2024
2252
  }
2025
2253
  }
2026
- async actionInstall(cwd, agents) {
2254
+ async actionInstall(cwd, agents, enforce) {
2027
2255
  console.log(picocolors.default.cyan("\n🪝 Installing skill-loader hooks..."));
2256
+ if (enforce) console.log(picocolors.default.yellow(" --enforce: Claude will BLOCK edits to SOUL.md, MEMORY.md, .env*, .ssh/, and credentials*.json|yaml."));
2028
2257
  const report = await this.hookService.install({
2029
2258
  rootDir: cwd,
2030
- agents
2259
+ agents,
2260
+ enforce
2031
2261
  });
2032
2262
  this.printReport(report);
2033
2263
  }
@@ -2325,16 +2555,29 @@ var InitService = class {
2325
2555
  #
2326
2556
  `;
2327
2557
  const configPath = path.default.join(cwd, ".skillsrc");
2328
- await fs_extra.default.outputFile(configPath, commentHeader + js_yaml.default.dump(config));
2558
+ await fs_extra.default.outputFile(configPath, commentHeader + js_yaml.dump(config));
2329
2559
  }
2330
2560
  };
2331
2561
  //#endregion
2332
2562
  //#region src/services/GithubService.ts
2563
+ /** A downloaded file failed its integrity or size check. */
2564
+ var IntegrityError = class extends Error {
2565
+ constructor(message) {
2566
+ super(message);
2567
+ this.name = "IntegrityError";
2568
+ }
2569
+ };
2570
+ /** Computes the same blob SHA-1 git itself would (`git hash-object`), for comparison against a tree API `sha`. */
2571
+ function gitBlobSha1(content) {
2572
+ const header = Buffer.from(`blob ${content.byteLength}\0`, "utf8");
2573
+ return (0, node_crypto.createHash)("sha1").update(Buffer.concat([header, content])).digest("hex");
2574
+ }
2333
2575
  /**
2334
2576
  * Service for interacting with the GitHub API and fetching raw file content.
2335
2577
  * Handles repository tree discovery, file downloads, and URL parsing.
2336
2578
  */
2337
2579
  var GithubService = class {
2580
+ token;
2338
2581
  baseUrl = "https://api.github.com";
2339
2582
  rawBaseUrl = "https://raw.githubusercontent.com";
2340
2583
  constructor(token) {
@@ -2367,25 +2610,35 @@ var GithubService = class {
2367
2610
  }
2368
2611
  }
2369
2612
  /**
2370
- * Fetches the raw content of a file from GitHub using raw.githubusercontent.com.
2371
- * @param owner Repository owner
2372
- * @param repo Repository name
2373
- * @param ref Git reference
2374
- * @param path Path to the file
2375
- * @returns File content as string or null if not found
2613
+ * Fetches raw file bytes from GitHub and validates their Git blob identity.
2614
+ * Text callers should use getRawFile; package callers use this byte-preserving path.
2376
2615
  */
2377
- async getRawFile(owner, repo, ref, path) {
2616
+ async getRawFileBytes(owner, repo, ref, path, options) {
2378
2617
  const url = `${this.rawBaseUrl}/${owner}/${repo}/${ref}/${path}`;
2379
2618
  try {
2380
2619
  const res = await (0, cross_fetch.default)(url);
2381
2620
  if (!res.ok) return null;
2382
- return await res.text();
2621
+ const content = Buffer.from(await res.arrayBuffer());
2622
+ const maxBytes = options?.maxBytes ?? 1048576;
2623
+ if (content.byteLength > maxBytes) throw new IntegrityError(`${path}: ${content.byteLength} bytes exceeds the ${maxBytes}-byte limit`);
2624
+ if (options?.expectedSha) {
2625
+ const actualSha = gitBlobSha1(content);
2626
+ if (actualSha !== options.expectedSha) throw new IntegrityError(`${path}: blob sha mismatch (expected ${options.expectedSha}, got ${actualSha}) — download may be corrupted or tampered with`);
2627
+ }
2628
+ return content;
2383
2629
  } catch (error) {
2630
+ if (error instanceof IntegrityError) throw error;
2384
2631
  console.error(picocolors.default.red(`Failed to fetch file ${path}: ${error}`));
2385
2632
  return null;
2386
2633
  }
2387
2634
  }
2388
2635
  /**
2636
+ * Fetches raw file content as UTF-8 for established text-only callers.
2637
+ */
2638
+ async getRawFile(owner, repo, ref, path, options) {
2639
+ return (await this.getRawFileBytes(owner, repo, ref, path, options))?.toString("utf8") ?? null;
2640
+ }
2641
+ /**
2389
2642
  * Retrieves the latest release tag name for a repository.
2390
2643
  * @param owner Repository owner
2391
2644
  * @param repo Repository name
@@ -2417,34 +2670,76 @@ var GithubService = class {
2417
2670
  }
2418
2671
  }
2419
2672
  /**
2420
- * Downloads multiple files concurrently with a limit.
2673
+ * Downloads multiple text files concurrently with a limit. Existing text
2674
+ * consumers retain UTF-8 strings; package sync uses the byte variant below.
2421
2675
  */
2422
2676
  async downloadFilesConcurrent(tasks, concurrency = 10) {
2423
- const results = [];
2424
- const pool = [...tasks];
2425
- const executing = [];
2677
+ return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, { expectedSha: task.sha }));
2678
+ }
2679
+ /**
2680
+ * Downloads multiple files without decoding their response bytes.
2681
+ */
2682
+ async downloadFilesConcurrentBytes(tasks, concurrency = 10) {
2683
+ return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, { expectedSha: task.sha }));
2684
+ }
2685
+ async downloadFilesConcurrentWith(tasks, concurrency, download) {
2686
+ const outcomes = Array(tasks.length);
2687
+ let nextIndex = 0;
2426
2688
  const worker = async () => {
2427
- while (pool.length > 0) {
2428
- const task = pool.shift();
2429
- if (!task) break;
2430
- const content = await this.getRawFile(task.owner, task.repo, task.ref, task.path);
2431
- if (content !== null) results.push({
2432
- path: task.path,
2433
- content
2434
- });
2689
+ while (nextIndex < tasks.length) {
2690
+ const index = nextIndex++;
2691
+ const task = tasks[index];
2692
+ if (!task) continue;
2693
+ try {
2694
+ const content = await download(task);
2695
+ outcomes[index] = content === null ? {
2696
+ kind: "failed",
2697
+ path: task.path,
2698
+ reason: "not found"
2699
+ } : {
2700
+ kind: "ok",
2701
+ path: task.path,
2702
+ content
2703
+ };
2704
+ } catch (error) {
2705
+ outcomes[index] = {
2706
+ kind: "failed",
2707
+ path: task.path,
2708
+ reason: error instanceof Error ? error.message : String(error)
2709
+ };
2710
+ }
2435
2711
  }
2436
2712
  };
2437
- for (let i = 0; i < Math.min(concurrency, tasks.length); i++) executing.push(worker());
2438
- await Promise.all(executing);
2439
- return results;
2713
+ await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
2714
+ const ok = [];
2715
+ const failed = [];
2716
+ for (const outcome of outcomes) {
2717
+ if (!outcome) continue;
2718
+ if (outcome.kind === "ok") ok.push({
2719
+ path: outcome.path,
2720
+ content: outcome.content
2721
+ });
2722
+ else failed.push({
2723
+ path: outcome.path,
2724
+ reason: outcome.reason
2725
+ });
2726
+ }
2727
+ return {
2728
+ ok,
2729
+ failed
2730
+ };
2440
2731
  }
2441
2732
  /**
2442
- * Parses a GitHub URL into owner and repository name.
2733
+ * Parses a GitHub URL into owner and repository name. Anchored to the
2734
+ * start of the string so a URL like `https://evil.com/?x=github.com/a/b`
2735
+ * — where "github.com/owner/repo" only appears as a substring, not as the
2736
+ * actual host — is correctly rejected instead of silently resolving to
2737
+ * `a/b` on the real github.com API.
2443
2738
  * @param url The GitHub URL to parse
2444
2739
  * @returns Object containing owner and repo, or null if invalid
2445
2740
  */
2446
2741
  static parseGitHubUrl(url) {
2447
- const m = url.match(/github\.com\/([^/]+)\/([^/]+)/i);
2742
+ const m = url.match(/^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s]+)/i);
2448
2743
  if (!m) return null;
2449
2744
  return {
2450
2745
  owner: m[1],
@@ -2563,7 +2858,7 @@ var InitCommand = class {
2563
2858
  const { frameworkChoices, agentChoices, defaultFramework } = this.initService.getPromptChoices(context, categories);
2564
2859
  const answers = await inquirer.default.prompt([
2565
2860
  {
2566
- type: "list",
2861
+ type: "rawlist",
2567
2862
  name: "framework",
2568
2863
  message: "Select Framework:",
2569
2864
  choices: frameworkChoices,
@@ -2589,7 +2884,7 @@ var InitCommand = class {
2589
2884
  message: "Enable MCP server integration?",
2590
2885
  default: true
2591
2886
  }, {
2592
- type: "list",
2887
+ type: "rawlist",
2593
2888
  name: "mcpScope",
2594
2889
  message: "Where should sync write MCP configs?",
2595
2890
  when: (a) => a.mcpEnabled === true,
@@ -2717,7 +3012,7 @@ var ListSkillsCommand = class {
2717
3012
  value: f.id
2718
3013
  }));
2719
3014
  framework = (await inquirer.default.prompt([{
2720
- type: "list",
3015
+ type: "rawlist",
2721
3016
  name: "framework",
2722
3017
  message: "Select framework to list available skills for:",
2723
3018
  choices,
@@ -2850,11 +3145,17 @@ var getTargets = (home = os.default.homedir()) => {
2850
3145
  * a user-home file is about to be modified.
2851
3146
  */
2852
3147
  var McpConfigService = class {
2853
- /** Returns the standard MCP server entry our CLI proposes. */
3148
+ /**
3149
+ * Returns the standard MCP server entry our CLI proposes. Defaults to the
3150
+ * version this CLI was released with rather than an unversioned `npx -y
3151
+ * <package>`, which would otherwise pull whatever is latest on npm the
3152
+ * moment the agent starts — an unpinned update-drift surface (AST07).
3153
+ * Pass an explicit `version` (from `mcp.version` in .skillsrc) to override.
3154
+ */
2854
3155
  buildEntry(version) {
2855
3156
  return {
2856
3157
  command: "npx",
2857
- args: ["-y", version ? `${PACKAGE}@${version}` : PACKAGE]
3158
+ args: ["-y", `${PACKAGE}@${version || "0.6.0"}`]
2858
3159
  };
2859
3160
  }
2860
3161
  /** For testing: allows overriding the home directory. */
@@ -3402,10 +3703,7 @@ var AgentBridgeService = class {
3402
3703
  case "cursor":
3403
3704
  content += `---\ndescription: ${commonDescription}\nglobs: ['**/*']\nalwaysApply: true\n---\n\n`;
3404
3705
  break;
3405
- case "copilot":
3406
- content += `---\ndescription: ${commonDescription}\napplyTo: '**/*'\n---\n\n`;
3407
- break;
3408
- case "none": break;
3706
+ case "copilot": content += `---\ndescription: ${commonDescription}\napplyTo: '**/*'\n---\n\n`;
3409
3707
  }
3410
3708
  if (agentId === Agent.Claude) {
3411
3709
  const claudeProtocol = [
@@ -3543,6 +3841,7 @@ var IndexGeneratorService = class {};
3543
3841
  * Centralized patterns for detecting and sanitizing prompt injection.
3544
3842
  * Shared between the CLI and CI scanning scripts.
3545
3843
  */
3844
+ /** Applied to the SKILL.md frontmatter `description` field. Always error-level. */
3546
3845
  var INJECTION_PATTERNS = [
3547
3846
  /ignore\s+(?:all\s+|previous\s+|prior\s+)*(?:instructions?|rules?|guidance)/gi,
3548
3847
  /you\s+(?:must|should|shall|will)\s+(?:now|immediately)\b/gi,
@@ -3550,6 +3849,23 @@ var INJECTION_PATTERNS = [
3550
3849
  /(?:^|\n)\s*-{3,}\s*(?:\n|$)/g,
3551
3850
  /<(?:script|iframe|style)[^>]*\s*>[\s\S]*?<\/(?:script|iframe|style)[^>]*\s*>/gi
3552
3851
  ];
3852
+ new RegExp(`[${[
3853
+ 8203,
3854
+ 8204,
3855
+ 8205,
3856
+ 8206,
3857
+ 8207,
3858
+ 8234,
3859
+ 8235,
3860
+ 8236,
3861
+ 8237,
3862
+ 8238,
3863
+ 8294,
3864
+ 8295,
3865
+ 8296,
3866
+ 8297,
3867
+ 65279
3868
+ ].map((cp) => String.fromCodePoint(cp)).join("")}]`, "g");
3553
3869
  //#endregion
3554
3870
  //#region src/services/MetadataReader.ts
3555
3871
  /**
@@ -3629,7 +3945,7 @@ var MetadataReader = class {
3629
3945
  try {
3630
3946
  const frontmatterMatch = (await fs_extra.default.readFile(skillPath, "utf8")).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
3631
3947
  if (!frontmatterMatch) return null;
3632
- const fm = js_yaml.default.load(frontmatterMatch[1]);
3948
+ const fm = js_yaml.load(frontmatterMatch[1]);
3633
3949
  const priorityMatch = frontmatterMatch[2].match(/## \*\*Priority:\s*([^*]+)\*\*/);
3634
3950
  const priority = priorityMatch ? priorityMatch[1].trim() : "P1";
3635
3951
  const rawTriggers = (fm?.metadata || {})?.triggers || {};
@@ -3858,6 +4174,14 @@ var IndexGeneratorServiceImpl = class extends IndexGeneratorService {
3858
4174
  coveredCategories.add("quality-engineering");
3859
4175
  routerRows.push("| QE workflow | `<SKILLS>/quality-engineering/_INDEX.md` |");
3860
4176
  }
4177
+ if (availableCategories.includes("system-design")) {
4178
+ coveredCategories.add("system-design");
4179
+ routerRows.push("| Architecture or scale request | `<SKILLS>/system-design/_INDEX.md` |");
4180
+ }
4181
+ if (availableCategories.includes("cybersecurity")) {
4182
+ coveredCategories.add("cybersecurity");
4183
+ routerRows.push("| Cybersecurity investigation or authorized exercise | `<SKILLS>/cybersecurity/_INDEX.md` |");
4184
+ }
3861
4185
  return [
3862
4186
  "## Agent Skills Index",
3863
4187
  "",
@@ -3915,9 +4239,24 @@ var IndexGeneratorServiceImpl = class extends IndexGeneratorService {
3915
4239
  //#endregion
3916
4240
  //#region src/services/SkillSyncService.ts
3917
4241
  /**
4242
+ * A selected registry category or package could not be assembled completely.
4243
+ * Callers must not write installations or lockfiles from this partial result.
4244
+ */
4245
+ var SkillAssemblyError = class extends Error {
4246
+ failures;
4247
+ constructor(failures) {
4248
+ super(`Failed to assemble selected skills: ${failures.join(", ")}`);
4249
+ this.failures = failures;
4250
+ this.name = "SkillAssemblyError";
4251
+ }
4252
+ };
4253
+ /**
3918
4254
  * Service responsible for synchronizing agent skills from a remote registry.
3919
4255
  */
3920
4256
  var SkillSyncService = class {
4257
+ githubService;
4258
+ failedPackagesByCategory = /* @__PURE__ */ new Map();
4259
+ failedCategories = /* @__PURE__ */ new Set();
3921
4260
  constructor(githubService) {
3922
4261
  this.githubService = githubService;
3923
4262
  }
@@ -3925,11 +4264,13 @@ var SkillSyncService = class {
3925
4264
  * Assembles skills from the remote registry based on provided categories and configuration.
3926
4265
  */
3927
4266
  async assembleSkills(categories, config) {
4267
+ this.failedPackagesByCategory.clear();
4268
+ this.failedCategories.clear();
3928
4269
  const collected = [];
3929
4270
  const githubMatch = GithubService.parseGitHubUrl(config.registry);
3930
4271
  if (!githubMatch) {
3931
4272
  console.log(picocolors.default.red("Error: Only GitHub registries supported."));
3932
- return [];
4273
+ throw new SkillAssemblyError(["registry is not a GitHub repository"]);
3933
4274
  }
3934
4275
  const { owner, repo } = githubMatch;
3935
4276
  for (const category of categories) {
@@ -3938,15 +4279,23 @@ var SkillSyncService = class {
3938
4279
  console.log(picocolors.default.gray(` - Discovering ${category} (${ref})...`));
3939
4280
  const treeData = await this.githubService.getRepoTree(owner, repo, ref);
3940
4281
  if (!treeData) {
4282
+ this.failedCategories.add(category);
3941
4283
  console.log(picocolors.default.red(` ❌ Failed to fetch ${category}@${ref}.`));
3942
4284
  continue;
3943
4285
  }
3944
4286
  const foldersToSync = this.identifyFoldersToSync(category, catConfig, treeData.tree);
3945
4287
  for (const absOrRelSkill of foldersToSync) {
3946
4288
  const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree);
3947
- if (skill) collected.push(skill);
4289
+ if (skill) {
4290
+ collected.push(skill);
4291
+ continue;
4292
+ }
4293
+ const [sourceCategory, skillName] = absOrRelSkill.includes("/") ? absOrRelSkill.split("/") : [category, absOrRelSkill];
4294
+ if (sourceCategory && skillName) this.markPackageFailed(sourceCategory, skillName);
3948
4295
  }
3949
4296
  }
4297
+ const failures = [...this.failedCategories, ...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) => `${category}/${skill}`))];
4298
+ if (failures.length > 0) throw new SkillAssemblyError(failures);
3950
4299
  return collected;
3951
4300
  }
3952
4301
  /**
@@ -3955,75 +4304,89 @@ var SkillSyncService = class {
3955
4304
  async writeSkills(skills, config, agents) {
3956
4305
  const overrides = config.custom_overrides || [];
3957
4306
  const fetchedSkillsByCategory = {};
3958
- for (const skill of skills) {
3959
- if (!fetchedSkillsByCategory[skill.category]) fetchedSkillsByCategory[skill.category] = /* @__PURE__ */ new Set();
3960
- fetchedSkillsByCategory[skill.category].add(skill.skill);
3961
- }
4307
+ for (const skill of skills) (fetchedSkillsByCategory[skill.category] ??= /* @__PURE__ */ new Set()).add(skill.skill);
3962
4308
  for (const agentId of agents) {
3963
- const agentDef = SUPPORTED_AGENTS.find((a) => a.id === agentId);
3964
- if (!agentDef || !agentDef.path) continue;
4309
+ const agentDef = SUPPORTED_AGENTS.find((agent) => agent.id === agentId);
4310
+ if (!agentDef?.path) continue;
3965
4311
  const basePath = agentDef.path;
3966
4312
  await fs_extra.default.ensureDir(basePath);
3967
- if (config.prune !== false) for (const category of Object.keys(fetchedSkillsByCategory)) {
3968
- const categoryPath = path.default.join(basePath, category);
3969
- if (await fs_extra.default.pathExists(categoryPath)) {
3970
- const existingDirs = await fs_extra.default.readdir(categoryPath);
3971
- for (const dir of existingDirs) if (!fetchedSkillsByCategory[category].has(dir)) {
3972
- const fullPath = path.default.join(categoryPath, dir);
3973
- if (!this.isOverridden(fullPath, overrides)) await fs_extra.default.remove(fullPath);
3974
- }
3975
- }
3976
- }
3977
- for (const skill of skills) await this.writeSkillForAgent(agentId, skill, overrides, basePath);
4313
+ for (const skill of skills) if (!await this.writeSkillForAgent(agentId, skill, overrides, basePath)) this.markPackageFailed(skill.category, skill.skill);
4314
+ if (config.prune !== false) await this.pruneOrphanedSkills(basePath, fetchedSkillsByCategory, overrides);
3978
4315
  console.log(picocolors.default.gray(` - Updated ${basePath}/ (${agentDef.name})`));
3979
4316
  }
3980
4317
  }
3981
4318
  async writeSkillForAgent(agentId, skill, overrides, basePath) {
3982
4319
  const isKiro = agentId === Agent.Kiro;
3983
- const kiroFolder = `${skill.category}-${skill.skill}`;
3984
- const skillPath = isKiro ? path.default.join(basePath, kiroFolder) : path.default.join(basePath, skill.category, skill.skill);
3985
- await fs_extra.default.ensureDir(skillPath);
4320
+ const skillPath = isKiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
4321
+ if (!this.isPathSafe(skillPath, basePath)) {
4322
+ console.log(picocolors.default.red(` ❌ Security Error: Invalid skill path ${skill.category}/${skill.skill}`));
4323
+ throw new Error(`Invalid skill path ${skill.category}/${skill.skill}`);
4324
+ }
3986
4325
  for (const fileItem of skill.files) {
3987
4326
  const targetFilePath = path.default.join(skillPath, fileItem.name);
3988
- if (this.isOverridden(targetFilePath, overrides)) {
3989
- console.log(picocolors.default.yellow(` ⚠️ Skipping overridden: ${this.normalizePath(targetFilePath)}`));
3990
- continue;
3991
- }
3992
4327
  if (!this.isPathSafe(targetFilePath, skillPath)) {
3993
4328
  console.log(picocolors.default.red(` ❌ Security Error: Invalid path ${fileItem.name}`));
3994
- continue;
4329
+ throw new Error(`Invalid path ${fileItem.name}`);
3995
4330
  }
3996
- let content = fileItem.content;
3997
- if (isKiro && fileItem.name === "SKILL.md") content = this.transformSkillForKiro(content, skill.category);
3998
- await fs_extra.default.outputFile(targetFilePath, content);
4331
+ }
4332
+ const stagingPath = path.default.join(path.default.dirname(skillPath), `.${path.default.basename(skillPath)}.ags-staging`);
4333
+ if (await fs_extra.default.pathExists(stagingPath)) throw new Error(`Refusing to replace ${skill.category}/${skill.skill}: staging path already exists at ${stagingPath}`);
4334
+ await fs_extra.default.ensureDir(path.default.dirname(skillPath));
4335
+ try {
4336
+ if (this.hasOverridesInsideSkill(skillPath, overrides)) await this.copyOverrides(skillPath, stagingPath, overrides);
4337
+ for (const fileItem of skill.files) {
4338
+ const targetFilePath = path.default.join(skillPath, fileItem.name);
4339
+ if (this.isOverridden(targetFilePath, overrides)) {
4340
+ console.log(picocolors.default.yellow(` ⚠️ Skipping overridden: ${this.normalizePath(targetFilePath)}`));
4341
+ continue;
4342
+ }
4343
+ const stagedFilePath = path.default.join(stagingPath, fileItem.name);
4344
+ let content = fileItem.bytes ?? fileItem.content;
4345
+ if (isKiro && fileItem.name === "SKILL.md") content = this.transformSkillForKiro(fileItem.content, skill.category);
4346
+ await fs_extra.default.outputFile(stagedFilePath, content);
4347
+ }
4348
+ await this.replaceSkillDirectory(stagingPath, skillPath);
4349
+ return true;
4350
+ } catch (error) {
4351
+ await fs_extra.default.remove(stagingPath);
4352
+ throw error;
3999
4353
  }
4000
4354
  }
4001
4355
  async fetchSkill(owner, repo, ref, category, absOrRelSkill, tree) {
4002
4356
  const [sourceCat, skillName] = absOrRelSkill.includes("/") ? absOrRelSkill.split("/") : [category, absOrRelSkill];
4357
+ if (!sourceCat || !skillName) return null;
4003
4358
  const prefix = `skills/${sourceCat}/${skillName}/`;
4004
- const downloadTasks = tree.filter((f) => f.path.startsWith(prefix) && f.type === "blob").filter((f) => {
4005
- const rel = f.path.replace(prefix, "");
4006
- return rel === "SKILL.md" || /^(references|scripts|assets)\//.test(rel);
4007
- }).map((f) => ({
4359
+ const packageFiles = tree.filter((file) => file.type === "blob" && file.path.startsWith(prefix) && this.isPackageResource(file.path.slice(prefix.length)));
4360
+ const rootAttributionFiles = tree.filter((file) => file.type === "blob" && this.isRootAttributionFile(file.path) && !packageFiles.some((packageFile) => packageFile.path.slice(prefix.length).toLowerCase() === file.path.toLowerCase()));
4361
+ const downloadTasks = [...packageFiles, ...rootAttributionFiles].sort((left, right) => left.path.localeCompare(right.path)).map((file) => ({
4008
4362
  owner,
4009
4363
  repo,
4010
4364
  ref,
4011
- path: f.path
4365
+ path: file.path,
4366
+ sha: file.sha
4012
4367
  }));
4013
- const files = await this.githubService.downloadFilesConcurrent(downloadTasks);
4014
- if (files.length === 0) return null;
4368
+ const { ok: files, failed } = await this.githubService.downloadFilesConcurrentBytes(downloadTasks);
4369
+ for (const failure of failed) console.log(picocolors.default.red(` ❌ ${sourceCat}/${skillName}: ${failure.path} — ${failure.reason}`));
4370
+ if (!packageFiles.some((file) => file.path === `${prefix}SKILL.md`) || failed.length > 0 || files.length !== downloadTasks.length) return null;
4015
4371
  console.log(picocolors.default.gray(` + Fetched ${sourceCat}/${skillName} (${files.length} files)`));
4016
4372
  return {
4017
4373
  category: sourceCat,
4018
4374
  skill: skillName,
4019
- files: files.map((f) => ({
4020
- name: f.path.replace(prefix, ""),
4021
- content: f.content
4375
+ files: files.map((file) => ({
4376
+ name: file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path,
4377
+ content: file.content.toString("utf8"),
4378
+ bytes: file.content
4022
4379
  }))
4023
4380
  };
4024
4381
  }
4025
4382
  identifyFoldersToSync(category, catConfig, tree) {
4026
- const skillFolders = new Set(tree.filter((f) => f.path.startsWith(`skills/${category}/`)).map((f) => f.path.split("/")[2]).filter(Boolean));
4383
+ const hasSkillMd = tree.some((f) => f.type === "blob" && f.path.startsWith(`skills/${category}/`) && f.path.endsWith("/SKILL.md"));
4384
+ const skillFolders = new Set(tree.filter((f) => {
4385
+ if (!f.path.startsWith(`skills/${category}/`)) return false;
4386
+ if (hasSkillMd) return f.type === "blob" && f.path.endsWith("/SKILL.md");
4387
+ const rel = f.path.slice(`skills/${category}/`.length);
4388
+ return !rel.startsWith("references/") && rel !== "references" && rel !== "_INDEX.md";
4389
+ }).map((f) => f.path.split("/")[2]).filter(Boolean));
4027
4390
  const folders = Array.from(skillFolders).filter((folder) => {
4028
4391
  if (catConfig.include && !catConfig.include.includes(folder)) return false;
4029
4392
  if (catConfig.exclude && catConfig.exclude.includes(folder)) return false;
@@ -4035,21 +4398,128 @@ var SkillSyncService = class {
4035
4398
  expandAbsoluteInclude(absSkill, folders, tree) {
4036
4399
  const [targetCat, targetSkill] = absSkill.split("/");
4037
4400
  if (!targetCat || !targetSkill) return;
4038
- if (targetSkill === "*") new Set(tree.filter((f) => f.path.startsWith(`skills/${targetCat}/`)).map((f) => f.path.split("/")[2]).filter(Boolean)).forEach((s) => {
4039
- const fullPath = `${targetCat}/${s}`;
4040
- if (!folders.includes(fullPath)) folders.push(fullPath);
4041
- });
4042
- else if (!folders.includes(absSkill)) if (tree.some((f) => f.path.startsWith(`skills/${targetCat}/${targetSkill}/`))) folders.push(absSkill);
4043
- else console.log(picocolors.default.yellow(` ⚠️ Absolute include ${absSkill} not found in repository.`));
4401
+ if (targetSkill === "*") {
4402
+ const hasTargetSkillMd = tree.some((f) => f.type === "blob" && f.path.startsWith(`skills/${targetCat}/`) && f.path.endsWith("/SKILL.md"));
4403
+ new Set(tree.filter((f) => {
4404
+ if (!f.path.startsWith(`skills/${targetCat}/`)) return false;
4405
+ if (hasTargetSkillMd) return f.type === "blob" && f.path.endsWith("/SKILL.md");
4406
+ const rel = f.path.slice(`skills/${targetCat}/`.length);
4407
+ return !rel.startsWith("references/") && rel !== "references" && rel !== "_INDEX.md";
4408
+ }).map((f) => f.path.split("/")[2]).filter(Boolean)).forEach((s) => {
4409
+ const fullPath = `${targetCat}/${s}`;
4410
+ if (!folders.includes(fullPath)) folders.push(fullPath);
4411
+ });
4412
+ } else if (!folders.includes(absSkill)) {
4413
+ if (tree.some((f) => f.path.startsWith(`skills/${targetCat}/${targetSkill}/`))) folders.push(absSkill);
4414
+ else console.log(picocolors.default.yellow(` ⚠️ Absolute include ${absSkill} not found in repository.`));
4415
+ }
4044
4416
  }
4417
+ /**
4418
+ * Kiro has no file/keyword routing concept, so `metadata.triggers` is
4419
+ * intentionally dropped here (Kiro users get skills with no auto-routing
4420
+ * — a known, documented limitation, not a bug). Every other declared
4421
+ * field — including the optional Universal-Skill-Format fields
4422
+ * (version/risk_tier/allowed-tools/permissions/content_hash/signature) —
4423
+ * is preserved rather than silently discarded.
4424
+ *
4425
+ * Uses a real YAML parse + js-yaml dump (matching SpecialistTransformer)
4426
+ * instead of regex-extract-and-reinterpolate: the previous implementation
4427
+ * built `description: ${description}` via raw string interpolation, which
4428
+ * had the same YAML-key-injection exposure fixed elsewhere in this PR — a
4429
+ * description containing a quote+newline could inject a new top-level key
4430
+ * into the emitted Kiro frontmatter.
4431
+ */
4045
4432
  transformSkillForKiro(content, category) {
4046
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
4433
+ const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
4047
4434
  if (!frontmatterMatch) return content;
4048
- const [fullMatch, frontmatter] = frontmatterMatch;
4049
- const body = content.slice(fullMatch.length);
4050
- const name = frontmatter.match(/^name:\s*(.+)$/m)?.[1].trim() || "";
4051
- const description = frontmatter.match(/^description:\s*(.+)$/m)?.[1].trim() || "";
4052
- return `---\nname: ${`${category.charAt(0).toUpperCase() + category.slice(1)} - ${name}`}\ndescription: ${description}\n---` + body;
4435
+ let metadata;
4436
+ try {
4437
+ metadata = js_yaml.load(frontmatterMatch[1]) ?? {};
4438
+ } catch {
4439
+ return content;
4440
+ }
4441
+ const body = frontmatterMatch[2];
4442
+ const name = typeof metadata.name === "string" ? metadata.name : "";
4443
+ const displayName = `${category.charAt(0).toUpperCase() + category.slice(1)} - ${name}`;
4444
+ const fm = {
4445
+ ...metadata,
4446
+ name: displayName,
4447
+ description: typeof metadata.description === "string" ? metadata.description : ""
4448
+ };
4449
+ const kiroMetadata = fm.metadata;
4450
+ if (this.isRecord(kiroMetadata)) {
4451
+ const preservedMetadata = { ...kiroMetadata };
4452
+ delete preservedMetadata.triggers;
4453
+ fm.metadata = preservedMetadata;
4454
+ }
4455
+ return `---\n${js_yaml.dump(fm, { lineWidth: -1 }).trimEnd()}\n---\n\n${body}`;
4456
+ }
4457
+ isPackageResource(relativePath) {
4458
+ return relativePath === "SKILL.md" || /^(references|scripts|assets)\//.test(relativePath) || this.isRootAttributionFile(relativePath);
4459
+ }
4460
+ isRootAttributionFile(filePath) {
4461
+ return !filePath.includes("/") && /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i.test(filePath);
4462
+ }
4463
+ markPackageFailed(category, skill) {
4464
+ let failedSkills = this.failedPackagesByCategory.get(category);
4465
+ if (!failedSkills) {
4466
+ failedSkills = /* @__PURE__ */ new Set();
4467
+ this.failedPackagesByCategory.set(category, failedSkills);
4468
+ }
4469
+ failedSkills.add(skill);
4470
+ }
4471
+ async pruneOrphanedSkills(basePath, fetchedSkillsByCategory, overrides) {
4472
+ for (const [category, fetchedSkills] of Object.entries(fetchedSkillsByCategory)) {
4473
+ if (this.failedCategories.has(category)) continue;
4474
+ const categoryPath = path.default.join(basePath, category);
4475
+ if (!await fs_extra.default.pathExists(categoryPath)) continue;
4476
+ const failedSkills = this.failedPackagesByCategory.get(category);
4477
+ const existingDirs = await fs_extra.default.readdir(categoryPath);
4478
+ for (const directory of existingDirs) {
4479
+ const fullPath = path.default.join(categoryPath, directory);
4480
+ if (fetchedSkills.has(directory) || failedSkills?.has(directory) || this.isOverridden(fullPath, overrides)) continue;
4481
+ await fs_extra.default.remove(fullPath);
4482
+ }
4483
+ }
4484
+ }
4485
+ hasOverridesInsideSkill(skillPath, overrides) {
4486
+ const skillRelativePath = this.normalizePath(skillPath);
4487
+ const skillSuffix = skillRelativePath.split("/").slice(-2).join("/");
4488
+ return overrides.some((override) => {
4489
+ const normalizedOverride = override.replace(/\\/g, "/").replace(/\/$/, "");
4490
+ return this.isOverridden(skillPath, [override]) || normalizedOverride.startsWith(`${skillRelativePath}/`) || normalizedOverride.includes(`/${skillRelativePath}/`) || normalizedOverride === skillSuffix || normalizedOverride.startsWith(`${skillSuffix}/`) || normalizedOverride.includes(`/${skillSuffix}/`);
4491
+ });
4492
+ }
4493
+ async copyOverrides(sourcePath, targetPath, overrides) {
4494
+ if (!await fs_extra.default.pathExists(sourcePath)) return;
4495
+ const sourceStat = await fs_extra.default.lstat(sourcePath);
4496
+ if (sourceStat.isSymbolicLink() || !sourceStat.isDirectory()) {
4497
+ console.log(picocolors.default.yellow(` ⚠️ Skipping override copy outside installation root: ${this.normalizePath(sourcePath)}`));
4498
+ return;
4499
+ }
4500
+ const entries = await fs_extra.default.readdir(sourcePath, { withFileTypes: true });
4501
+ for (const entry of entries) {
4502
+ const source = path.default.join(sourcePath, entry.name);
4503
+ const target = path.default.join(targetPath, entry.name);
4504
+ if (entry.isDirectory()) await this.copyOverrides(source, target, overrides);
4505
+ else if (entry.isFile() && this.isOverridden(source, overrides)) await fs_extra.default.copy(source, target);
4506
+ }
4507
+ }
4508
+ async replaceSkillDirectory(stagingPath, skillPath) {
4509
+ const backupPath = `${skillPath}.ags-backup`;
4510
+ if (await fs_extra.default.pathExists(backupPath)) throw new Error(`Refusing to replace ${this.normalizePath(skillPath)}: backup path already exists at ${backupPath}`);
4511
+ const hadExistingSkill = await fs_extra.default.pathExists(skillPath);
4512
+ if (hadExistingSkill) await fs_extra.default.move(skillPath, backupPath, { overwrite: false });
4513
+ try {
4514
+ await fs_extra.default.move(stagingPath, skillPath, { overwrite: false });
4515
+ } catch (error) {
4516
+ if (hadExistingSkill) await fs_extra.default.move(backupPath, skillPath, { overwrite: false });
4517
+ throw error;
4518
+ }
4519
+ if (hadExistingSkill) await fs_extra.default.remove(backupPath);
4520
+ }
4521
+ isRecord(value) {
4522
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4053
4523
  }
4054
4524
  isOverridden(targetPath, overrides) {
4055
4525
  const rel = this.normalizePath(targetPath);
@@ -4067,6 +4537,21 @@ var SkillSyncService = class {
4067
4537
  }
4068
4538
  };
4069
4539
  //#endregion
4540
+ //#region src/services/utils/tomlEscape.ts
4541
+ /**
4542
+ * Escapes backslashes and double quotes for embedding inside any TOML basic
4543
+ * string — single-line `"..."` or multi-line `"""..."""`.
4544
+ *
4545
+ * Escaping every quote (not just runs of 3+) sidesteps the ambiguity around
4546
+ * multi-line basic strings, where an unescaped run of 3+ quote characters
4547
+ * can be misread as the closing delimiter: with every quote escaped, no
4548
+ * unescaped `"""` sequence can ever occur in the output, regardless of how
4549
+ * many consecutive quote characters the source content contains.
4550
+ */
4551
+ function escapeTomlString(value) {
4552
+ return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
4553
+ }
4554
+ //#endregion
4070
4555
  //#region src/services/utils/WorkflowTransformer.ts
4071
4556
  var WORKFLOW_ARGUMENTS = "Optional args: slug=<feature>, ticket=<id/url>, mode=interactive|autonomous|channel, channel=<id>, auto_continue=true|false, profile=business|hybrid|technical.";
4072
4557
  /**
@@ -4133,8 +4618,10 @@ var WorkflowTransformer = class {
4133
4618
  body: content
4134
4619
  };
4135
4620
  const descMatch = match[1].match(/description:\s*(.+)/);
4621
+ const rawDescription = descMatch ? descMatch[1].trim() : "";
4622
+ const quotedMatch = rawDescription.match(/^"(.*)"$|^'(.*)'$/);
4136
4623
  return {
4137
- description: descMatch ? descMatch[1].trim() : "",
4624
+ description: quotedMatch ? quotedMatch[1] ?? quotedMatch[2] : rawDescription,
4138
4625
  body: match[2]
4139
4626
  };
4140
4627
  }
@@ -4167,13 +4654,13 @@ ${body}`;
4167
4654
  * User invokes via /<name> [arguments]
4168
4655
  */
4169
4656
  static toGeminiCommand(description, body) {
4170
- return `description = "${description.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"
4657
+ return `description = "${escapeTomlString(description)}"
4171
4658
  prompt = """
4172
4659
  Execute this workflow for: {{args}}
4173
4660
 
4174
4661
  ${WORKFLOW_ARGUMENTS}
4175
4662
 
4176
- ${body.replace(/"""/g, "\\\"\\\"\\\"")}
4663
+ ${escapeTomlString(body)}
4177
4664
  """
4178
4665
  `;
4179
4666
  }
@@ -4183,7 +4670,7 @@ ${body.replace(/"""/g, "\\\"\\\"\\\"")}
4183
4670
  * User invokes via /prompt-name in Copilot chat.
4184
4671
  */
4185
4672
  static toCopilotPrompt(description, body) {
4186
- return `---\ndescription: "${description}"\n---\n${body}`;
4673
+ return `---\ndescription: "${description.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"\n---\n${body}`;
4187
4674
  }
4188
4675
  /**
4189
4676
  * Agent Skill (SKILL.md) format.
@@ -4225,6 +4712,7 @@ ${body}
4225
4712
  * Service responsible for synchronizing agent workflows from a remote registry.
4226
4713
  */
4227
4714
  var WorkflowSyncService = class {
4715
+ githubService;
4228
4716
  constructor(githubService) {
4229
4717
  this.githubService = githubService;
4230
4718
  }
@@ -4283,12 +4771,14 @@ var WorkflowSyncService = class {
4283
4771
  if (Array.isArray(config.workflows)) return config.workflows.includes(this.workflowNameFromPath(f.path));
4284
4772
  return false;
4285
4773
  });
4286
- const files = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
4774
+ const { ok: files, failed } = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
4287
4775
  owner,
4288
4776
  repo,
4289
4777
  ref,
4290
- path: f.path
4778
+ path: f.path,
4779
+ sha: f.sha
4291
4780
  })));
4781
+ for (const failure of failed) console.log(picocolors.default.red(` ❌ ${failure.path} — ${failure.reason}`));
4292
4782
  if (files.length > 0) {
4293
4783
  console.log(picocolors.default.gray(` + Fetched ${files.length} workflows`));
4294
4784
  return [{
@@ -4371,6 +4861,46 @@ var WorkflowSyncService = class {
4371
4861
  };
4372
4862
  //#endregion
4373
4863
  //#region src/services/utils/SpecialistTransformer.ts
4864
+ var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
4865
+ /**
4866
+ * Serializes frontmatter fields via js-yaml so every value — including a
4867
+ * `description` sourced from skill content — is safely quoted/escaped.
4868
+ * Interpolating raw strings into a hand-built YAML template let a crafted
4869
+ * description inject its own top-level keys (e.g. a second `tools:`) into
4870
+ * the emitted agent file; js-yaml can't be tricked that way.
4871
+ */
4872
+ function dumpFrontmatter(fields) {
4873
+ return js_yaml.dump(fields, { lineWidth: -1 }).trimEnd();
4874
+ }
4875
+ /**
4876
+ * Codex is the only target with a native least-privilege primitive
4877
+ * (sandbox_mode). L2/L3 map to workspace-write; danger-full-access is never
4878
+ * auto-granted regardless of declared risk_tier — that always requires
4879
+ * explicit user configuration outside the sync pipeline.
4880
+ */
4881
+ function sandboxModeFor(riskTier) {
4882
+ return riskTier === "L2" || riskTier === "L3" ? "workspace-write" : "read-only";
4883
+ }
4884
+ /** Summarizes whichever of risk_tier/allowed-tools/permissions a specialist declared, for a warning comment. */
4885
+ function summarizeDeclaredPermissions(metadata) {
4886
+ const parts = [];
4887
+ if (metadata.risk_tier) parts.push(`risk_tier: ${metadata.risk_tier}`);
4888
+ if (metadata["allowed-tools"]) parts.push(`allowed-tools: ${JSON.stringify(metadata["allowed-tools"])}`);
4889
+ if (metadata.permissions) parts.push(`permissions: ${JSON.stringify(metadata.permissions)}`);
4890
+ return parts.length > 0 ? parts.join("; ") : null;
4891
+ }
4892
+ /**
4893
+ * For platforms with no way to express a declared risk_tier/permissions/
4894
+ * allowed-tools, prepend a visible warning rather than silently dropping
4895
+ * the declaration — a persona authored as restricted for one platform
4896
+ * should not read as unconstrained everywhere else without at least a
4897
+ * trace of what was intended.
4898
+ */
4899
+ function unenforceableWarningComment(metadata, agentName) {
4900
+ const declared = summarizeDeclaredPermissions(metadata);
4901
+ if (!declared) return "";
4902
+ return `<!-- ags: permissions not enforceable on ${agentName}; declared: ${declared} -->\n`;
4903
+ }
4374
4904
  /**
4375
4905
  * Transforms specialist skill markdown into platform-specific agent personas.
4376
4906
  */
@@ -4378,45 +4908,82 @@ var SpecialistTransformer = class {
4378
4908
  static transform(source, agentId) {
4379
4909
  const agentDef = getAgentDefinition(agentId);
4380
4910
  if (!agentDef) return null;
4381
- const parts = source.content.split("---");
4382
- if (parts.length < 3) return null;
4383
- const metadata = js_yaml.default.load(parts[1]);
4384
- const body = parts.slice(2).join("---").trim();
4911
+ const match = source.content.match(FRONTMATTER_RE);
4912
+ if (!match) return null;
4913
+ const metadata = js_yaml.load(match[1]);
4914
+ const body = match[2].trim();
4385
4915
  const baseName = source.name.replace(/^specialist-/, "");
4386
4916
  const fullName = source.name;
4387
4917
  const description = metadata.description || `Specialist persona for ${baseName}`;
4388
4918
  switch (agentId) {
4389
- case Agent.Claude: return {
4390
- name: `${baseName}.md`,
4391
- content: `---\nname: ${baseName}\ndescription: "${description}"\n---\n\n${body}`
4392
- };
4919
+ case Agent.Claude: {
4920
+ const fm = {
4921
+ name: baseName,
4922
+ description
4923
+ };
4924
+ const allowedTools = metadata["allowed-tools"];
4925
+ if (metadata.tools) fm.tools = Array.isArray(metadata.tools) ? metadata.tools.join(", ") : metadata.tools;
4926
+ else if (Array.isArray(allowedTools) && allowedTools.length > 0) fm.tools = allowedTools.join(", ");
4927
+ else if (typeof allowedTools === "string" && allowedTools.trim().length > 0) fm.tools = allowedTools.trim();
4928
+ if (metadata.model) fm.model = metadata.model;
4929
+ if (metadata.color) fm.color = metadata.color;
4930
+ return {
4931
+ name: `${baseName}.md`,
4932
+ content: `---\n${dumpFrontmatter(fm)}\n---\n\n${body}`
4933
+ };
4934
+ }
4393
4935
  case Agent.Cursor: return {
4394
4936
  name: `${fullName}.mdc`,
4395
- content: `---\ndescription: ${description}\nglobs: ["**/*"]\n---\n# Specialist: ${baseName}\n\n${body}`
4937
+ content: `${unenforceableWarningComment(metadata, "Cursor")}---\n${dumpFrontmatter({
4938
+ description,
4939
+ globs: ["**/*"]
4940
+ })}\n---\n# Specialist: ${baseName}\n\n${body}`
4396
4941
  };
4397
4942
  case Agent.Copilot: return {
4398
4943
  name: `${fullName}.instructions.md`,
4399
- content: `---\ndescription: "${description}"\napplyTo: "**/*"\n---\n\n${body}`
4944
+ content: `${unenforceableWarningComment(metadata, "Copilot")}---\n${dumpFrontmatter({
4945
+ description,
4946
+ applyTo: "**/*"
4947
+ })}\n---\n\n${body}`
4400
4948
  };
4401
4949
  case Agent.OpenCode: return {
4402
4950
  name: `${baseName}.md`,
4403
- content: `---\ndescription: "${description}"\nmode: subagent\n---\n\n${body}`
4951
+ content: `${unenforceableWarningComment(metadata, "OpenCode")}---\n${dumpFrontmatter({
4952
+ description,
4953
+ mode: "subagent"
4954
+ })}\n---\n\n${body}`
4404
4955
  };
4405
4956
  case Agent.Gemini: return {
4406
4957
  name: `${baseName}.md`,
4407
- content: `---\nname: ${baseName}\ndescription: "${description}"\nkind: local\n---\n\n${body}`
4958
+ content: `${unenforceableWarningComment(metadata, "Gemini")}---\n${dumpFrontmatter({
4959
+ name: baseName,
4960
+ description,
4961
+ kind: "local"
4962
+ })}\n---\n\n${body}`
4408
4963
  };
4409
4964
  case Agent.Kiro: return {
4410
4965
  name: `${baseName}.md`,
4411
- content: `---\nname: ${baseName}\ndescription: "${description}"\n---\n\n${body}`
4412
- };
4413
- case Agent.Codex: return {
4414
- name: `${baseName}.toml`,
4415
- content: `name = "${baseName}"\ndescription = "${description}"\nsandbox_mode = "read-only"\ndeveloper_instructions = """\n${body}\n"""`
4966
+ content: `${unenforceableWarningComment(metadata, "Kiro")}---\n${dumpFrontmatter({
4967
+ name: baseName,
4968
+ description
4969
+ })}\n---\n\n${body}`
4416
4970
  };
4971
+ case Agent.Codex: {
4972
+ const sandboxMode = sandboxModeFor(metadata.risk_tier);
4973
+ const unenforceable = [];
4974
+ if (metadata["allowed-tools"]) unenforceable.push("allowed-tools");
4975
+ const permissions = metadata.permissions;
4976
+ if (permissions?.network) unenforceable.push("permissions.network");
4977
+ if (permissions?.filesystem) unenforceable.push("permissions.filesystem");
4978
+ const comment = unenforceable.length > 0 ? `# ags: ${unenforceable.join(", ")} declared but not enforceable via Codex TOML (only sandbox_mode is projected)\n` : "";
4979
+ return {
4980
+ name: `${baseName}.toml`,
4981
+ content: `${comment}name = "${escapeTomlString(baseName)}"\ndescription = "${escapeTomlString(description)}"\nsandbox_mode = "${sandboxMode}"\ndeveloper_instructions = """\n${escapeTomlString(body)}\n"""`
4982
+ };
4983
+ }
4417
4984
  default: return {
4418
4985
  name: `${baseName}${agentDef.ruleExtension || ".md"}`,
4419
- content: body
4986
+ content: `${unenforceableWarningComment(metadata, agentDef.name)}${body}`
4420
4987
  };
4421
4988
  }
4422
4989
  }
@@ -4428,6 +4995,7 @@ var SpecialistTransformer = class {
4428
4995
  * to native agent configuration directories.
4429
4996
  */
4430
4997
  var SpecialistSyncService = class {
4998
+ githubService;
4431
4999
  constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
4432
5000
  this.githubService = githubService;
4433
5001
  }
@@ -4599,6 +5167,86 @@ var GitService = class {
4599
5167
  }
4600
5168
  };
4601
5169
  //#endregion
5170
+ //#region src/services/LockfileService.ts
5171
+ var LOCKFILE_NAME = ".skills-lock.json";
5172
+ function sha256(content) {
5173
+ return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
5174
+ }
5175
+ function contentHashOf(files) {
5176
+ return sha256(Object.keys(files).sort().map((p) => `${p}\0${files[p]}\n`).join(""));
5177
+ }
5178
+ /**
5179
+ * Records the sha256 of every file `ags sync` writes for a skill, so a later
5180
+ * `ags verify` can detect drift between what was fetched and what's actually
5181
+ * on disk — tampering, a partial write, or a manual edit that silently
5182
+ * diverged from the registry. This is deliberately scoped to local
5183
+ * tamper-detection: it does not (yet) resolve refs to commits or detect a
5184
+ * moved tag on the registry side — see docs/SECURITY.md's OWASP AST table
5185
+ * (AST07) for that follow-up.
5186
+ */
5187
+ var LockfileService = class {
5188
+ lockfilePath(rootDir) {
5189
+ return path.default.join(rootDir, LOCKFILE_NAME);
5190
+ }
5191
+ buildEntries(skills, refByCategory) {
5192
+ const entries = {};
5193
+ for (const skill of skills) {
5194
+ const files = {};
5195
+ for (const file of skill.files) files[file.name] = sha256(file.bytes ?? Buffer.from(file.content, "utf8"));
5196
+ entries[`${skill.category}/${skill.skill}`] = {
5197
+ ref: refByCategory[skill.category] || "unknown",
5198
+ files,
5199
+ contentHash: contentHashOf(files)
5200
+ };
5201
+ }
5202
+ return entries;
5203
+ }
5204
+ async write(rootDir, registry, skills, refByCategory) {
5205
+ if (skills.length === 0) return;
5206
+ const lock = {
5207
+ version: 1,
5208
+ registry,
5209
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5210
+ skills: this.buildEntries(skills, refByCategory)
5211
+ };
5212
+ await fs_extra.default.writeJson(this.lockfilePath(rootDir), lock, { spaces: 2 });
5213
+ }
5214
+ async read(rootDir) {
5215
+ const file = this.lockfilePath(rootDir);
5216
+ if (!await fs_extra.default.pathExists(file)) return null;
5217
+ return fs_extra.default.readJson(file);
5218
+ }
5219
+ /**
5220
+ * Recomputes the sha256 of every file the lockfile knows about, under
5221
+ * `installedPath/<category>/<skill>/<relPath>` (the standard, non-Kiro
5222
+ * layout `SkillSyncService.writeSkillForAgent` uses), and compares.
5223
+ */
5224
+ async verify(rootDir, installedPath) {
5225
+ const lock = await this.read(rootDir);
5226
+ if (!lock) return {
5227
+ ok: false,
5228
+ mismatches: [],
5229
+ missing: [`${LOCKFILE_NAME} not found — run 'ags sync' first`]
5230
+ };
5231
+ const mismatches = [];
5232
+ const missing = [];
5233
+ for (const [skillKey, entry] of Object.entries(lock.skills)) for (const [relPath, expectedHash] of Object.entries(entry.files)) {
5234
+ const label = `${skillKey}/${relPath}`;
5235
+ const filePath = path.default.join(installedPath, skillKey, relPath);
5236
+ if (!await fs_extra.default.pathExists(filePath)) {
5237
+ missing.push(label);
5238
+ continue;
5239
+ }
5240
+ if (sha256(await fs_extra.default.readFile(filePath)) !== expectedHash) mismatches.push(label);
5241
+ }
5242
+ return {
5243
+ ok: mismatches.length === 0 && missing.length === 0,
5244
+ mismatches,
5245
+ missing
5246
+ };
5247
+ }
5248
+ };
5249
+ //#endregion
4602
5250
  //#region src/services/utils/MarkdownUtils.ts
4603
5251
  /**
4604
5252
  * Utility functions for manipulating markdown content
@@ -4616,7 +5264,7 @@ var MarkdownUtils = class {
4616
5264
  const updated = [];
4617
5265
  for (const target of targets) {
4618
5266
  const targetPath = path.default.join(rootDir, target);
4619
- let content = "";
5267
+ let content;
4620
5268
  const markerStart = "<!-- SKILLS_INDEX_START -->";
4621
5269
  const markerEnd = "<!-- SKILLS_INDEX_END -->";
4622
5270
  if (await fs_extra.default.pathExists(targetPath)) {
@@ -4656,6 +5304,7 @@ var SyncService = class {
4656
5304
  workflowSyncService = new WorkflowSyncService(this.githubService);
4657
5305
  specialistSyncService = new SpecialistSyncService();
4658
5306
  gitService = new GitService();
5307
+ lockfileService = new LockfileService();
4659
5308
  async reconcileConfig(config, projectDeps) {
4660
5309
  const reenabled = this.configService.reconcileDependencies(config, projectDeps);
4661
5310
  if (reenabled.length > 0) {
@@ -4686,7 +5335,18 @@ var SyncService = class {
4686
5335
  async writeSkills(skills, config) {
4687
5336
  await this.cleanupOldFolders();
4688
5337
  const agents = await this.resolveTargetAgents(config);
4689
- return this.skillSyncService.writeSkills(skills, config, agents);
5338
+ await this.skillSyncService.writeSkills(skills, config, agents);
5339
+ await this.writeLockfile(skills, config);
5340
+ }
5341
+ /**
5342
+ * Records what was actually fetched (per-file sha256) so `ags verify` can
5343
+ * later detect drift between the lockfile and what's on disk. No-op if
5344
+ * nothing was fetched (e.g. every category failed to resolve).
5345
+ */
5346
+ async writeLockfile(skills, config) {
5347
+ const refByCategory = {};
5348
+ for (const [category, entry] of Object.entries(config.skills)) refByCategory[category] = entry.ref || "main";
5349
+ await this.lockfileService.write(process.cwd(), config.registry, skills, refByCategory);
4690
5350
  }
4691
5351
  async assembleWorkflows(config) {
4692
5352
  return this.workflowSyncService.assembleWorkflows(config);
@@ -4737,8 +5397,10 @@ var SyncService = class {
4737
5397
  if ((await MarkdownUtils.injectIndex(process.cwd(), ["AGENTS.md"], routerIndex)).length > 0) console.log(picocolors.default.green(" ✅ AGENTS.md router index updated."));
4738
5398
  else console.log(picocolors.default.yellow(" ⚠️ Skipped AGENTS.md update: index markers <!-- SKILLS_INDEX_START --> … <!-- SKILLS_INDEX_END --> are missing or out of order. Opt-in by adding these markers to your file."));
4739
5399
  const serverDir = path.default.join(process.cwd(), "server");
4740
- if (await fs_extra.default.pathExists(serverDir)) if ((await MarkdownUtils.injectIndex(serverDir, ["AGENTS.md"], routerIndex)).length > 0) console.log(picocolors.default.green(" ✅ server/AGENTS.md router index updated."));
4741
- else console.log(picocolors.default.yellow(" ⚠️ Skipped server/AGENTS.md update: index markers <!-- SKILLS_INDEX_START --> … <!-- SKILLS_INDEX_END --> are missing or out of order in server/AGENTS.md. Opt-in via markers."));
5400
+ if (await fs_extra.default.pathExists(serverDir)) {
5401
+ if ((await MarkdownUtils.injectIndex(serverDir, ["AGENTS.md"], routerIndex)).length > 0) console.log(picocolors.default.green(" ✅ server/AGENTS.md router index updated."));
5402
+ else console.log(picocolors.default.yellow(" ⚠️ Skipped server/AGENTS.md update: index markers <!-- SKILLS_INDEX_START --> … <!-- SKILLS_INDEX_END --> are missing or out of order in server/AGENTS.md. Opt-in via markers."));
5403
+ }
4742
5404
  await new AgentBridgeService().bridge(process.cwd(), agents);
4743
5405
  } catch (error) {
4744
5406
  console.log(picocolors.default.yellow(` ⚠️ Failed to update index: ${error}`));
@@ -4758,8 +5420,29 @@ var SyncService = class {
4758
5420
  const latestRef = `${remoteMetaCat.tag_prefix || ""}${remoteMetaCat.version}`;
4759
5421
  if (catConfig.ref !== latestRef) updates[cat] = latestRef;
4760
5422
  }
5423
+ this.warnAboutRevokedRefs(config, remoteMeta);
4761
5424
  return updates;
4762
5425
  }
5426
+ /**
5427
+ * Prints a warning (does not block sync) for any installed category ref
5428
+ * that appears in the registry's `revocations` list — e.g. a version
5429
+ * later found to carry a vulnerability. Checked against the *live*
5430
+ * registry metadata.json (fetched above), not this repo's own local copy,
5431
+ * so a revocation recorded after a consumer's initial sync is still seen
5432
+ * on their next sync/update check.
5433
+ */
5434
+ warnAboutRevokedRefs(config, remoteMeta) {
5435
+ const revocations = remoteMeta.revocations ?? [];
5436
+ if (revocations.length === 0) return;
5437
+ for (const [cat, catConfig] of Object.entries(config.skills)) {
5438
+ if (!catConfig.ref) continue;
5439
+ const hit = revocations.find((r) => r.category === cat && r.refs.includes(catConfig.ref));
5440
+ if (!hit) continue;
5441
+ console.log(picocolors.default.red(`\n🚨 ${cat}@${catConfig.ref} has been revoked: ${hit.reason}`));
5442
+ if (hit.advisory) console.log(picocolors.default.gray(` Advisory: ${hit.advisory}`));
5443
+ console.log(picocolors.default.yellow(" Run `ags sync --yes` to update to a non-revoked version."));
5444
+ }
5445
+ }
4763
5446
  async resolveTargetAgents(config) {
4764
5447
  if (config.agents && config.agents.length > 0) return config.agents;
4765
5448
  const detectedMap = await this.detectionService.detectAgents();
@@ -4767,6 +5450,29 @@ var SyncService = class {
4767
5450
  if (detected.length > 0) return detected;
4768
5451
  return [];
4769
5452
  }
5453
+ /**
5454
+ * Verifies installed skill files against `.skills-lock.json`. Checks the
5455
+ * first configured agent's skill directory by default (or `agentId` if
5456
+ * given) — the standard `<agentPath>/<category>/<skill>/<file>` layout;
5457
+ * Kiro's flattened `<category>-<skill>/` layout isn't supported yet.
5458
+ */
5459
+ async verifyLockfile(config, agentId) {
5460
+ const targetAgentId = agentId ?? config.agents?.[0];
5461
+ const agentDef = SUPPORTED_AGENTS.find((a) => a.id === targetAgentId);
5462
+ if (!agentDef?.path) return {
5463
+ agent: null,
5464
+ result: {
5465
+ ok: false,
5466
+ mismatches: [],
5467
+ missing: ["no configured agent with a skill directory to verify"]
5468
+ }
5469
+ };
5470
+ const result = await this.lockfileService.verify(process.cwd(), path.default.join(process.cwd(), agentDef.path));
5471
+ return {
5472
+ agent: agentDef.id,
5473
+ result
5474
+ };
5475
+ }
4770
5476
  async cleanupOldFolders() {
4771
5477
  const oldPath = path.default.join(process.cwd(), ".agent");
4772
5478
  const newPath = path.default.join(process.cwd(), ".agents");
@@ -4821,15 +5527,17 @@ var SyncCommand = class {
4821
5527
  console.log(picocolors.default.yellow("\n🚀 New skill versions detected:"));
4822
5528
  for (const [cat, ref] of Object.entries(updates)) console.log(picocolors.default.gray(` - ${cat}: ${config.skills[cat].ref} -> ${ref}`));
4823
5529
  let update = options.yes;
4824
- if (update === void 0) if (!process.stdin.isTTY) {
4825
- console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
4826
- update = false;
4827
- } else update = (await inquirer.default.prompt([{
4828
- type: "confirm",
4829
- name: "update",
4830
- message: "Do you want to update .skillsrc with these versions?",
4831
- default: true
4832
- }])).update;
5530
+ if (update === void 0) {
5531
+ if (!process.stdin.isTTY) {
5532
+ console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
5533
+ update = false;
5534
+ } else update = (await inquirer.default.prompt([{
5535
+ type: "confirm",
5536
+ name: "update",
5537
+ message: "Do you want to update .skillsrc with these versions?",
5538
+ default: true
5539
+ }])).update;
5540
+ }
4833
5541
  if (update) {
4834
5542
  for (const [cat, ref] of Object.entries(updates)) config.skills[cat].ref = ref;
4835
5543
  await this.configService.saveConfig(config);
@@ -4846,7 +5554,7 @@ var SyncCommand = class {
4846
5554
  await this.syncService.applyIndices(config, config.agents);
4847
5555
  console.log(picocolors.default.green("✅ All skills synced successfully!"));
4848
5556
  await this.runMcpPhase(config, options);
4849
- await this.runHookPhase(config);
5557
+ await this.runHookPhase(config, options);
4850
5558
  } catch (error) {
4851
5559
  if (error instanceof Error) console.error(picocolors.default.red("❌ Sync failed:"), error.message);
4852
5560
  else console.error(picocolors.default.red("❌ Sync failed:"), String(error));
@@ -4932,7 +5640,7 @@ var SyncCommand = class {
4932
5640
  message: "Enable MCP server integration?",
4933
5641
  default: true
4934
5642
  }, {
4935
- type: "list",
5643
+ type: "rawlist",
4936
5644
  name: "scope",
4937
5645
  message: "Where should sync write MCP configs?",
4938
5646
  when: (a) => a.enabled === true,
@@ -4959,7 +5667,7 @@ var SyncCommand = class {
4959
5667
  * files up to date. Claude's script is preserved if already customized; Kiro's
4960
5668
  * markdown hook is kept in sync with the embedded template.
4961
5669
  */
4962
- async runHookPhase(config) {
5670
+ async runHookPhase(config, options = {}) {
4963
5671
  const agents = config.agents ?? [];
4964
5672
  if (agents.length === 0) return;
4965
5673
  const mcp = config.mcp ?? defaultMcpConfig();
@@ -4970,9 +5678,23 @@ var SyncCommand = class {
4970
5678
  });
4971
5679
  return;
4972
5680
  }
5681
+ const promptPermission = options.yes ? async () => true : async (agent) => {
5682
+ if (!process.stdin.isTTY) {
5683
+ console.log(picocolors.default.gray(`\nℹ️ Skipping MCP permission grant for ${agent} (non-interactive; use --yes to auto-confirm).`));
5684
+ return false;
5685
+ }
5686
+ const { ok } = await inquirer.default.prompt([{
5687
+ type: "confirm",
5688
+ name: "ok",
5689
+ message: `Allow ${picocolors.default.cyan(agent)} to auto-run the agent-skills-standard MCP tools (adds "${CLAUDE_MCP_PERMISSION}" to permissions.allow)?`,
5690
+ default: true
5691
+ }]);
5692
+ return ok;
5693
+ };
4973
5694
  const writes = (await this.hookService.install({
4974
5695
  rootDir: process.cwd(),
4975
- agents
5696
+ agents,
5697
+ promptPermission
4976
5698
  })).writes.filter((w) => w.action !== "skipped-existing");
4977
5699
  if (writes.length === 0) return;
4978
5700
  console.log(picocolors.default.cyan("\n🪝 Updating skill-loader hooks..."));
@@ -4983,6 +5705,39 @@ var SyncCommand = class {
4983
5705
  }
4984
5706
  };
4985
5707
  //#endregion
5708
+ //#region src/commands/audit.ts
5709
+ /**
5710
+ * Prints the skill inventory recorded in .skills-lock.json — the AST09
5711
+ * "skill inventory" governance control: what's installed, from which ref,
5712
+ * and how many files each skill carries.
5713
+ */
5714
+ var AuditCommand = class {
5715
+ lockfileService;
5716
+ constructor(lockfileService) {
5717
+ this.lockfileService = lockfileService || new LockfileService();
5718
+ }
5719
+ async run() {
5720
+ const lock = await this.lockfileService.read(process.cwd());
5721
+ if (!lock) {
5722
+ console.log(picocolors.default.yellow("No .skills-lock.json found. Run `ags sync` first to generate one."));
5723
+ process.exitCode = 1;
5724
+ return;
5725
+ }
5726
+ const entries = Object.entries(lock.skills).sort(([a], [b]) => a.localeCompare(b));
5727
+ console.log(picocolors.default.cyan(`📋 Skill inventory (${lock.registry})`));
5728
+ console.log(picocolors.default.gray(` Generated: ${lock.generatedAt}\n`));
5729
+ if (entries.length === 0) {
5730
+ console.log(picocolors.default.gray(" (no skills recorded)"));
5731
+ return;
5732
+ }
5733
+ for (const [skillKey, entry] of entries) {
5734
+ const fileCount = Object.keys(entry.files).length;
5735
+ console.log(` ${picocolors.default.bold(skillKey.padEnd(40))} ${picocolors.default.gray(entry.ref.padEnd(24))} ${fileCount} file${fileCount === 1 ? "" : "s"}`);
5736
+ }
5737
+ console.log(picocolors.default.gray(`\n ${entries.length} skill(s) total`));
5738
+ }
5739
+ };
5740
+ //#endregion
4986
5741
  //#region src/commands/upgrade.ts
4987
5742
  /**
4988
5743
  * Command for upgrading the `agent-skills-standard` CLI to the latest version.
@@ -4997,7 +5752,7 @@ var UpgradeCommand = class {
4997
5752
  async run(options) {
4998
5753
  console.log(picocolors.default.cyan("🔍 Checking for updates..."));
4999
5754
  const currentVersion = this.currentVersion;
5000
- let latestVersion = null;
5755
+ let latestVersion;
5001
5756
  try {
5002
5757
  latestVersion = (0, child_process.execSync)("npm view agent-skills-standard version", { encoding: "utf8" }).trim();
5003
5758
  } catch {
@@ -5031,7 +5786,8 @@ var UpgradeCommand = class {
5031
5786
  (0, child_process.execSync)(upgradeCmd, { stdio: "inherit" });
5032
5787
  } catch {
5033
5788
  console.log(picocolors.default.yellow(`⚠️ Failed to install v${latestVersion}. Retrying with @latest...`));
5034
- (0, child_process.execSync)(this.getUpgradeCommand(pm, "latest"), { stdio: "inherit" });
5789
+ const fallbackCmd = this.getUpgradeCommand(pm, "latest");
5790
+ (0, child_process.execSync)(fallbackCmd, { stdio: "inherit" });
5035
5791
  }
5036
5792
  if (pm === "pnpm") {
5037
5793
  const installedVersion = this.getInstalledVersionFromPnpm(latestVersion);
@@ -5252,11 +6008,88 @@ var SkillDiscoveryService = class {
5252
6008
  }
5253
6009
  };
5254
6010
  //#endregion
6011
+ //#region src/schemas/skill-frontmatter.ts
6012
+ /**
6013
+ * Optional Universal-Skill-Format-style fields a SKILL.md's frontmatter may
6014
+ * declare, per the OWASP Agentic Skills Top 10 (AST03 least-privilege
6015
+ * manifests / AST04 schema validation). All optional and additive: the
6016
+ * existing 281-skill corpus has none of these today and stays valid without
6017
+ * modification. `FrontmatterRule` validates whichever of these a skill
6018
+ * chooses to declare; nothing here is enforced yet by the sync/transform
6019
+ * pipeline — that's tracked as follow-up work (see docs/SECURITY.md's AST
6020
+ * table, AST03).
6021
+ */
6022
+ var RISK_TIERS = [
6023
+ "L0",
6024
+ "L1",
6025
+ "L2",
6026
+ "L3"
6027
+ ];
6028
+ var SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
6029
+ var SHA256_RE = /^sha256:[0-9a-f]{64}$/;
6030
+ var skillPermissionsSchema = zod.z.object({
6031
+ network: zod.z.object({ allow: zod.z.array(zod.z.string()).default([]) }).strict().optional(),
6032
+ filesystem: zod.z.object({ deny: zod.z.array(zod.z.string()).default([]) }).strict().optional(),
6033
+ exec: zod.z.boolean().optional()
6034
+ }).strict();
6035
+ var skillSignatureSchema = zod.z.object({
6036
+ alg: zod.z.literal("ed25519"),
6037
+ keyid: zod.z.string().min(1),
6038
+ sig: zod.z.string().min(1)
6039
+ }).strict();
6040
+ /**
6041
+ * One upstream dependency a skill (or, in skills/metadata.json, a category)
6042
+ * tracks for freshness auditing. `source: github` needs a `repo`;
6043
+ * `source: manual` is checked only by its `reviewed` age.
6044
+ * See docs/FRESHNESS.md.
6045
+ */
6046
+ var upstreamEntrySchema = zod.z.object({
6047
+ name: zod.z.string().min(1),
6048
+ source: zod.z.enum(["github", "manual"]),
6049
+ repo: zod.z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be \"owner/name\"").optional(),
6050
+ pinned: zod.z.string().min(1),
6051
+ tag_pattern: zod.z.string().min(1).optional(),
6052
+ reviewed: zod.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "reviewed must be YYYY-MM-DD"),
6053
+ acknowledged: zod.z.string().min(1).optional()
6054
+ }).strict().refine((e) => e.source !== "github" || Boolean(e.repo), {
6055
+ message: "repo is required when source is \"github\"",
6056
+ path: ["repo"]
6057
+ });
6058
+ /**
6059
+ * Validates only the *optional, additive* Universal-Skill-Format fields.
6060
+ * `name`/`description` (required, with legacy hand-rolled error messages)
6061
+ * are validated separately in FrontmatterRule to preserve exact wording an
6062
+ * existing test suite and tooling depend on.
6063
+ */
6064
+ var optionalSkillFieldsSchema = zod.z.object({
6065
+ version: zod.z.string().regex(SEMVER_RE, "version must be a semver string (e.g. \"1.0.0\")").optional(),
6066
+ risk_tier: zod.z.enum(RISK_TIERS, { error: `risk_tier must be one of ${RISK_TIERS.join(", ")}` }).optional(),
6067
+ "allowed-tools": zod.z.union([zod.z.string(), zod.z.array(zod.z.string())]).optional(),
6068
+ permissions: skillPermissionsSchema.optional(),
6069
+ content_hash: zod.z.string().regex(SHA256_RE, "content_hash must match \"sha256:<64 hex chars>\"").optional(),
6070
+ signature: skillSignatureSchema.optional(),
6071
+ /**
6072
+ * `metadata` also carries `triggers` (validated by TriggersRule) — passthrough
6073
+ * keeps those keys intact while `upstream` gets schema-checked here.
6074
+ */
6075
+ metadata: zod.z.object({ upstream: zod.z.array(upstreamEntrySchema).optional() }).passthrough().optional()
6076
+ });
6077
+ /**
6078
+ * L2/L3 declare meaningfully elevated capability (exec, broad network) — if
6079
+ * a skill claims that tier it should also say what it needs via
6080
+ * `permissions`, otherwise the tier declaration is decorative.
6081
+ */
6082
+ function validateRiskTierNeedsPermissions(fields) {
6083
+ if ((fields.risk_tier === "L2" || fields.risk_tier === "L3") && !fields.permissions) return `risk_tier ${fields.risk_tier} should declare a permissions block`;
6084
+ return null;
6085
+ }
6086
+ //#endregion
5255
6087
  //#region src/services/validation/rules.ts
5256
6088
  /**
5257
6089
  * Enforces a maximum line count for skill files to ensure token efficiency.
5258
6090
  */
5259
6091
  var SizeRule = class {
6092
+ maxLines;
5260
6093
  name = "Size Limit";
5261
6094
  constructor(maxLines = 100) {
5262
6095
  this.maxLines = maxLines;
@@ -5276,7 +6109,15 @@ var SizeRule = class {
5276
6109
  }
5277
6110
  };
5278
6111
  /**
5279
- * Validates frontmatter presence and required fields.
6112
+ * Validates frontmatter presence, required fields, and — for any of the
6113
+ * optional Universal-Skill-Format fields a skill chooses to declare
6114
+ * (version, risk_tier, allowed-tools, permissions, content_hash, signature)
6115
+ * — their shape, via cli/src/schemas/skill-frontmatter.ts.
6116
+ *
6117
+ * Uses a real YAML parse (`yaml.load`) rather than substring `includes()`
6118
+ * checks, so e.g. a body line that happens to contain the text `name:`
6119
+ * cannot be mistaken for the frontmatter field, and a malformed YAML block
6120
+ * is reported instead of silently passing.
5280
6121
  */
5281
6122
  var FrontmatterRule = class {
5282
6123
  name = "Frontmatter";
@@ -5292,20 +6133,35 @@ var FrontmatterRule = class {
5292
6133
  errors: ["Missing or invalid frontmatter"],
5293
6134
  warnings: []
5294
6135
  };
5295
- const frontmatter = frontmatterMatch[1];
5296
- if (!frontmatter.includes("name:")) {
6136
+ let parsed;
6137
+ try {
6138
+ parsed = js_yaml.load(frontmatterMatch[1]);
6139
+ } catch {
6140
+ return {
6141
+ passed: false,
6142
+ errors: ["Invalid YAML frontmatter"],
6143
+ warnings: []
6144
+ };
6145
+ }
6146
+ const fm = parsed && typeof parsed === "object" ? parsed : {};
6147
+ if (typeof fm.name !== "string" || fm.name.length === 0) {
5297
6148
  result.errors.push("Missing \"name\" field in frontmatter");
5298
6149
  result.passed = false;
5299
6150
  }
5300
- if (!frontmatter.includes("description:")) {
6151
+ if (typeof fm.description !== "string" || fm.description.length === 0) {
5301
6152
  result.errors.push("Missing \"description\" field in frontmatter");
5302
6153
  result.passed = false;
6154
+ } else if (fm.description.length > 1024) {
6155
+ result.errors.push(`Description too long (${fm.description.length} chars > 1024 limit)`);
6156
+ result.passed = false;
6157
+ } else if (fm.description.length > 300) result.warnings.push(`Description is ${fm.description.length} chars (> 300); trim for _INDEX.md readability`);
6158
+ const optionalParse = optionalSkillFieldsSchema.safeParse(fm);
6159
+ if (!optionalParse.success) {
6160
+ for (const issue of optionalParse.error.issues) result.errors.push(`${issue.path.join(".")}: ${issue.message}`);
6161
+ result.passed = false;
5303
6162
  } else {
5304
- const descMatch = frontmatter.match(/description:\s*(.+)/);
5305
- if (descMatch && descMatch[1].length > 1024) {
5306
- result.errors.push(`Description too long (${descMatch[1].length} chars > 1024 limit)`);
5307
- result.passed = false;
5308
- } else if (descMatch && descMatch[1].length > 300) result.warnings.push(`Description is ${descMatch[1].length} chars (> 300); trim for _INDEX.md readability`);
6163
+ const tierWarning = validateRiskTierNeedsPermissions(optionalParse.data);
6164
+ if (tierWarning) result.warnings.push(tierWarning);
5309
6165
  }
5310
6166
  return result;
5311
6167
  }
@@ -5358,6 +6214,7 @@ var CANONICAL_PRIORITY_LABELS = {
5358
6214
  * is 'error', that its label matches the canonical vocabulary.
5359
6215
  */
5360
6216
  var PriorityRule = class {
6217
+ labelSeverity;
5361
6218
  name = "Priority Section";
5362
6219
  constructor(labelSeverity = "warning") {
5363
6220
  this.labelSeverity = labelSeverity;
@@ -5405,7 +6262,7 @@ var TriggersRule = class {
5405
6262
  };
5406
6263
  let parsed;
5407
6264
  try {
5408
- parsed = js_yaml.default.load(frontmatterMatch[1]);
6265
+ parsed = js_yaml.load(frontmatterMatch[1]);
5409
6266
  } catch {
5410
6267
  return {
5411
6268
  passed: false,
@@ -5443,12 +6300,15 @@ var DirectoryStructureRule = class {
5443
6300
  const scriptsDir = path.default.join(skillDir, "scripts");
5444
6301
  if (await fs_extra.default.pathExists(scriptsDir)) {
5445
6302
  const scriptFiles = await fs_extra.default.readdir(scriptsDir);
5446
- for (const file of scriptFiles) if (![
5447
- ".py",
5448
- ".js",
5449
- ".ts",
5450
- ".sh"
5451
- ].includes(path.default.extname(file))) result.warnings.push(`Script without standard extension: ${file}`);
6303
+ for (const file of scriptFiles) {
6304
+ if ((await fs_extra.default.stat(path.default.join(scriptsDir, file))).isDirectory()) continue;
6305
+ if (![
6306
+ ".py",
6307
+ ".js",
6308
+ ".ts",
6309
+ ".sh"
6310
+ ].includes(path.default.extname(file))) result.warnings.push(`Script without standard extension: ${file}`);
6311
+ }
5452
6312
  }
5453
6313
  const refsDir = path.default.join(skillDir, "references");
5454
6314
  if (await fs_extra.default.pathExists(refsDir)) {
@@ -5550,7 +6410,7 @@ var SkillValidator = class {
5550
6410
  console.log(picocolors.default.green(`✅ Category metadata valid: ${category} (v${catConfig.version}, ${catConfig.tag_prefix})`));
5551
6411
  }
5552
6412
  } catch (error) {
5553
- throw new Error(`Metadata validation failed: ${error instanceof Error ? error.message : error}`);
6413
+ throw new Error(`Metadata validation failed: ${error instanceof Error ? error.message : error}`, { cause: error });
5554
6414
  }
5555
6415
  }
5556
6416
  generateSummary() {
@@ -5597,6 +6457,50 @@ var ValidateCommand = class {
5597
6457
  }
5598
6458
  };
5599
6459
  //#endregion
6460
+ //#region src/commands/verify.ts
6461
+ /**
6462
+ * Command that checks installed skill files against .skills-lock.json,
6463
+ * catching drift between what `ags sync` fetched and what's actually on
6464
+ * disk — a tampered file, a partial write, or a manual edit.
6465
+ */
6466
+ var VerifyCommand = class {
6467
+ configService;
6468
+ syncService;
6469
+ constructor(configService, syncService) {
6470
+ this.configService = configService || new ConfigService();
6471
+ this.syncService = syncService || new SyncService();
6472
+ }
6473
+ async run(options = {}) {
6474
+ const config = await this.configService.loadConfig();
6475
+ if (!config) {
6476
+ console.log(picocolors.default.red("❌ Error: .skillsrc not found. Run `init` first."));
6477
+ process.exitCode = 1;
6478
+ return;
6479
+ }
6480
+ const { agent, result } = await this.syncService.verifyLockfile(config, options.agent);
6481
+ if (!agent) {
6482
+ console.log(picocolors.default.red(`❌ ${result.missing.join(", ")}`));
6483
+ process.exitCode = 1;
6484
+ return;
6485
+ }
6486
+ console.log(picocolors.default.cyan(`🔍 Verifying skills installed for ${agent}...`));
6487
+ if (result.ok) {
6488
+ console.log(picocolors.default.green("✅ All installed skill files match the lockfile."));
6489
+ return;
6490
+ }
6491
+ if (result.missing.length > 0) {
6492
+ console.log(picocolors.default.red(`\n❌ Missing (${result.missing.length}):`));
6493
+ for (const m of result.missing) console.log(picocolors.default.red(` - ${m}`));
6494
+ }
6495
+ if (result.mismatches.length > 0) {
6496
+ console.log(picocolors.default.red(`\n❌ Content mismatch (${result.mismatches.length}):`));
6497
+ for (const m of result.mismatches) console.log(picocolors.default.red(` - ${m}`));
6498
+ }
6499
+ console.log(picocolors.default.yellow("\nRun `ags sync` to restore the registry version, or investigate why these files diverged."));
6500
+ process.exitCode = 1;
6501
+ }
6502
+ };
6503
+ //#endregion
5600
6504
  //#region src/index.ts
5601
6505
  dotenv.default.config();
5602
6506
  var _agsExecPath = process.argv[1] ?? "";
@@ -5609,6 +6513,12 @@ program.command("init").description("Initialize a .skillsrc configuration file i
5609
6513
  program.command("sync").description("Sync skills to AI Agent skill directories").option("-y, --yes", "Automatically confirm interactive prompts (e.g. update versions)").option("--snippets", "Generate JSON config snippets in ./mcp-config-snippets/; if MCP is disabled, run snippet-only mode").action(async (options) => {
5610
6514
  await new SyncCommand().run(options);
5611
6515
  });
6516
+ program.command("verify").description("Check installed skill files against .skills-lock.json (catches tampering, partial writes, or manual drift)").option("--agent <agent>", "Which agent skill directory to verify (default: first configured agent)").action(async (options) => {
6517
+ await new VerifyCommand().run(options);
6518
+ });
6519
+ program.command("audit").description("Print the installed skill inventory recorded in .skills-lock.json").action(async () => {
6520
+ await new AuditCommand().run();
6521
+ });
5612
6522
  program.command("list-skills").description("List available framework skills and detection status").option("-f, --framework <framework>", "The framework to list skills for").action(async (options) => {
5613
6523
  await new ListSkillsCommand().run(options);
5614
6524
  });
@@ -5626,8 +6536,8 @@ program.command("mcp <action> [scope]").description("Manage the optional MCP ser
5626
6536
  if (positional) merged.scope = opts.scope ?? positional;
5627
6537
  await new McpCommand().run(action, merged);
5628
6538
  });
5629
- program.command("hooks <action>").description("Manage the PreToolUse skill-loader hook. Actions: status | install | uninstall").action(async (action) => {
5630
- await new HooksCommand().run(action);
6539
+ program.command("hooks <action>").description("Manage the PreToolUse skill-loader hook. Actions: status | install | uninstall").option("--enforce", "With \"install\": Claude blocks (does not just remind) edits to SOUL.md, MEMORY.md, .env*, .ssh/, credentials*.json|yaml").action(async (action, options) => {
6540
+ await new HooksCommand().run(action, options);
5631
6541
  });
5632
6542
  program.command("evals <action>").description("Verify or display live skill-eval runs. Actions: verify | report").option("--run <runId>", "For verify: check a single run id instead of all committed runs").action(async (action, opts) => {
5633
6543
  await new EvalsCommand().run(action, opts);