agent-skills-standard 2.7.1 → 2.7.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
@@ -38,19 +38,22 @@ cross_fetch = __toESM(cross_fetch);
38
38
  let js_yaml = require("js-yaml");
39
39
  js_yaml = __toESM(js_yaml);
40
40
  let zod = require("zod");
41
+ let node_path = require("node:path");
42
+ node_path = __toESM(node_path);
43
+ let child_process = require("child_process");
41
44
  let os = require("os");
42
45
  os = __toESM(os);
46
+ let node_stream = require("node:stream");
43
47
  let node_child_process = require("node:child_process");
44
48
  let node_os = require("node:os");
45
49
  node_os = __toESM(node_os);
46
- let child_process = require("child_process");
47
50
  let fs = require("fs");
48
51
  fs = __toESM(fs);
49
52
  let crypto = require("crypto");
50
53
  crypto = __toESM(crypto);
51
54
  var package_default = {
52
55
  name: "agent-skills-standard",
53
- version: "2.7.1",
56
+ version: "2.7.3",
54
57
  description: "A CLI to manage and sync AI agent skills standard for Cursor, Claude, Copilot, Windsurf, and more.",
55
58
  main: "dist/index.js",
56
59
  bin: {
@@ -1433,6 +1436,11 @@ var McpConfigSchema = zod.z.object({
1433
1436
  scope: McpScopeSchema,
1434
1437
  prompted: zod.z.boolean(),
1435
1438
  version: zod.z.string().optional(),
1439
+ via: zod.z.enum([
1440
+ "auto",
1441
+ "npx",
1442
+ "global"
1443
+ ]).optional(),
1436
1444
  snippets: zod.z.boolean().optional()
1437
1445
  });
1438
1446
  var SkillConfigSchema = zod.z.object({
@@ -1455,10 +1463,63 @@ var SkillConfigSchema = zod.z.object({
1455
1463
  mcp: McpConfigSchema.optional(),
1456
1464
  telemetry: zod.z.boolean().optional()
1457
1465
  });
1466
+ /**
1467
+ * Known top-level runtime fields a reviewed publication may replace. Unlisted
1468
+ * (unknown/extension) root fields are always preserved verbatim.
1469
+ */
1470
+ var REVIEWED_ROOT_FIELDS = [
1471
+ "registry",
1472
+ "agents",
1473
+ "custom_overrides",
1474
+ "workflows",
1475
+ "prune",
1476
+ "workflows_ref",
1477
+ "specialists_ref",
1478
+ "telemetry",
1479
+ "skills_mode"
1480
+ ];
1481
+ /** Known MCP fields; unknown MCP extension keys survive an MCP consent update. */
1482
+ var MCP_FIELD_KEYS = [
1483
+ "enabled",
1484
+ "scope",
1485
+ "prompted",
1486
+ "version",
1487
+ "via",
1488
+ "snippets"
1489
+ ];
1458
1490
  var DEFAULT_SDLC_SUPPORT_CATEGORIES = ["quality-engineering"];
1459
1491
  var PRIMARY_CONFIG_FILE = ".skillsrc";
1460
1492
  var LEGACY_YAML_CONFIG_FILE = ".skillsrc.yaml";
1461
1493
  var CONFIG_CONFLICT_MESSAGE = "Configuration changed on disk since it was opened; reload before applying.";
1494
+ var RESERVED_SELECTION_KEYS = {
1495
+ ref: "set `ref` on the category itself, e.g. `skills.<category>.ref`",
1496
+ include: "move the allowlist under the category it applies to, e.g. `skills.<category>.include`",
1497
+ exclude: "move the exclusion list under the category it applies to, e.g. `skills.<category>.exclude`; a global `skills.exclude` is not supported"
1498
+ };
1499
+ function isReservedSelectionKey(key) {
1500
+ return Object.prototype.hasOwnProperty.call(RESERVED_SELECTION_KEYS, key);
1501
+ }
1502
+ function isLegacySectionKey(trimmed) {
1503
+ if (trimmed === "workflows:") return "workflows";
1504
+ if (trimmed === "mcp:") return "mcp";
1505
+ if (trimmed.startsWith("custom_overrides:")) return "custom_overrides";
1506
+ return null;
1507
+ }
1508
+ /**
1509
+ * Invalid configuration that names both the file and the logical key needing
1510
+ * repair. Callers surface the message verbatim rather than guessing a
1511
+ * migration or rewriting the user's document.
1512
+ */
1513
+ var ConfigValidationError = class extends Error {
1514
+ configPath;
1515
+ logicalPath;
1516
+ constructor(configPath, logicalPath, detail) {
1517
+ super(`Invalid configuration at ${configPath}: "${logicalPath}" ${detail}`);
1518
+ this.configPath = configPath;
1519
+ this.logicalPath = logicalPath;
1520
+ this.name = "ConfigValidationError";
1521
+ }
1522
+ };
1462
1523
  /**
1463
1524
  * Service for managing the `.skillsrc` configuration file.
1464
1525
  * Handles loading, saving, and initial construction of the configuration based on project metadata.
@@ -1498,12 +1559,32 @@ var ConfigService = class {
1498
1559
  if (trimmed.startsWith("- ")) return trimmed.replace(/^-\s+/, "").split(/\s+-\s+/).map((item) => item.trim()).filter(Boolean);
1499
1560
  return trimmed.split(",").map((item) => item.trim()).filter(Boolean);
1500
1561
  }
1562
+ /**
1563
+ * Parses one inline legacy value with the document YAML parser so quoted
1564
+ * scalars and flow collections keep their YAML meaning. Shapes the
1565
+ * line-oriented fallback cannot represent unambiguously are refused instead
1566
+ * of being published as raw token strings.
1567
+ */
1568
+ parseLegacyYamlValue(raw, configPath, logicalPath) {
1569
+ let parsed;
1570
+ try {
1571
+ parsed = js_yaml.load(raw);
1572
+ } catch (error) {
1573
+ throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that is not valid YAML: ${error.message}`);
1574
+ }
1575
+ if (raw.startsWith("{") || raw.startsWith("[")) {
1576
+ if (!this.isRecord(parsed) && !Array.isArray(parsed)) throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that does not parse as a YAML collection.`);
1577
+ return parsed;
1578
+ }
1579
+ if (typeof parsed === "object" && parsed !== null) throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that parses as a YAML collection without flow braces; quote it or use {..}/[..].`);
1580
+ return parsed;
1581
+ }
1501
1582
  parseLegacyBoolean(value) {
1502
1583
  const normalized = value.trim().toLowerCase();
1503
1584
  if (normalized === "true") return true;
1504
1585
  if (normalized === "false") return false;
1505
1586
  }
1506
- migrateLegacyTextConfig(content) {
1587
+ migrateLegacyTextConfig(content, configPath) {
1507
1588
  const lines = content.split(/\r?\n/);
1508
1589
  const registryLine = lines.find((line) => line.trim().startsWith("registry:"));
1509
1590
  const agentsIndex = lines.findIndex((line) => line.trim() === "agents:");
@@ -1523,35 +1604,74 @@ var ConfigService = class {
1523
1604
  agents.push(match[1].trim());
1524
1605
  }
1525
1606
  config.agents = agents;
1526
- const sectionNames = /* @__PURE__ */ new Set([
1527
- "custom_overrides",
1528
- "workflows",
1529
- "mcp"
1530
- ]);
1607
+ const skills = {};
1608
+ config.skills = skills;
1531
1609
  let currentCategory = null;
1610
+ let currentCategoryIndent = 0;
1611
+ let pendingList = null;
1532
1612
  for (let index = skillsIndex + 1; index < lines.length; index += 1) {
1533
- const trimmed = lines[index].trim();
1534
- if (!trimmed) continue;
1535
- if (trimmed === "workflows:" || trimmed === "mcp:" || trimmed.startsWith("custom_overrides:")) break;
1536
- const categoryMatch = trimmed.match(/^([A-Za-z0-9-]+):$/);
1537
- if (categoryMatch && !sectionNames.has(categoryMatch[1])) {
1538
- currentCategory = categoryMatch[1];
1539
- config.skills[currentCategory] = {};
1613
+ const line = lines[index];
1614
+ const trimmed = line.trim();
1615
+ if (!trimmed || trimmed.startsWith("#")) continue;
1616
+ const indent = line.length - line.trimStart().length;
1617
+ const sectionKey = isLegacySectionKey(trimmed);
1618
+ if (sectionKey) {
1619
+ if (currentCategory && indent > currentCategoryIndent) throw new ConfigValidationError(configPath, `skills.${currentCategory}.${sectionKey}`, "is indented as a category property but names a root section; cannot infer which it is.");
1620
+ break;
1621
+ }
1622
+ if (trimmed.startsWith("- ")) {
1623
+ const item = trimmed.slice(2).trim();
1624
+ if (!pendingList || !currentCategory) continue;
1625
+ if (indent < pendingList.indent) throw new ConfigValidationError(configPath, `skills.${currentCategory}.${pendingList.key}`, `has a list item ("${item}") indented less than the key that opened it; cannot infer membership.`);
1626
+ if (item) {
1627
+ const entry = skills[currentCategory];
1628
+ const value = this.parseLegacyYamlValue(item, configPath, `skills.${currentCategory}.${pendingList.key}`);
1629
+ if (isReservedSelectionKey(pendingList.key) && typeof value !== "string") throw new ConfigValidationError(configPath, `skills.${currentCategory}.${pendingList.key}`, `has a non-string list item ("${item}"); selection ids must be strings.`);
1630
+ const current = entry[pendingList.key];
1631
+ entry[pendingList.key] = Array.isArray(current) ? [...current, value] : [value];
1632
+ }
1540
1633
  continue;
1541
1634
  }
1542
- if (!currentCategory) continue;
1543
- const refMatch = trimmed.match(/^ref:\s*(.+)$/);
1544
- if (refMatch) {
1545
- config.skills[currentCategory].ref = refMatch[1].trim();
1635
+ const keyMatch = trimmed.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
1636
+ if (!keyMatch) continue;
1637
+ const key = keyMatch[1];
1638
+ const inlineValue = (keyMatch[2] ?? "").trim();
1639
+ const isNestedProperty = currentCategory !== null && indent > currentCategoryIndent;
1640
+ if (pendingList && indent > pendingList.indent) throw new ConfigValidationError(configPath, `skills.${currentCategory}.${pendingList.key}`, "opened a block list, but a nested key appears instead; cannot infer membership.");
1641
+ if (isReservedSelectionKey(key)) {
1642
+ if (!currentCategory || indent < currentCategoryIndent) throw new ConfigValidationError(configPath, key, `cannot infer which category this ${key} applies to. Correction: ${RESERVED_SELECTION_KEYS[key]}.`);
1643
+ pendingList = null;
1644
+ const entry = skills[currentCategory];
1645
+ if (key === "ref") {
1646
+ if (!inlineValue) throw new ConfigValidationError(configPath, `skills.${currentCategory}.ref`, "must name a git ref.");
1647
+ entry.ref = inlineValue;
1648
+ continue;
1649
+ }
1650
+ entry[key] = inlineValue ? this.parseLegacyList(inlineValue) : [];
1651
+ if (!inlineValue) pendingList = {
1652
+ key,
1653
+ indent
1654
+ };
1546
1655
  continue;
1547
1656
  }
1548
- const excludeMatch = trimmed.match(/^exclude:\s*(.+)$/);
1549
- if (excludeMatch) {
1550
- config.skills[currentCategory].exclude = this.parseLegacyList(excludeMatch[1]);
1657
+ if (isNestedProperty && currentCategory) {
1658
+ pendingList = null;
1659
+ const entry = skills[currentCategory];
1660
+ if (inlineValue) entry[key] = this.parseLegacyYamlValue(inlineValue, configPath, `skills.${currentCategory}.${key}`);
1661
+ else {
1662
+ entry[key] = null;
1663
+ pendingList = {
1664
+ key,
1665
+ indent
1666
+ };
1667
+ }
1551
1668
  continue;
1552
1669
  }
1553
- const includeMatch = trimmed.match(/^include:\s*(.+)$/);
1554
- if (includeMatch) config.skills[currentCategory].include = this.parseLegacyList(includeMatch[1]);
1670
+ if (inlineValue) throw new ConfigValidationError(configPath, `skills.${key}`, `has an inline value ("${inlineValue}") where a category entry is required. Correction: write selection keys as nested mapping entries under "skills.${key}".`);
1671
+ currentCategory = key;
1672
+ currentCategoryIndent = indent;
1673
+ pendingList = null;
1674
+ skills[currentCategory] = {};
1555
1675
  }
1556
1676
  const overridesLine = lines.find((line) => line.trim().startsWith("custom_overrides:"));
1557
1677
  if (overridesLine) {
@@ -1586,7 +1706,27 @@ var ConfigService = class {
1586
1706
  }
1587
1707
  return config;
1588
1708
  }
1589
- normalizeRawConfig(rawConfig) {
1709
+ describeValue(value) {
1710
+ if (Array.isArray(value)) return "a list";
1711
+ if (value === null) return "null";
1712
+ return `a ${typeof value}`;
1713
+ }
1714
+ /**
1715
+ * Rejects reserved selection keys and non-mapping category values before any
1716
+ * registry request, naming the exact logical key. Category names themselves
1717
+ * are never checked against a local allowlist: a project may use any category
1718
+ * the remote registry actually publishes.
1719
+ */
1720
+ assertValidSkillCategories(skills, configPath) {
1721
+ if (skills === void 0) return;
1722
+ if (!this.isRecord(skills)) throw new ConfigValidationError(configPath, "skills", `must be a mapping of category name to its selection, not ${this.describeValue(skills)}.`);
1723
+ for (const [category, value] of Object.entries(skills)) {
1724
+ if (isReservedSelectionKey(category)) throw new ConfigValidationError(configPath, `skills.${category}`, `is a reserved selection key, not a category. Correction: ${RESERVED_SELECTION_KEYS[category]}.`);
1725
+ if (value === null || value === void 0) continue;
1726
+ if (!this.isRecord(value)) throw new ConfigValidationError(configPath, `skills.${category}`, `must be a mapping of optional ref/include/exclude (plus extension keys), not ${this.describeValue(value)}.`);
1727
+ }
1728
+ }
1729
+ normalizeRawConfig(rawConfig, configPath) {
1590
1730
  if (!this.isRecord(rawConfig)) return {
1591
1731
  config: {},
1592
1732
  migrated: false
@@ -1607,10 +1747,11 @@ var ConfigService = class {
1607
1747
  }
1608
1748
  return agent;
1609
1749
  });
1750
+ this.assertValidSkillCategories(normalized.skills, configPath);
1610
1751
  if (this.isRecord(normalized.skills)) {
1611
1752
  const normalizedSkills = {};
1612
1753
  for (const [category, value] of Object.entries(normalized.skills)) {
1613
- if (!this.isRecord(value)) {
1754
+ if (value === null || value === void 0) {
1614
1755
  normalizedSkills[category] = {};
1615
1756
  migrated = true;
1616
1757
  continue;
@@ -1648,20 +1789,29 @@ var ConfigService = class {
1648
1789
  const configPath = await this.resolveConfigPath(cwd);
1649
1790
  if (!configPath) return null;
1650
1791
  const content = await fs_extra.default.readFile(configPath, "utf8");
1792
+ return this.parseConfigSource(content, configPath, cwd);
1793
+ }
1794
+ /**
1795
+ * Parses already-read bytes with the same parser as `readConfigSource`. Used
1796
+ * by reviewed publication so its baseline comes from the verified file
1797
+ * content rather than caller-held snapshot objects.
1798
+ */
1799
+ parseConfigSource(content, configPath, cwd) {
1651
1800
  let rawConfig;
1652
1801
  let migratedFromText = false;
1653
1802
  try {
1654
1803
  rawConfig = js_yaml.load(content);
1655
1804
  } catch (parseError) {
1656
- rawConfig = this.migrateLegacyTextConfig(content);
1805
+ rawConfig = this.migrateLegacyTextConfig(content, configPath);
1657
1806
  migratedFromText = rawConfig !== null;
1658
1807
  if (!migratedFromText) throw parseError;
1659
1808
  }
1660
- const { config: normalizedConfig, migrated } = this.normalizeRawConfig(rawConfig);
1809
+ const { config: normalizedConfig, migrated } = this.normalizeRawConfig(rawConfig, configPath);
1661
1810
  const parsed = SkillConfigSchema.safeParse(normalizedConfig);
1662
1811
  if (!parsed.success) throw new Error(`Invalid .skillsrc format: ${parsed.error.message}`);
1663
1812
  return {
1664
1813
  config: parsed.data,
1814
+ document: normalizedConfig,
1665
1815
  raw: this.isRecord(rawConfig) ? { ...rawConfig } : {},
1666
1816
  sourcePath: configPath,
1667
1817
  fingerprint: (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex"),
@@ -1680,10 +1830,11 @@ var ConfigService = class {
1680
1830
  try {
1681
1831
  const source = await this.readConfigSource(cwd);
1682
1832
  if (!source) return null;
1683
- if (source.migratedFromText || source.migrated || source.shouldRenameLegacyYamlFile) await fs_extra.default.outputFile(this.getPrimaryConfigPath(cwd), this.dumpConfig(source.config));
1833
+ if (source.migratedFromText || source.migrated || source.shouldRenameLegacyYamlFile) await fs_extra.default.outputFile(this.getPrimaryConfigPath(cwd), this.dumpConfig(source.document));
1684
1834
  if (source.shouldRenameLegacyYamlFile) await fs_extra.default.remove(source.sourcePath);
1685
1835
  return source.config;
1686
1836
  } catch (error) {
1837
+ if (error instanceof ConfigValidationError) throw error;
1687
1838
  throw new Error(`Failed to load config: ${error}`, { cause: error });
1688
1839
  }
1689
1840
  }
@@ -1707,28 +1858,92 @@ var ConfigService = class {
1707
1858
  * unrelated root values and category extension fields survive.
1708
1859
  */
1709
1860
  async saveSelection(snapshot, next, cwd = process.cwd()) {
1710
- const parsed = SkillConfigSchema.safeParse(next);
1711
- if (!parsed.success) throw new Error(`Invalid .skillsrc format: ${parsed.error.message}`);
1712
- const validated = parsed.data;
1861
+ return this.commitReviewedSnapshot(snapshot, next, cwd, (baseline, validated) => {
1862
+ const raw = baseline.raw;
1863
+ const merged = { ...raw };
1864
+ merged.skills = this.mergeSkillEntries(raw.skills, validated.skills);
1865
+ if (validated.skills_mode) merged.skills_mode = validated.skills_mode;
1866
+ else delete merged.skills_mode;
1867
+ return merged;
1868
+ });
1869
+ }
1870
+ /**
1871
+ * Publishes a complete reviewed configuration (accepted category/workflow/
1872
+ * specialist pins and later MCP consent updates) without dropping unknown
1873
+ * data. Changed known runtime fields are applied over the source document,
1874
+ * skill entries merge while preserving category extensions, and unknown root
1875
+ * fields plus unknown MCP extension fields survive.
1876
+ */
1877
+ async saveReviewedConfig(snapshot, next, cwd = process.cwd()) {
1878
+ return this.commitReviewedSnapshot(snapshot, next, cwd, (baseline, validated, configPath) => this.buildReviewedDocument(baseline.config, baseline.raw, validated, configPath));
1879
+ }
1880
+ /**
1881
+ * Shared publication: validate, verify the snapshot still matches the file
1882
+ * bytes, re-parse those bytes as the only baseline, build and validate the
1883
+ * normalized document, then write. Any failure refuses before the filesystem
1884
+ * is touched. `snapshot.config`/`snapshot.raw` are never used as a baseline
1885
+ * because callers can mutate them in place.
1886
+ */
1887
+ async commitReviewedSnapshot(snapshot, next, cwd, buildDocument) {
1888
+ this.assertValidSkillCategories(next.skills, snapshot.sourcePath);
1889
+ const parsedNext = SkillConfigSchema.safeParse(next);
1890
+ if (!parsedNext.success) throw new Error(`Invalid .skillsrc format: ${parsedNext.error.message}`);
1891
+ const validated = parsedNext.data;
1713
1892
  if (await this.resolveConfigPath(cwd) !== snapshot.sourcePath) throw new Error(CONFIG_CONFLICT_MESSAGE);
1714
1893
  const currentContent = await fs_extra.default.readFile(snapshot.sourcePath, "utf8");
1715
1894
  if ((0, node_crypto.createHash)("sha256").update(currentContent, "utf8").digest("hex") !== snapshot.fingerprint) throw new Error(CONFIG_CONFLICT_MESSAGE);
1716
- const merged = { ...snapshot.raw };
1717
- merged.skills = this.mergeSkillEntries(snapshot.raw.skills, validated.skills);
1718
- if (validated.skills_mode) merged.skills_mode = validated.skills_mode;
1719
- else delete merged.skills_mode;
1895
+ const baseline = this.parseConfigSource(currentContent, snapshot.sourcePath, cwd);
1720
1896
  const primaryConfigPath = this.getPrimaryConfigPath(cwd);
1897
+ const merged = buildDocument(baseline, validated, primaryConfigPath);
1898
+ const { config: normalizedDocument } = this.normalizeRawConfig(merged, primaryConfigPath);
1899
+ const persisted = SkillConfigSchema.safeParse(normalizedDocument);
1900
+ if (!persisted.success) throw new Error(`Invalid .skillsrc format: ${persisted.error.message}`);
1721
1901
  const content = this.dumpConfig(merged);
1722
1902
  await fs_extra.default.outputFile(primaryConfigPath, content);
1723
1903
  const legacyYamlPath = this.getLegacyYamlConfigPath(cwd);
1724
1904
  if (snapshot.sourcePath === legacyYamlPath && await fs_extra.default.pathExists(legacyYamlPath)) await fs_extra.default.remove(legacyYamlPath);
1725
1905
  return {
1726
- config: validated,
1906
+ config: persisted.data,
1727
1907
  raw: merged,
1728
1908
  sourcePath: primaryConfigPath,
1729
1909
  fingerprint: (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex")
1730
1910
  };
1731
1911
  }
1912
+ buildReviewedDocument(previous, raw, next, configPath) {
1913
+ const merged = { ...raw };
1914
+ merged.skills = this.mergeSkillEntries(raw.skills, next.skills);
1915
+ const previousFields = previous;
1916
+ const nextFields = next;
1917
+ for (const field of REVIEWED_ROOT_FIELDS) {
1918
+ if (this.valuesEqual(nextFields[field], previousFields[field])) continue;
1919
+ if (nextFields[field] === void 0) delete merged[field];
1920
+ else merged[field] = nextFields[field];
1921
+ }
1922
+ if (!this.valuesEqual(next.mcp, previous.mcp)) {
1923
+ if (next.mcp === void 0) {
1924
+ const unknownKeys = this.isRecord(raw.mcp) ? Object.keys(raw.mcp).filter((key) => !MCP_FIELD_KEYS.includes(key)) : [];
1925
+ if (unknownKeys.length > 0) throw new ConfigValidationError(configPath, "mcp", `cannot be removed because it contains unrecognized extension keys (${unknownKeys.join(", ")}); remove them explicitly first.`);
1926
+ delete merged.mcp;
1927
+ } else {
1928
+ const mcp = { ...this.isRecord(raw.mcp) ? raw.mcp : {} };
1929
+ const nextMcp = next.mcp;
1930
+ for (const key of MCP_FIELD_KEYS) if (Object.prototype.hasOwnProperty.call(nextMcp, key)) mcp[key] = nextMcp[key];
1931
+ else delete mcp[key];
1932
+ merged.mcp = mcp;
1933
+ }
1934
+ }
1935
+ return merged;
1936
+ }
1937
+ valuesEqual(left, right) {
1938
+ if (left === right) return true;
1939
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => this.valuesEqual(value, right[index]));
1940
+ if (this.isRecord(left) && this.isRecord(right)) {
1941
+ const leftKeys = Object.keys(left);
1942
+ const rightKeys = Object.keys(right);
1943
+ return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && this.valuesEqual(left[key], right[key]));
1944
+ }
1945
+ return false;
1946
+ }
1732
1947
  mergeSkillEntries(rawSkills, nextSkills) {
1733
1948
  const existing = this.isRecord(rawSkills) ? rawSkills : {};
1734
1949
  const merged = {};
@@ -1752,6 +1967,7 @@ var ConfigService = class {
1752
1967
  async saveConfig(config, cwd = process.cwd()) {
1753
1968
  const primaryConfigPath = this.getPrimaryConfigPath(cwd);
1754
1969
  const legacyYamlPath = this.getLegacyYamlConfigPath(cwd);
1970
+ this.assertValidSkillCategories(config.skills, primaryConfigPath);
1755
1971
  await fs_extra.default.outputFile(primaryConfigPath, this.dumpConfig(config));
1756
1972
  if (legacyYamlPath !== primaryConfigPath && await fs_extra.default.pathExists(legacyYamlPath)) await fs_extra.default.remove(legacyYamlPath);
1757
1973
  }
@@ -2606,8 +2822,485 @@ var HookService = class {
2606
2822
  }
2607
2823
  };
2608
2824
  //#endregion
2825
+ //#region src/services/install/safePath.ts
2826
+ /**
2827
+ * Asserts that a target file path resides inside rootDir and that NO intermediate
2828
+ * directory or terminal file under rootDir is a symbolic link.
2829
+ *
2830
+ * Requirements:
2831
+ * - rootDir may itself be a symlink (or inside one); components under rootDir must NOT be.
2832
+ * - Both existing files and dangling symlinks must be detected via lstat.
2833
+ * - Fails closed if lexical containment is breached or if any component under rootDir is a symlink.
2834
+ *
2835
+ * @param rootDir The trusted root directory
2836
+ * @param targetPath The destination file/directory path to check
2837
+ */
2838
+ async function assertNoSymlinksUnderRoot(rootDir, targetPath) {
2839
+ const resolvedRoot = path.default.resolve(rootDir);
2840
+ const resolvedTarget = path.default.resolve(targetPath);
2841
+ const relative = path.default.relative(resolvedRoot, resolvedTarget);
2842
+ if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new Error(`Path traversal or outside target detected: "${targetPath}" is outside "${rootDir}"`);
2843
+ const segments = relative.split(path.default.sep).filter(Boolean);
2844
+ let current = resolvedRoot;
2845
+ for (const segment of segments) {
2846
+ current = path.default.join(current, segment);
2847
+ try {
2848
+ if ((await fs_extra.default.lstat(current)).isSymbolicLink()) throw new Error(`Symlink path component not permitted: "${current}" is a symbolic link under "${rootDir}"`);
2849
+ } catch (err) {
2850
+ if (err instanceof Error && err.message.includes("Symlink path component")) throw err;
2851
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") break;
2852
+ throw err;
2853
+ }
2854
+ }
2855
+ }
2856
+ var DIGEST_RE$1 = /^[0-9a-f]{64}$/;
2857
+ var GIT_BLOB_SHA1_RE$1 = /^[0-9a-f]{40}$/;
2858
+ var COMMIT_RE = /^[0-9a-f]{40}$/;
2859
+ var PACKAGE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
2860
+ var CATEGORY_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2861
+ var ATTRIBUTION_FILE_NAME_RE$1 = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
2862
+ function isBoundedRelativePath$1(value) {
2863
+ if (typeof value !== "string" || value.length === 0) return false;
2864
+ if (value.includes("\0") || value.includes("\\")) return false;
2865
+ if (node_path.default.posix.isAbsolute(value) || node_path.default.isAbsolute(value)) return false;
2866
+ if (node_path.default.posix.normalize(value) !== value) return false;
2867
+ return !value.split("/").includes("..");
2868
+ }
2869
+ function isBoundedAttributionFileName(value) {
2870
+ return typeof value === "string" && ATTRIBUTION_FILE_NAME_RE$1.test(value);
2871
+ }
2872
+ function fail$1(where, message) {
2873
+ throw new Error(`Invalid .skills-lock.json attributionCatalog: ${where} ${message}`);
2874
+ }
2875
+ function assertExactKeys(where, value, keys) {
2876
+ const allowed = new Set(keys);
2877
+ for (const key of Object.keys(value)) if (!allowed.has(key)) fail$1(where, `has unexpected field '${key}'`);
2878
+ }
2879
+ function assertString(where, field, value) {
2880
+ if (typeof value !== "string" || value.length === 0) fail$1(where, `has an invalid ${field}`);
2881
+ return value;
2882
+ }
2883
+ function assertRecord(where, value) {
2884
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail$1(where, "must be an object");
2885
+ return value;
2886
+ }
2887
+ function assertDigest(where, field, value) {
2888
+ if (typeof value !== "string" || !DIGEST_RE$1.test(value)) fail$1(where, `has an invalid ${field}`);
2889
+ return value;
2890
+ }
2891
+ function assertCommit(where, field, value) {
2892
+ if (typeof value !== "string" || !COMMIT_RE.test(value)) fail$1(where, `has an invalid ${field} (expected a resolved commit)`);
2893
+ return value;
2894
+ }
2895
+ function assertBlobSha1(where, value) {
2896
+ if (typeof value !== "string" || !GIT_BLOB_SHA1_RE$1.test(value)) fail$1(where, "has an invalid blobSha1 (expected a Git blob SHA-1, not a truncated SHA-256)");
2897
+ return value;
2898
+ }
2899
+ function parseDestination(where, value) {
2900
+ const raw = assertRecord(where, value);
2901
+ assertExactKeys(where, raw, [
2902
+ "agent",
2903
+ "path",
2904
+ "sha256"
2905
+ ]);
2906
+ const agent = assertString(where, "destination agent", raw.agent);
2907
+ const destinationPath = assertString(where, "destination path", raw.path);
2908
+ if (!isBoundedRelativePath$1(destinationPath)) fail$1(where, "has an unsafe destination path");
2909
+ return {
2910
+ agent,
2911
+ path: destinationPath,
2912
+ sha256: assertDigest(where, "destination sha256", raw.sha256)
2913
+ };
2914
+ }
2915
+ function parseFile(where, value) {
2916
+ const raw = assertRecord(where, value);
2917
+ assertExactKeys(where, raw, [
2918
+ "name",
2919
+ "sourcePath",
2920
+ "sourceKind",
2921
+ "requestedRef",
2922
+ "resolvedCommit",
2923
+ "blobSha1",
2924
+ "sha256",
2925
+ "basis",
2926
+ "destinations"
2927
+ ]);
2928
+ const name = raw.name;
2929
+ if (!isBoundedAttributionFileName(name)) fail$1(where, "has an invalid installed filename");
2930
+ const sourcePath = assertString(where, "sourcePath", raw.sourcePath);
2931
+ if (!isBoundedRelativePath$1(sourcePath)) fail$1(where, "has an unsafe sourcePath");
2932
+ if (raw.sourceKind !== "package" && raw.sourceKind !== "root") fail$1(where, "has an invalid sourceKind");
2933
+ if (raw.basis !== "release-manifest" && raw.basis !== "commit-tree-blob") fail$1(where, "has an invalid verification basis");
2934
+ if (!Array.isArray(raw.destinations) || raw.destinations.length === 0) fail$1(where, "has no published destinations");
2935
+ const destinations = raw.destinations.map((binding, index) => parseDestination(`${where} destination[${index}]`, binding));
2936
+ return {
2937
+ name,
2938
+ sourcePath,
2939
+ sourceKind: raw.sourceKind,
2940
+ requestedRef: assertString(where, "requestedRef", raw.requestedRef),
2941
+ resolvedCommit: assertCommit(where, "resolvedCommit", raw.resolvedCommit),
2942
+ blobSha1: assertBlobSha1(where, raw.blobSha1),
2943
+ sha256: assertDigest(where, "sha256", raw.sha256),
2944
+ basis: raw.basis,
2945
+ destinations
2946
+ };
2947
+ }
2948
+ function parsePackage(where, value) {
2949
+ const raw = assertRecord(where, value);
2950
+ assertExactKeys(where, raw, [
2951
+ "registry",
2952
+ "declaringCategory",
2953
+ "requestedRef",
2954
+ "resolvedCommit",
2955
+ "files"
2956
+ ]);
2957
+ if (typeof raw.declaringCategory !== "string" || !CATEGORY_TOKEN_RE.test(raw.declaringCategory)) fail$1(where, "has an invalid declaringCategory");
2958
+ if (!Array.isArray(raw.files) || raw.files.length === 0) fail$1(where, "has no attribution files");
2959
+ const fileNames = /* @__PURE__ */ new Set();
2960
+ const files = raw.files.map((file, index) => {
2961
+ const parsed = parseFile(`${where} files[${index}]`, file);
2962
+ if (fileNameKey(parsed.name, fileNames)) fail$1(where, `has a duplicate installed filename '${parsed.name}'`);
2963
+ return parsed;
2964
+ });
2965
+ return {
2966
+ registry: assertString(where, "registry", raw.registry),
2967
+ declaringCategory: raw.declaringCategory,
2968
+ requestedRef: assertString(where, "requestedRef", raw.requestedRef),
2969
+ resolvedCommit: assertCommit(where, "resolvedCommit", raw.resolvedCommit),
2970
+ files
2971
+ };
2972
+ }
2973
+ function fileNameKey(name, seen) {
2974
+ const key = name.toLowerCase();
2975
+ if (seen.has(key)) return true;
2976
+ seen.add(key);
2977
+ return false;
2978
+ }
2979
+ /**
2980
+ * Validates and narrows the optional attribution catalog. Unknown versions and
2981
+ * malformed qualified records fail closed so no caller can republish them.
2982
+ */
2983
+ function validateAttributionCatalog(catalog) {
2984
+ const raw = assertRecord("root", catalog);
2985
+ assertExactKeys("root", raw, ["version", "packages"]);
2986
+ if (raw.version !== 1) fail$1("root", `has unsupported version ${String(raw.version)} (this CLI only reads version 1)`);
2987
+ const rawPackages = assertRecord("packages", raw.packages);
2988
+ const packages = {};
2989
+ for (const [packageId, record] of Object.entries(rawPackages)) {
2990
+ if (!PACKAGE_ID_RE.test(packageId)) fail$1(`package '${packageId}'`, "is not a bounded category/skill key");
2991
+ packages[packageId] = parsePackage(`package '${packageId}'`, record);
2992
+ }
2993
+ return {
2994
+ version: 1,
2995
+ packages
2996
+ };
2997
+ }
2998
+ function sameOrigin(left, right) {
2999
+ return left.sourcePath === right.sourcePath && left.sourceKind === right.sourceKind && left.requestedRef === right.requestedRef && left.resolvedCommit === right.resolvedCommit && left.blobSha1 === right.blobSha1 && left.sha256 === right.sha256 && left.basis === right.basis;
3000
+ }
3001
+ /**
3002
+ * A previous record is trustworthy only when every recorded destination
3003
+ * exists and its own bytes still match its own recorded digest. A pooled
3004
+ * digest anywhere is not evidence: one intact copy never qualifies an edited
3005
+ * or missing sibling, and digests swapped between agents never match.
3006
+ */
3007
+ function qualifiesAgainstInstalled(previous, installed) {
3008
+ if (!installed || installed.length === 0) return false;
3009
+ return previous.destinations.every((recorded) => {
3010
+ const actual = installed.find((candidate) => candidate.agent === recorded.agent && candidate.path === recorded.path);
3011
+ return actual !== void 0 && actual.sha256 !== null && actual.sha256 === recorded.sha256;
3012
+ });
3013
+ }
3014
+ function compareFiles(previous, planned, unqualifiedNames, originMoved) {
3015
+ const previousByName = new Map(previous.files.map((file) => [file.name, file]));
3016
+ const plannedByName = new Map(planned.files.map((file) => [file.name, file]));
3017
+ const names = [.../* @__PURE__ */ new Set([...previousByName.keys(), ...plannedByName.keys()])].sort();
3018
+ const files = [];
3019
+ let sawAdded = false;
3020
+ let sawRemoved = false;
3021
+ let sawBytesChanged = false;
3022
+ let sawOriginChanged = false;
3023
+ for (const name of names) {
3024
+ const oldFile = previousByName.get(name) ?? null;
3025
+ const newFile = plannedByName.get(name) ?? null;
3026
+ const trustworthyOld = (oldFile ? unqualifiedNames.has(name) : false) ? null : oldFile;
3027
+ if (!oldFile && newFile) {
3028
+ sawAdded = true;
3029
+ files.push({
3030
+ name,
3031
+ status: "added",
3032
+ previousSha256: null,
3033
+ plannedSha256: newFile.sha256,
3034
+ previous: null,
3035
+ planned: newFile
3036
+ });
3037
+ continue;
3038
+ }
3039
+ if (oldFile && !newFile) {
3040
+ sawRemoved = true;
3041
+ files.push({
3042
+ name,
3043
+ status: "removed",
3044
+ previousSha256: oldFile.sha256,
3045
+ plannedSha256: null,
3046
+ previous: trustworthyOld,
3047
+ planned: null
3048
+ });
3049
+ continue;
3050
+ }
3051
+ if (oldFile && newFile) {
3052
+ if (oldFile.sha256 !== newFile.sha256) {
3053
+ sawBytesChanged = true;
3054
+ files.push({
3055
+ name,
3056
+ status: "changed",
3057
+ previousSha256: oldFile.sha256,
3058
+ plannedSha256: newFile.sha256,
3059
+ previous: trustworthyOld,
3060
+ planned: newFile
3061
+ });
3062
+ continue;
3063
+ }
3064
+ if (!sameOrigin(oldFile, newFile) || originMoved) {
3065
+ sawOriginChanged = true;
3066
+ files.push({
3067
+ name,
3068
+ status: "origin-changed",
3069
+ previousSha256: oldFile.sha256,
3070
+ plannedSha256: newFile.sha256,
3071
+ previous: trustworthyOld,
3072
+ planned: newFile
3073
+ });
3074
+ continue;
3075
+ }
3076
+ files.push({
3077
+ name,
3078
+ status: "unchanged",
3079
+ previousSha256: oldFile.sha256,
3080
+ plannedSha256: newFile.sha256,
3081
+ previous: trustworthyOld,
3082
+ planned: newFile
3083
+ });
3084
+ }
3085
+ }
3086
+ return {
3087
+ files,
3088
+ sawAdded,
3089
+ sawRemoved,
3090
+ sawBytesChanged,
3091
+ sawOriginChanged
3092
+ };
3093
+ }
3094
+ /**
3095
+ * Pure comparison of qualified attribution metadata. No writes, no prompts:
3096
+ * the command layer owns consent, publication and exit behavior.
3097
+ */
3098
+ function compareAttribution(input) {
3099
+ const previousPackages = input.previousCatalog?.packages ?? {};
3100
+ const packageIds = [.../* @__PURE__ */ new Set([...Object.keys(previousPackages), ...Object.keys(input.planned)])].sort();
3101
+ const packages = [];
3102
+ const legacyUnqualified = [];
3103
+ const disclosures = [];
3104
+ let requiresConsent = false;
3105
+ for (const packageId of packageIds) {
3106
+ const previous = previousPackages[packageId] ?? null;
3107
+ const planned = input.planned[packageId] ?? null;
3108
+ if (!input.hasExistingInstallation) {
3109
+ if (!planned) continue;
3110
+ packages.push({
3111
+ packageId,
3112
+ status: "added",
3113
+ reason: "first-install",
3114
+ previous: null,
3115
+ planned,
3116
+ files: planned.files.map((file) => ({
3117
+ name: file.name,
3118
+ status: "added",
3119
+ previousSha256: null,
3120
+ plannedSha256: file.sha256,
3121
+ previous: null,
3122
+ planned: file
3123
+ })),
3124
+ requiresConsent: false
3125
+ });
3126
+ disclosures.push(planned);
3127
+ continue;
3128
+ }
3129
+ if (!previous) {
3130
+ if (!planned) continue;
3131
+ const installed = input.installed?.[packageId];
3132
+ if (Object.values(installed ?? {}).some((bindings) => bindings.some((binding) => binding.sha256 !== null))) {
3133
+ legacyUnqualified.push(packageId);
3134
+ requiresConsent = true;
3135
+ packages.push({
3136
+ packageId,
3137
+ status: "legacy-unqualified",
3138
+ reason: "unqualified-origin",
3139
+ previous: null,
3140
+ planned,
3141
+ files: planned.files.map((file) => ({
3142
+ name: file.name,
3143
+ status: "added",
3144
+ previousSha256: null,
3145
+ plannedSha256: file.sha256,
3146
+ previous: null,
3147
+ planned: file
3148
+ })),
3149
+ requiresConsent: true
3150
+ });
3151
+ continue;
3152
+ }
3153
+ requiresConsent = true;
3154
+ packages.push({
3155
+ packageId,
3156
+ status: "added",
3157
+ reason: "added",
3158
+ previous: null,
3159
+ planned,
3160
+ files: planned.files.map((file) => ({
3161
+ name: file.name,
3162
+ status: "added",
3163
+ previousSha256: null,
3164
+ plannedSha256: file.sha256,
3165
+ previous: null,
3166
+ planned: file
3167
+ })),
3168
+ requiresConsent: true
3169
+ });
3170
+ continue;
3171
+ }
3172
+ const installed = input.installed?.[packageId];
3173
+ const unqualifiedNames = new Set(previous.files.filter((file) => !qualifiesAgainstInstalled(file, installed?.[file.name])).map((file) => file.name));
3174
+ if (!planned) {
3175
+ requiresConsent = true;
3176
+ packages.push({
3177
+ packageId,
3178
+ status: "removed",
3179
+ reason: "removed",
3180
+ previous: unqualifiedNames.size > 0 ? null : previous,
3181
+ planned: null,
3182
+ files: previous.files.map((file) => ({
3183
+ name: file.name,
3184
+ status: "removed",
3185
+ previousSha256: file.sha256,
3186
+ plannedSha256: null,
3187
+ previous: unqualifiedNames.has(file.name) ? null : file,
3188
+ planned: null
3189
+ })),
3190
+ requiresConsent: true
3191
+ });
3192
+ continue;
3193
+ }
3194
+ const compared = compareFiles(previous, planned, unqualifiedNames, previous.registry !== planned.registry || previous.declaringCategory !== planned.declaringCategory || previous.requestedRef !== planned.requestedRef || previous.resolvedCommit !== planned.resolvedCommit);
3195
+ if (unqualifiedNames.size > 0) {
3196
+ legacyUnqualified.push(packageId);
3197
+ requiresConsent = true;
3198
+ packages.push({
3199
+ packageId,
3200
+ status: "legacy-unqualified",
3201
+ reason: installed && Object.keys(installed).length > 0 ? "installed-bytes-mismatch" : "unqualified-origin",
3202
+ previous: null,
3203
+ planned,
3204
+ files: compared.files,
3205
+ requiresConsent: true
3206
+ });
3207
+ continue;
3208
+ }
3209
+ let reason = "unchanged";
3210
+ let status = "unchanged";
3211
+ if (compared.sawRemoved) {
3212
+ reason = "removed";
3213
+ status = "changed";
3214
+ } else if (compared.sawAdded) {
3215
+ reason = "added";
3216
+ status = "changed";
3217
+ } else if (compared.sawBytesChanged) {
3218
+ reason = "changed";
3219
+ status = "changed";
3220
+ } else if (compared.sawOriginChanged) {
3221
+ reason = "origin-changed";
3222
+ status = "origin-changed";
3223
+ }
3224
+ if (status !== "unchanged") requiresConsent = true;
3225
+ packages.push({
3226
+ packageId,
3227
+ status,
3228
+ reason,
3229
+ previous,
3230
+ planned,
3231
+ files: compared.files,
3232
+ requiresConsent: status !== "unchanged"
3233
+ });
3234
+ }
3235
+ return {
3236
+ firstInstall: !input.hasExistingInstallation,
3237
+ requiresConsent,
3238
+ packages,
3239
+ legacyUnqualified,
3240
+ disclosures
3241
+ };
3242
+ }
3243
+ /**
3244
+ * Digest of an installed attribution file, or null when it cannot be read
3245
+ * inside the project root. The shared install path boundary rejects traversal
3246
+ * and any symlink component (terminal, parent, or dangling) before the bytes
3247
+ * are read, so external bytes can never qualify a record.
3248
+ */
3249
+ async function readInstalledAttributionDigest(rootDir, relativePath) {
3250
+ try {
3251
+ const absPath = node_path.default.join(rootDir, relativePath);
3252
+ await assertNoSymlinksUnderRoot(rootDir, absPath);
3253
+ const bytes = await fs_extra.default.readFile(absPath);
3254
+ return (0, node_crypto.createHash)("sha256").update(bytes).digest("hex");
3255
+ } catch {
3256
+ return null;
3257
+ }
3258
+ }
3259
+ /**
3260
+ * Reads the bytes actually installed at every recorded destination, preserving
3261
+ * agent/path identity. A missing or unsafe destination is reported with
3262
+ * `sha256: null`, which the comparison treats as unqualified rather than as
3263
+ * agreement.
3264
+ */
3265
+ async function collectInstalledAttributionDigests(rootDir, packages) {
3266
+ const state = {};
3267
+ for (const [packageId, record] of Object.entries(packages)) {
3268
+ const perFile = {};
3269
+ for (const file of record.files) {
3270
+ perFile[file.name] = [];
3271
+ for (const binding of file.destinations) perFile[file.name].push({
3272
+ agent: binding.agent,
3273
+ path: binding.path,
3274
+ sha256: await readInstalledAttributionDigest(rootDir, binding.path)
3275
+ });
3276
+ }
3277
+ state[packageId] = perFile;
3278
+ }
3279
+ return state;
3280
+ }
3281
+ function buildAttributionCatalog(packages) {
3282
+ const sorted = {};
3283
+ for (const packageId of Object.keys(packages).sort()) sorted[packageId] = packages[packageId];
3284
+ return {
3285
+ version: 1,
3286
+ packages: sorted
3287
+ };
3288
+ }
3289
+ //#endregion
2609
3290
  //#region src/services/LockfileService.ts
2610
3291
  var LOCKFILE_NAME = ".skills-lock.json";
3292
+ /** Top-level names this writer owns; anything else is preserved verbatim. */
3293
+ var KNOWN_LOCK_KEYS = {
3294
+ version: true,
3295
+ registry: true,
3296
+ generatedAt: true,
3297
+ sources: true,
3298
+ entries: true,
3299
+ generation: true,
3300
+ disclosed: true,
3301
+ workflowCatalog: true,
3302
+ attributionCatalog: true
3303
+ };
2611
3304
  /**
2612
3305
  * Reserved ownership agent for the shared canonical workflow body installed at
2613
3306
  * `.agents/workflows/*.md`. It is not a provider and must never be added to the
@@ -2647,7 +3340,7 @@ function parseGenerationLedger(value) {
2647
3340
  entries
2648
3341
  };
2649
3342
  }
2650
- function sha256(content) {
3343
+ function sha256$1(content) {
2651
3344
  return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
2652
3345
  }
2653
3346
  /**
@@ -2687,7 +3380,8 @@ var lockV2Schema = zod.z.object({
2687
3380
  sha256: zod.z.string()
2688
3381
  })),
2689
3382
  disclosed: zod.z.record(zod.z.string(), zod.z.array(zod.z.string())).optional(),
2690
- workflowCatalog: zod.z.unknown().optional()
3383
+ workflowCatalog: zod.z.unknown().optional(),
3384
+ attributionCatalog: zod.z.unknown().optional()
2691
3385
  }).passthrough();
2692
3386
  var lockVersionProbe = zod.z.object({ version: zod.z.unknown() });
2693
3387
  /**
@@ -2701,12 +3395,13 @@ function parseLockV2(raw) {
2701
3395
  const where = issue?.path.length ? issue.path.join(".") : "root";
2702
3396
  throw new Error(`Invalid .skills-lock.json: ${where} ${issue?.message ?? "is invalid"}`);
2703
3397
  }
2704
- const { workflowCatalog: rawCatalog, ...rest } = parsed.data;
3398
+ const { workflowCatalog: rawCatalog, attributionCatalog: rawAttribution, ...rest } = parsed.data;
2705
3399
  const generation = parseGenerationLedger(raw.generation);
2706
3400
  return {
2707
3401
  ...rest,
2708
3402
  ...generation ? { generation } : {},
2709
- ...rawCatalog === void 0 ? {} : { workflowCatalog: validateWorkflowCatalog(rawCatalog) }
3403
+ ...rawCatalog === void 0 ? {} : { workflowCatalog: validateWorkflowCatalog(rawCatalog) },
3404
+ ...rawAttribution === void 0 ? {} : { attributionCatalog: validateAttributionCatalog(rawAttribution) }
2710
3405
  };
2711
3406
  }
2712
3407
  var DIGEST_RE = /^[a-f0-9]{64}$/;
@@ -2835,21 +3530,27 @@ var LockfileService = class {
2835
3530
  async write(rootDir, lock) {
2836
3531
  const file = this.lockfilePath(rootDir);
2837
3532
  if (lock.workflowCatalog !== void 0) validateWorkflowCatalog(lock.workflowCatalog);
3533
+ if (lock.attributionCatalog !== void 0) validateAttributionCatalog(lock.attributionCatalog);
3534
+ let existingRaw;
2838
3535
  let existingDisclosed;
2839
3536
  let existingGeneration;
3537
+ let existingAttribution;
2840
3538
  if (await fs_extra.default.pathExists(file)) {
2841
- let raw;
2842
3539
  try {
2843
- raw = await readJsonStrict(file);
3540
+ existingRaw = await readJsonStrict(file);
2844
3541
  } catch {}
3542
+ const raw = existingRaw;
2845
3543
  if (raw) {
2846
3544
  existingDisclosed = raw.disclosed;
2847
- if (raw.version === 2) existingGeneration = parseGenerationLedger(raw.generation);
2848
- else if (raw.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
3545
+ if (raw.version === 2) {
3546
+ existingGeneration = parseGenerationLedger(raw.generation);
3547
+ if (raw.attributionCatalog !== void 0) existingAttribution = validateAttributionCatalog(raw.attributionCatalog);
3548
+ } else if (raw.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
2849
3549
  }
2850
3550
  }
2851
3551
  const generation = lock.generation === void 0 ? existingGeneration : parseGenerationLedger(lock.generation);
2852
3552
  const disclosed = lock.disclosed ?? existingDisclosed;
3553
+ const attribution = lock.attributionCatalog === void 0 ? existingAttribution : lock.attributionCatalog;
2853
3554
  const sortedEntries = {};
2854
3555
  for (const k of Object.keys(lock.entries).sort()) sortedEntries[k] = lock.entries[k];
2855
3556
  const sortedGenerationEntries = {};
@@ -2861,10 +3562,16 @@ var LockfileService = class {
2861
3562
  version: 1,
2862
3563
  entries: sortedGenerationEntries
2863
3564
  } } : {},
2864
- ...disclosed ? { disclosed } : {}
3565
+ ...disclosed ? { disclosed } : {},
3566
+ ...attribution ? { attributionCatalog: attribution } : {}
2865
3567
  };
2866
3568
  if (!generation) delete out.generation;
2867
3569
  if (!disclosed) delete out.disclosed;
3570
+ if (!attribution) delete out.attributionCatalog;
3571
+ for (const [key, value] of Object.entries(existingRaw ?? {})) {
3572
+ if (KNOWN_LOCK_KEYS[key] || key in out) continue;
3573
+ out[key] = value;
3574
+ }
2868
3575
  await this.publishAtomic(file, out);
2869
3576
  }
2870
3577
  /**
@@ -2894,6 +3601,7 @@ var LockfileService = class {
2894
3601
  if (raw?.version === 2) parseGenerationLedger(raw.generation);
2895
3602
  else if (raw?.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
2896
3603
  if (raw.workflowCatalog !== void 0) validateWorkflowCatalog(raw.workflowCatalog);
3604
+ if (raw.attributionCatalog !== void 0) validateAttributionCatalog(raw.attributionCatalog);
2897
3605
  await this.publishAtomic(file, {
2898
3606
  ...raw,
2899
3607
  disclosed
@@ -2909,7 +3617,7 @@ var LockfileService = class {
2909
3617
  missing.push(rel);
2910
3618
  continue;
2911
3619
  }
2912
- if (sha256(await fs_extra.default.readFile(absPath)) !== entry.sha256) mismatches.push(rel);
3620
+ if (sha256$1(await fs_extra.default.readFile(absPath)) !== entry.sha256) mismatches.push(rel);
2913
3621
  }
2914
3622
  return {
2915
3623
  ok: mismatches.length === 0 && missing.length === 0,
@@ -2927,11 +3635,10 @@ var LockfileService = class {
2927
3635
  * agent-skills-standard-mcp`, which would silently pull whatever is latest
2928
3636
  * on npm at the moment the agent starts.
2929
3637
  *
2930
- * Bump this alongside mcp/package.json's version when releasing — there is
2931
- * no automated sync between the two (mirrors how skills/metadata.json
2932
- * category versions are maintained today).
3638
+ * Bumped automatically by `scripts/release-mcp.ts` when the MCP package is
3639
+ * released and gated in CI, so this pin always matches `mcp/package.json`.
2933
3640
  */
2934
- var MCP_COMPATIBLE_VERSION = "0.6.1";
3641
+ var MCP_COMPATIBLE_VERSION = "0.8.0";
2935
3642
  /**
2936
3643
  * Minimum `agent-skills-standard-mcp` version required for session retrospectives.
2937
3644
  */
@@ -2953,6 +3660,10 @@ function escapeTomlString(value) {
2953
3660
  }
2954
3661
  //#endregion
2955
3662
  //#region src/services/utils/codexTomlMcp.ts
3663
+ /** Structural equality for the server entry we generate and re-read. */
3664
+ function sameMcpEntry(a, b) {
3665
+ return a.command === b.command && a.args.length === b.args.length && a.args.every((arg, index) => arg === b.args[index]);
3666
+ }
2956
3667
  var HEADER_RE = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(#.*)?$/;
2957
3668
  function normalizeKey(raw) {
2958
3669
  return raw.split(".").map((s) => s.trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")).join(".");
@@ -2990,6 +3701,61 @@ function renderTomlMcpServer(name, entry) {
2990
3701
  function hasTomlMcpServer(content, name) {
2991
3702
  return findOurRanges(content.split("\n"), name).length > 0;
2992
3703
  }
3704
+ function parseTomlStringLiteral(raw) {
3705
+ const value = raw.trim();
3706
+ if (value.length < 2) return null;
3707
+ if (value.startsWith("\"") && value.endsWith("\"")) return value.slice(1, -1).replace(/\\(["\\])/g, "$1");
3708
+ if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
3709
+ return null;
3710
+ }
3711
+ var STRING_TOKEN_RE = /("(?:\\.|[^"\\])*"|'[^']*')/g;
3712
+ /**
3713
+ * Reads the `[mcp_servers.<name>]` table emitted by `renderTomlMcpServer`.
3714
+ * Returns null when the table is absent or either `command` / `args` is
3715
+ * missing or malformed. Only the exact table block is inspected, so
3716
+ * unrelated tables (and subtables) are ignored.
3717
+ */
3718
+ function readTomlMcpServer(content, name) {
3719
+ const lines = content.split("\n");
3720
+ const base = `mcp_servers.${name}`;
3721
+ let start = -1;
3722
+ for (let i = 0; i < lines.length; i += 1) {
3723
+ const m = HEADER_RE.exec(lines[i]);
3724
+ if (m && normalizeKey(m[1]) === base) {
3725
+ start = i;
3726
+ break;
3727
+ }
3728
+ }
3729
+ if (start === -1) return null;
3730
+ let command = null;
3731
+ let args = null;
3732
+ for (let i = start + 1; i < lines.length; i += 1) {
3733
+ const line = lines[i];
3734
+ if (HEADER_RE.test(line)) break;
3735
+ const commandMatch = /^\s*command\s*=\s*((?:"(?:\\.|[^"\\])*")|(?:'[^']*'))\s*(?:#.*)?$/.exec(line);
3736
+ if (commandMatch) {
3737
+ command = parseTomlStringLiteral(commandMatch[1]);
3738
+ continue;
3739
+ }
3740
+ const argsMatch = /^\s*args\s*=\s*\[(.*)\]\s*(?:#.*)?$/.exec(line);
3741
+ if (argsMatch) {
3742
+ const tokens = argsMatch[1].match(STRING_TOKEN_RE) ?? [];
3743
+ if (argsMatch[1].replace(STRING_TOKEN_RE, "").replace(/[\s,]/g, "").length > 0) return null;
3744
+ const parsed = [];
3745
+ for (const token of tokens) {
3746
+ const value = parseTomlStringLiteral(token);
3747
+ if (value === null) return null;
3748
+ parsed.push(value);
3749
+ }
3750
+ args = parsed;
3751
+ }
3752
+ }
3753
+ if (command === null || args === null) return null;
3754
+ return {
3755
+ command,
3756
+ args
3757
+ };
3758
+ }
2993
3759
  function removeTomlMcpServer(content, name) {
2994
3760
  const lines = content.split("\n");
2995
3761
  const ranges = findOurRanges(lines, name);
@@ -3025,6 +3791,10 @@ function upsertTomlMcpServer(content, name, entry) {
3025
3791
  //#region src/services/McpConfigService.ts
3026
3792
  var SERVER_NAME = "agent-skills-standard";
3027
3793
  var PACKAGE = "agent-skills-standard-mcp";
3794
+ /** Sentinel: the `ags-mcp` command is not on PATH (no manifest fallback allowed). */
3795
+ var GLOBAL_BINARY_MISSING = Symbol("ags-mcp-missing");
3796
+ var SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
3797
+ var SEMVER_EXACT = /^\d+\.\d+\.\d+$/;
3028
3798
  var getTargets = (home = os.default.homedir(), platform = process.platform) => {
3029
3799
  const out = {};
3030
3800
  for (const agent of Object.values(Agent)) {
@@ -3059,18 +3829,111 @@ var McpConfigService = class {
3059
3829
  * <package>`, which would otherwise pull whatever is latest on npm the
3060
3830
  * moment the agent starts — an unpinned update-drift surface (AST07).
3061
3831
  * Pass an explicit `version` (from `mcp.version` in .skillsrc) to override.
3832
+ *
3833
+ * `via` selects the launch strategy (default `auto`). A global install
3834
+ * avoids the ~0.6s npx resolution overhead on every client spawn, but a
3835
+ * stale global must never be silently preferred over the pin.
3062
3836
  */
3063
- buildEntry(version) {
3837
+ buildEntry(opts = {}) {
3838
+ const version = opts.version || "0.8.0";
3839
+ const via = opts.via ?? "auto";
3840
+ const platform = this.testPlatform ?? process.platform;
3841
+ if (via === "global" || via === "auto" && platform !== "win32" && this.detectGlobalVersion() === version) return platform === "win32" ? {
3842
+ command: "cmd",
3843
+ args: ["/c", "ags-mcp"]
3844
+ } : {
3845
+ command: "ags-mcp",
3846
+ args: []
3847
+ };
3064
3848
  return {
3065
3849
  command: "npx",
3066
- args: ["-y", `${PACKAGE}@${version || "0.6.1"}`]
3850
+ args: [
3851
+ "-y",
3852
+ "--prefer-offline",
3853
+ `${PACKAGE}@${version}`
3854
+ ]
3067
3855
  };
3068
3856
  }
3857
+ /**
3858
+ * Version reported by a globally installed `ags-mcp`, or `null` when the
3859
+ * binary is missing/unusable.
3860
+ *
3861
+ * Two probes, in order:
3862
+ * 1. `ags-mcp --version` — authoritative and install-manager agnostic.
3863
+ * 2. the manifest under `npm root -g` — required for servers published
3864
+ * before `--version` existed, where probe 1 either prints nothing or
3865
+ * times out because the stdio server starts normally instead.
3866
+ * A missing binary (ENOENT) never falls through: an installed manifest
3867
+ * alone does not prove the command is on the client's PATH.
3868
+ */
3869
+ detectGlobalVersion() {
3870
+ const binaryVersion = this.probeGlobalBinaryVersion();
3871
+ if (binaryVersion === GLOBAL_BINARY_MISSING) return null;
3872
+ return binaryVersion ?? this.probeGlobalManifestVersion();
3873
+ }
3874
+ /** `ags-mcp --version`, or the sentinel when the command is not on PATH. */
3875
+ probeGlobalBinaryVersion() {
3876
+ try {
3877
+ if (this.testGlobalVersionProbe) return this.testGlobalVersionProbe();
3878
+ const output = (0, child_process.execFileSync)("ags-mcp", ["--version"], {
3879
+ encoding: "utf8",
3880
+ timeout: 2e3,
3881
+ stdio: [
3882
+ "ignore",
3883
+ "pipe",
3884
+ "ignore"
3885
+ ]
3886
+ });
3887
+ const firstLine = String(output).split("\n")[0].trim();
3888
+ return SEMVER_PREFIX.test(firstLine) ? firstLine : null;
3889
+ } catch (err) {
3890
+ return err.code === "ENOENT" ? GLOBAL_BINARY_MISSING : null;
3891
+ }
3892
+ }
3893
+ /** Version field of the globally installed manifest, purely from disk. */
3894
+ probeGlobalManifestVersion() {
3895
+ if (this.testGlobalManifestProbe) return this.testGlobalManifestProbe();
3896
+ const isWindows = (this.testPlatform ?? process.platform) === "win32";
3897
+ try {
3898
+ const globalRoot = String((0, child_process.execFileSync)(isWindows ? "npm.cmd" : "npm", ["root", "-g"], {
3899
+ encoding: "utf8",
3900
+ timeout: 5e3,
3901
+ stdio: [
3902
+ "ignore",
3903
+ "pipe",
3904
+ "ignore"
3905
+ ],
3906
+ ...isWindows ? { shell: true } : {}
3907
+ })).trim();
3908
+ if (!globalRoot) return null;
3909
+ const manifestPath = path.default.join(globalRoot, PACKAGE, "package.json");
3910
+ if (!fs_extra.default.pathExistsSync(manifestPath)) return null;
3911
+ const manifest = fs_extra.default.readJsonSync(manifestPath);
3912
+ return typeof manifest.version === "string" && SEMVER_EXACT.test(manifest.version) ? manifest.version : null;
3913
+ } catch {
3914
+ return null;
3915
+ }
3916
+ }
3069
3917
  /** For testing: allows overriding the home directory. */
3070
3918
  testHome = null;
3071
3919
  setHomeForTesting(home) {
3072
3920
  this.testHome = home;
3073
3921
  }
3922
+ /** For testing: allows overriding the detected platform. */
3923
+ testPlatform = null;
3924
+ setPlatformForTesting(platform) {
3925
+ this.testPlatform = platform;
3926
+ }
3927
+ /** For testing: allows overriding the global-version probe. */
3928
+ testGlobalVersionProbe = null;
3929
+ setGlobalVersionProbeForTesting(fn) {
3930
+ this.testGlobalVersionProbe = fn;
3931
+ }
3932
+ /** For testing: allows overriding the `npm root -g` manifest probe. */
3933
+ testGlobalManifestProbe = null;
3934
+ setGlobalManifestProbeForTesting(fn) {
3935
+ this.testGlobalManifestProbe = fn;
3936
+ }
3074
3937
  getTargets() {
3075
3938
  return getTargets(this.testHome ?? void 0);
3076
3939
  }
@@ -3089,7 +3952,10 @@ var McpConfigService = class {
3089
3952
  const { rootDir, agents, mcp } = opts;
3090
3953
  if (mcp.scope === "disabled" || !mcp.enabled) return report;
3091
3954
  const TARGETS = this.getTargets();
3092
- const entry = this.buildEntry(mcp.version);
3955
+ const entry = this.buildEntry({
3956
+ version: mcp.version,
3957
+ via: mcp.via
3958
+ });
3093
3959
  if (mcp.scope === "snippets-only" || mcp.snippets) await this.generateSnippets(rootDir, agents, entry, report);
3094
3960
  if (mcp.scope === "snippets-only") return report;
3095
3961
  for (const agent of agents) {
@@ -3166,12 +4032,45 @@ var McpConfigService = class {
3166
4032
  const target = TARGETS[agent];
3167
4033
  if (!target) continue;
3168
4034
  const row = { agent };
3169
- if (target.projectFile) row.project = await this.hasOurEntry(path.default.join(opts.rootDir, target.projectFile), target);
3170
- if (target.userFile) row.user = await this.hasOurEntry(target.userFile, target);
4035
+ if (target.projectFile) {
4036
+ const abs = path.default.join(opts.rootDir, target.projectFile);
4037
+ row.project = await this.hasOurEntry(abs, target);
4038
+ row.projectEntry = await this.readEntry(abs, target);
4039
+ }
4040
+ if (target.userFile) {
4041
+ row.user = await this.hasOurEntry(target.userFile, target);
4042
+ row.userEntry = await this.readEntry(target.userFile, target);
4043
+ }
3171
4044
  out.push(row);
3172
4045
  }
3173
4046
  return out;
3174
4047
  }
4048
+ /** Parse the entry this service wrote for `SERVER_NAME`, if present. */
4049
+ async readEntry(abs, target) {
4050
+ if (!await fs_extra.default.pathExists(abs)) return null;
4051
+ if (target.format === "toml") return readTomlMcpServer(await fs_extra.default.readFile(abs, "utf8"), SERVER_NAME);
4052
+ const data = await fs_extra.default.readJson(abs).catch(() => null);
4053
+ if (!data) return null;
4054
+ const container = this.getNestedValue(data, target.key);
4055
+ if (!container) return null;
4056
+ if (target.shape === "map") {
4057
+ if (typeof container !== "object" || Array.isArray(container)) return null;
4058
+ return this.normalizeEntry(container[SERVER_NAME]);
4059
+ }
4060
+ if (!Array.isArray(container)) return null;
4061
+ const item = container.find((entry) => entry.name === SERVER_NAME);
4062
+ return this.normalizeEntry(item?.transport);
4063
+ }
4064
+ normalizeEntry(value) {
4065
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
4066
+ const record = value;
4067
+ if (typeof record.command !== "string") return null;
4068
+ const args = Array.isArray(record.args) ? record.args.map((arg) => String(arg)) : [];
4069
+ return {
4070
+ command: record.command,
4071
+ args
4072
+ };
4073
+ }
3175
4074
  async generateSnippets(rootDir, agents, entry, report) {
3176
4075
  const dir = path.default.join(rootDir, "mcp-config-snippets");
3177
4076
  await fs_extra.default.ensureDir(dir);
@@ -3271,7 +4170,11 @@ var McpConfigService = class {
3271
4170
  if (!data) return false;
3272
4171
  const container = this.getNestedValue(data, target.key);
3273
4172
  if (!container) return false;
3274
- if (target.shape === "map") return Boolean(container[SERVER_NAME]);
4173
+ if (target.shape === "map") {
4174
+ if (typeof container !== "object" || Array.isArray(container)) return false;
4175
+ return Boolean(container[SERVER_NAME]);
4176
+ }
4177
+ if (!Array.isArray(container)) return false;
3275
4178
  return container.some((item) => item.name === SERVER_NAME);
3276
4179
  }
3277
4180
  buildFreshDoc(target, entry) {
@@ -3374,6 +4277,10 @@ function defaultDoctorDeps() {
3374
4277
  rootDir,
3375
4278
  agents
3376
4279
  }),
4280
+ buildMcpEntry: (cfg) => mcp.buildEntry({
4281
+ version: cfg.mcp?.version,
4282
+ via: cfg.mcp?.via
4283
+ }),
3377
4284
  hookStatus: (rootDir, agents) => hooks.status({
3378
4285
  rootDir,
3379
4286
  agents
@@ -3507,13 +4414,48 @@ var DoctorService = class {
3507
4414
  if (!config.mcp?.enabled || scope !== "project" && scope !== "user") return check("mcp", "skip", "MCP disabled in .skillsrc");
3508
4415
  const rows = await this.deps.mcpStatus(rootDir, agents);
3509
4416
  const missing = rows.filter((r) => scope === "project" ? !r.project : !(r.project || r.user)).map((r) => r.agent);
3510
- if (missing.length === 0) return check("mcp", "ok", `registered for ${rows.map((r) => r.agent).join(", ") || "no MCP-capable agents"}`);
3511
- return check("mcp", "fail", `MCP server not registered for: ${missing.join(", ")}`, {
4417
+ if (missing.length > 0) return check("mcp", "fail", `MCP server not registered for: ${missing.join(", ")}`, {
3512
4418
  description: `register MCP for ${missing.join(", ")}`,
3513
4419
  apply: async () => {
3514
4420
  for (const agent of missing) await this.deps.installMcp(rootDir, agent, config);
3515
4421
  }
3516
4422
  });
4423
+ const expected = this.deps.buildMcpEntry ? this.deps.buildMcpEntry(config) : new McpConfigService().buildEntry({
4424
+ version: config.mcp.version,
4425
+ via: config.mcp.via
4426
+ });
4427
+ const pin = config.mcp.version ?? "0.8.0";
4428
+ const drifted = [];
4429
+ for (const row of rows) {
4430
+ if (scope === "project") {
4431
+ if (row.project && row.projectEntry && !sameMcpEntry(row.projectEntry, expected)) drifted.push({
4432
+ agent: row.agent,
4433
+ scope: "project",
4434
+ entry: row.projectEntry
4435
+ });
4436
+ continue;
4437
+ }
4438
+ if (row.project && row.projectEntry && !sameMcpEntry(row.projectEntry, expected)) drifted.push({
4439
+ agent: row.agent,
4440
+ scope: "project",
4441
+ entry: row.projectEntry
4442
+ });
4443
+ if (row.user && row.userEntry && !sameMcpEntry(row.userEntry, expected)) drifted.push({
4444
+ agent: row.agent,
4445
+ scope: "user",
4446
+ entry: row.userEntry
4447
+ });
4448
+ }
4449
+ if (drifted.length > 0) {
4450
+ const driftedAgents = [...new Set(drifted.map((item) => item.agent))];
4451
+ return check("mcp", "warn", `MCP pin drift: ${drifted.map((item) => `${item.agent} ${item.scope} pins ${item.entry.command}${item.entry.args.length ? ` ${item.entry.args.join(" ")}` : ""} but this CLI expects agent-skills-standard-mcp@${pin}`).join("; ")}`, {
4452
+ description: `refresh MCP entry for ${driftedAgents.join(", ")}`,
4453
+ apply: async () => {
4454
+ for (const agent of driftedAgents) await this.deps.installMcp(rootDir, agent, config);
4455
+ }
4456
+ });
4457
+ }
4458
+ return check("mcp", "ok", `registered for ${rows.map((r) => r.agent).join(", ") || "no MCP-capable agents"}`);
3517
4459
  }
3518
4460
  async hooksCheck(rootDir, agents) {
3519
4461
  const rows = await this.deps.hookStatus(rootDir, agents);
@@ -4009,10 +4951,306 @@ var InitService = class {
4009
4951
  await fs_extra.default.outputFile(configPath, commentHeader + js_yaml.dump(config));
4010
4952
  }
4011
4953
  };
4012
- //#endregion
4013
- //#region src/services/GithubService.ts
4014
- /** A downloaded file failed its integrity or size check. */
4015
- var IntegrityError = class extends Error {
4954
+ var AMBIGUOUS_ERROR_BODY_TIMEOUT_MS = 1e3;
4955
+ var RATE_LIMIT_EVIDENCE = /rate[-_ ]?limit|abuse[-_ ]?detection|too many requests/i;
4956
+ /**
4957
+ * Drops credentials, query strings, and fragments. GitHub tokens can appear in
4958
+ * query strings on some endpoints, so only origin and path are retained.
4959
+ */
4960
+ function sanitizeEndpoint(url) {
4961
+ try {
4962
+ const parsed = new URL(url);
4963
+ return `${parsed.origin}${parsed.pathname}`;
4964
+ } catch {
4965
+ return url.split(/[?#]/)[0] ?? url;
4966
+ }
4967
+ }
4968
+ function readHeader(res, name) {
4969
+ const headers = res.headers;
4970
+ if (!headers || typeof headers.get !== "function") return void 0;
4971
+ const value = headers.get(name);
4972
+ return typeof value === "string" ? value : void 0;
4973
+ }
4974
+ /** `Retry-After` in seconds. Malformed, negative, or non-integer values are ignored. */
4975
+ function parseRetryAfterSeconds(value) {
4976
+ if (value === void 0) return void 0;
4977
+ const trimmed = value.trim();
4978
+ if (!/^\d+$/.test(trimmed)) return void 0;
4979
+ const seconds = Number(trimmed);
4980
+ return Number.isSafeInteger(seconds) ? seconds : void 0;
4981
+ }
4982
+ /** `x-ratelimit-reset` is epoch seconds. Malformed or implausible values are ignored. */
4983
+ function parseResetAt(value) {
4984
+ if (value === void 0) return void 0;
4985
+ const trimmed = value.trim();
4986
+ if (!/^\d+$/.test(trimmed)) return void 0;
4987
+ const seconds = Number(trimmed);
4988
+ if (!Number.isSafeInteger(seconds) || seconds <= 0) return void 0;
4989
+ const date = /* @__PURE__ */ new Date(seconds * 1e3);
4990
+ const time = date.getTime();
4991
+ if (Number.isNaN(time)) return void 0;
4992
+ const year = date.getUTCFullYear();
4993
+ if (year < 1970 || year > 2100) return void 0;
4994
+ return date.toISOString();
4995
+ }
4996
+ function parseRemaining(value) {
4997
+ if (value === void 0) return void 0;
4998
+ const trimmed = value.trim();
4999
+ if (!/^\d+$/.test(trimmed)) return void 0;
5000
+ const remaining = Number(trimmed);
5001
+ return Number.isSafeInteger(remaining) ? remaining : void 0;
5002
+ }
5003
+ function readNodeBody(stream) {
5004
+ return new Promise((resolve) => {
5005
+ const chunks = [];
5006
+ let total = 0;
5007
+ let settled = false;
5008
+ const finish = (text) => {
5009
+ if (settled) return;
5010
+ settled = true;
5011
+ clearTimeout(timer);
5012
+ stream.removeListener("data", onData);
5013
+ stream.removeListener("end", onEnd);
5014
+ stream.removeListener("error", onError);
5015
+ if (text === null) stream.destroy();
5016
+ else stream.pause();
5017
+ resolve(text);
5018
+ };
5019
+ const onData = (chunk) => {
5020
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
5021
+ total += bytes.byteLength;
5022
+ if (total > 8192) {
5023
+ finish(null);
5024
+ return;
5025
+ }
5026
+ chunks.push(bytes);
5027
+ };
5028
+ const onEnd = () => finish(Buffer.concat(chunks).toString("utf8"));
5029
+ const onError = () => finish(null);
5030
+ const timer = setTimeout(() => finish(null), AMBIGUOUS_ERROR_BODY_TIMEOUT_MS);
5031
+ stream.on("data", onData);
5032
+ stream.on("end", onEnd);
5033
+ stream.on("error", onError);
5034
+ });
5035
+ }
5036
+ function readWebChunk(reader, timeoutMs) {
5037
+ return new Promise((resolve) => {
5038
+ let settled = false;
5039
+ const timer = setTimeout(() => {
5040
+ if (settled) return;
5041
+ settled = true;
5042
+ resolve("timeout");
5043
+ }, timeoutMs);
5044
+ reader.read().then((result) => {
5045
+ if (settled) return;
5046
+ settled = true;
5047
+ clearTimeout(timer);
5048
+ resolve(result);
5049
+ }, () => {
5050
+ if (settled) return;
5051
+ settled = true;
5052
+ clearTimeout(timer);
5053
+ resolve("timeout");
5054
+ });
5055
+ });
5056
+ }
5057
+ /**
5058
+ * Abandons a WHATWG reader without waiting on `cancel()`. A cancellation
5059
+ * promise may never settle; awaiting it would extend the classification
5060
+ * deadline. The lock is released when possible, otherwise the runtime owns the
5061
+ * reader after the pending read settles.
5062
+ */
5063
+ function abandonWebReader(reader) {
5064
+ try {
5065
+ reader.cancel().catch(() => void 0);
5066
+ } catch {}
5067
+ try {
5068
+ reader.releaseLock();
5069
+ } catch {}
5070
+ }
5071
+ async function readWebBody(stream) {
5072
+ const reader = stream.getReader();
5073
+ const decoder = new TextDecoder();
5074
+ let text = "";
5075
+ let total = 0;
5076
+ const deadline = Date.now() + AMBIGUOUS_ERROR_BODY_TIMEOUT_MS;
5077
+ try {
5078
+ for (;;) {
5079
+ const remaining = deadline - Date.now();
5080
+ if (remaining <= 0) {
5081
+ abandonWebReader(reader);
5082
+ return null;
5083
+ }
5084
+ const chunk = await readWebChunk(reader, remaining);
5085
+ if (chunk === "timeout") {
5086
+ abandonWebReader(reader);
5087
+ return null;
5088
+ }
5089
+ if (chunk.done) return text + decoder.decode();
5090
+ total += chunk.value.byteLength;
5091
+ if (total > 8192) {
5092
+ abandonWebReader(reader);
5093
+ return null;
5094
+ }
5095
+ text += decoder.decode(chunk.value, { stream: true });
5096
+ }
5097
+ } catch {
5098
+ return null;
5099
+ }
5100
+ }
5101
+ /**
5102
+ * Releases a response body that will never be read. Node streams are destroyed
5103
+ * synchronously; WHATWG cancellation is started with rejection handling and
5104
+ * never awaited, so a stalled cancel cannot hold up classification or latch.
5105
+ */
5106
+ function abandonResponseBody(res) {
5107
+ const body = res.body;
5108
+ if (body instanceof node_stream.Readable) {
5109
+ body.destroy();
5110
+ return;
5111
+ }
5112
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) try {
5113
+ body.cancel().catch(() => void 0);
5114
+ } catch {}
5115
+ }
5116
+ /**
5117
+ * Reads only the ambiguous 403 body, bounded in bytes and time, for both the
5118
+ * Node stream that `cross-fetch` returns and a WHATWG stream. Any timeout,
5119
+ * oversize read, or stream error releases the body and returns null.
5120
+ */
5121
+ function readAmbiguousErrorBody(res) {
5122
+ const body = res.body;
5123
+ if (body instanceof node_stream.Readable) return readNodeBody(body);
5124
+ if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return readWebBody(body);
5125
+ return Promise.resolve(null);
5126
+ }
5127
+ var GithubRequestError = class GithubRequestError extends Error {
5128
+ kind;
5129
+ endpoint;
5130
+ authenticated;
5131
+ status;
5132
+ statusText;
5133
+ retryAfterSeconds;
5134
+ resetAt;
5135
+ rateLimitKind;
5136
+ constructor(init) {
5137
+ super(init.message, { cause: init.cause });
5138
+ this.name = "GithubRequestError";
5139
+ this.kind = init.kind;
5140
+ this.endpoint = sanitizeEndpoint(init.endpoint);
5141
+ this.authenticated = init.authenticated;
5142
+ this.status = init.status;
5143
+ this.statusText = init.statusText;
5144
+ this.retryAfterSeconds = init.retryAfterSeconds;
5145
+ this.resetAt = init.resetAt;
5146
+ this.rateLimitKind = init.rateLimitKind;
5147
+ }
5148
+ /** Serialization allowlist: no token, no headers, no cause chain. */
5149
+ toJSON() {
5150
+ return {
5151
+ name: "GithubRequestError",
5152
+ kind: this.kind,
5153
+ status: this.status ?? null,
5154
+ endpoint: this.endpoint,
5155
+ authenticated: this.authenticated,
5156
+ retryAfterSeconds: this.retryAfterSeconds ?? null,
5157
+ resetAt: this.resetAt ?? null,
5158
+ rateLimitKind: this.rateLimitKind ?? null,
5159
+ message: this.message
5160
+ };
5161
+ }
5162
+ static isRateLimit(error) {
5163
+ return error instanceof GithubRequestError && error.kind === "rate-limited";
5164
+ }
5165
+ static isAccessDenial(error) {
5166
+ return error instanceof GithubRequestError && error.kind === "access-denied";
5167
+ }
5168
+ };
5169
+ function buildFailure(res) {
5170
+ const status = typeof res.status === "number" && Number.isInteger(res.status) ? res.status : 0;
5171
+ const statusText = typeof res.statusText === "string" ? res.statusText : void 0;
5172
+ return {
5173
+ status,
5174
+ statusText,
5175
+ statusLabel: `${status || "unknown"}${statusText ? ` ${statusText}` : ""}`,
5176
+ retryAfterSeconds: parseRetryAfterSeconds(readHeader(res, "retry-after")),
5177
+ resetAt: parseResetAt(readHeader(res, "x-ratelimit-reset"))
5178
+ };
5179
+ }
5180
+ function classifyGithubStatus(res, endpoint, authenticated) {
5181
+ const { status, statusText, statusLabel, retryAfterSeconds, resetAt } = buildFailure(res);
5182
+ const primary = parseRemaining(readHeader(res, "x-ratelimit-remaining")) === 0;
5183
+ if (status === 404) return new GithubRequestError({
5184
+ kind: "not-found",
5185
+ message: `GitHub resource not found: ${statusLabel}`,
5186
+ endpoint,
5187
+ authenticated,
5188
+ status,
5189
+ statusText
5190
+ });
5191
+ if (status === 401) return new GithubRequestError({
5192
+ kind: "access-denied",
5193
+ message: `GitHub access denied: ${statusLabel}`,
5194
+ endpoint,
5195
+ authenticated,
5196
+ status,
5197
+ statusText,
5198
+ resetAt
5199
+ });
5200
+ if (status === 403 || status === 429) {
5201
+ if (primary || status === 429 || retryAfterSeconds !== void 0) {
5202
+ const rateLimitKind = primary ? "primary" : retryAfterSeconds !== void 0 || status === 429 ? "secondary" : void 0;
5203
+ return new GithubRequestError({
5204
+ kind: "rate-limited",
5205
+ message: `GitHub rate limit exceeded (${rateLimitKind ?? "unclassified"}): ${statusLabel}`,
5206
+ endpoint,
5207
+ authenticated,
5208
+ status,
5209
+ statusText,
5210
+ retryAfterSeconds,
5211
+ resetAt,
5212
+ rateLimitKind
5213
+ });
5214
+ }
5215
+ return "ambiguous";
5216
+ }
5217
+ return new GithubRequestError({
5218
+ kind: "transport",
5219
+ message: `GitHub API Error: ${statusLabel}`,
5220
+ endpoint,
5221
+ authenticated,
5222
+ status,
5223
+ statusText
5224
+ });
5225
+ }
5226
+ /** Resolves an ambiguous 403 by reading a bounded body for throttle evidence. */
5227
+ async function classifyAmbiguous403(res, endpoint, authenticated) {
5228
+ const { status, statusText, statusLabel, resetAt } = buildFailure(res);
5229
+ const body = await readAmbiguousErrorBody(res);
5230
+ if (body !== null && RATE_LIMIT_EVIDENCE.test(body)) return new GithubRequestError({
5231
+ kind: "rate-limited",
5232
+ message: `GitHub rate limit exceeded (secondary): ${statusLabel}`,
5233
+ endpoint,
5234
+ authenticated,
5235
+ status,
5236
+ statusText,
5237
+ resetAt,
5238
+ rateLimitKind: "secondary"
5239
+ });
5240
+ return new GithubRequestError({
5241
+ kind: "access-denied",
5242
+ message: `GitHub access denied: ${statusLabel}`,
5243
+ endpoint,
5244
+ authenticated,
5245
+ status,
5246
+ statusText,
5247
+ resetAt
5248
+ });
5249
+ }
5250
+ //#endregion
5251
+ //#region src/services/GithubService.ts
5252
+ /** A downloaded file failed its integrity or size check. */
5253
+ var IntegrityError = class extends Error {
4016
5254
  constructor(message) {
4017
5255
  super(message);
4018
5256
  this.name = "IntegrityError";
@@ -4024,13 +5262,32 @@ function gitBlobSha1(content) {
4024
5262
  return (0, node_crypto.createHash)("sha1").update(Buffer.concat([header, content])).digest("hex");
4025
5263
  }
4026
5264
  /**
5265
+ * Detaches a cached value from the caller that receives it. Buffers are
5266
+ * copied; plain objects are structured-cloned. The session cache therefore
5267
+ * never shares mutable state with consumers.
5268
+ */
5269
+ function detachValue(value) {
5270
+ if (Buffer.isBuffer(value)) return Buffer.from(value);
5271
+ if (value !== null && typeof value === "object") return structuredClone(value);
5272
+ return value;
5273
+ }
5274
+ /**
4027
5275
  * Service for interacting with the GitHub API and fetching raw file content.
4028
5276
  * Handles repository tree discovery, file downloads, and URL parsing.
5277
+ *
5278
+ * One instance is operation-scoped: identical requests are coalesced and
5279
+ * reused for the lifetime of the operation, the first confirmed throttling
5280
+ * response latches the whole operation, and `resetOperation()` clears both
5281
+ * when a client is reused for another command.
4029
5282
  */
4030
- var GithubService = class {
5283
+ var GithubService = class GithubService {
4031
5284
  token;
4032
5285
  baseUrl = "https://api.github.com";
4033
5286
  rawBaseUrl = "https://raw.githubusercontent.com";
5287
+ sessionCache = /* @__PURE__ */ new Map();
5288
+ inFlight = /* @__PURE__ */ new Map();
5289
+ rateLimitError = null;
5290
+ operationEpoch = 0;
4034
5291
  constructor(token) {
4035
5292
  this.token = token;
4036
5293
  }
@@ -4039,49 +5296,155 @@ var GithubService = class {
4039
5296
  if (this.token) h.Authorization = `token ${this.token}`;
4040
5297
  return h;
4041
5298
  }
5299
+ /** True once this operation has observed confirmed throttling. */
5300
+ get rateLimited() {
5301
+ return this.rateLimitError !== null;
5302
+ }
5303
+ /** The first throttling failure that latched this operation, if any. */
5304
+ get rateLimitFailure() {
5305
+ return this.rateLimitError;
5306
+ }
5307
+ /**
5308
+ * Clears session caches and the throttle latch. Call only between commands
5309
+ * that reuse the same client. Requests already in flight settle normally,
5310
+ * but their results are not retained.
5311
+ */
5312
+ resetOperation() {
5313
+ this.sessionCache.clear();
5314
+ this.inFlight.clear();
5315
+ this.rateLimitError = null;
5316
+ this.operationEpoch += 1;
5317
+ }
5318
+ /**
5319
+ * Single-flight per request identity: concurrent callers share one pending
5320
+ * request, completed values are reused within the operation, and failures
5321
+ * are never cached (so a recovered request can be retried).
5322
+ */
5323
+ async sessionRequest(key, run) {
5324
+ const cached = this.sessionCache.get(key);
5325
+ if (cached !== void 0) return detachValue(cached);
5326
+ const pending = this.inFlight.get(key);
5327
+ if (pending) return detachValue(await pending);
5328
+ const epoch = this.operationEpoch;
5329
+ const promise = this.runSessionRequest(key, epoch, run);
5330
+ this.inFlight.set(key, promise);
5331
+ return promise;
5332
+ }
5333
+ async runSessionRequest(key, epoch, run) {
5334
+ try {
5335
+ const value = await run();
5336
+ if (epoch === this.operationEpoch && value !== null && value !== void 0) this.sessionCache.set(key, detachValue(value));
5337
+ return value;
5338
+ } finally {
5339
+ if (epoch === this.operationEpoch) this.inFlight.delete(key);
5340
+ }
5341
+ }
5342
+ /**
5343
+ * Performs one HTTP request, rejecting with a typed failure for any non-2xx
5344
+ * response or transport error.
5345
+ *
5346
+ * - `authenticated` reflects the Authorization header actually attached to
5347
+ * this request, never a constructor assumption.
5348
+ * - A status/header-decidable throttle latches synchronously; only an
5349
+ * ambiguous 403 awaits its bounded body, and that latch is guarded by the
5350
+ * epoch captured before the request started.
5351
+ * - Transport messages are fixed text; the raw error (which may contain
5352
+ * credentials in a URL) stays internal to `cause`.
5353
+ */
5354
+ async request(url, init) {
5355
+ if (this.rateLimitError) throw this.rateLimitError;
5356
+ const epoch = this.operationEpoch;
5357
+ const authenticated = GithubService.hasAttachedAuthorization(init);
5358
+ let res;
5359
+ try {
5360
+ res = init === void 0 ? await (0, cross_fetch.default)(url) : await (0, cross_fetch.default)(url, init);
5361
+ } catch (error) {
5362
+ throw new GithubRequestError({
5363
+ kind: "transport",
5364
+ message: "GitHub request failed before a response was received",
5365
+ endpoint: url,
5366
+ authenticated,
5367
+ cause: error
5368
+ });
5369
+ }
5370
+ if (!res.ok) {
5371
+ const verdict = classifyGithubStatus(res, url, authenticated);
5372
+ if (verdict !== "ambiguous") {
5373
+ if (verdict.kind === "rate-limited" && epoch === this.operationEpoch && !this.rateLimitError) this.rateLimitError = verdict;
5374
+ abandonResponseBody(res);
5375
+ throw verdict;
5376
+ }
5377
+ const failure = await classifyAmbiguous403(res, url, authenticated);
5378
+ if (failure.kind === "rate-limited" && epoch === this.operationEpoch && !this.rateLimitError) this.rateLimitError = failure;
5379
+ throw failure;
5380
+ }
5381
+ return res;
5382
+ }
5383
+ /** True only when this request's headers actually carry an Authorization value. */
5384
+ static hasAttachedAuthorization(init) {
5385
+ const headers = init?.headers;
5386
+ if (!headers) return false;
5387
+ if (Array.isArray(headers)) return headers.some((entry) => entry[0]?.toLowerCase() === "authorization" && Boolean(entry[1]));
5388
+ if (typeof Headers !== "undefined" && headers instanceof Headers) return Boolean(headers.get("authorization"));
5389
+ return Object.entries(headers).some(([name, value]) => name.toLowerCase() === "authorization" && typeof value === "string" && value.length > 0);
5390
+ }
5391
+ /** Rate-limit and access-denial failures are actionable; never flatten them to null. */
5392
+ static isActionable(error) {
5393
+ return GithubRequestError.isRateLimit(error) || GithubRequestError.isAccessDenial(error);
5394
+ }
5395
+ static isAbsence(error) {
5396
+ return error instanceof GithubRequestError && error.kind === "not-found";
5397
+ }
4042
5398
  /**
4043
5399
  * Fetches the recursive Git tree for a repository.
4044
5400
  * @param owner Repository owner
4045
5401
  * @param repo Repository name
4046
5402
  * @param ref Git reference (branch, tag, or commit SHA)
4047
- * @returns The tree data or null if not found
5403
+ * @param options.commit Resolved commit; keys the shared tree by immutable identity
5404
+ * @returns The tree data, or null when the object or reference does not exist
4048
5405
  */
4049
- async getRepoTree(owner, repo, ref) {
4050
- const url = `${this.baseUrl}/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
4051
- try {
4052
- const res = await (0, cross_fetch.default)(url, { headers: this.headers });
4053
- if (!res.ok) {
4054
- if (res.status === 404) return null;
4055
- throw new Error(`GitHub API Error: ${res.status} ${res.statusText}`);
5406
+ async getRepoTree(owner, repo, ref, options) {
5407
+ const identity = options?.commit ?? ref;
5408
+ const key = options?.commit ? `tree-commit:${owner}/${repo}@${options.commit}` : `tree-ref:${owner}/${repo}@${ref}`;
5409
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/git/trees/${identity}?recursive=1`;
5410
+ return this.sessionRequest(key, async () => {
5411
+ try {
5412
+ return await (await this.request(url, { headers: this.headers })).json();
5413
+ } catch (error) {
5414
+ if (GithubService.isActionable(error)) throw error;
5415
+ if (GithubService.isAbsence(error)) return null;
5416
+ console.error(picocolors.default.red(`Failed to fetch repo tree: ${error}`));
5417
+ return null;
4056
5418
  }
4057
- return await res.json();
4058
- } catch (error) {
4059
- console.error(picocolors.default.red(`Failed to fetch repo tree: ${error}`));
4060
- return null;
4061
- }
5419
+ });
4062
5420
  }
4063
5421
  /**
4064
5422
  * Fetches raw file bytes from GitHub and validates their Git blob identity.
4065
5423
  * Text callers should use getRawFile; package callers use this byte-preserving path.
4066
5424
  */
4067
5425
  async getRawFileBytes(owner, repo, ref, path, options) {
4068
- const url = `${this.rawBaseUrl}/${owner}/${repo}/${ref}/${path}`;
4069
- try {
4070
- const res = await (0, cross_fetch.default)(url);
4071
- if (!res.ok) return null;
4072
- const content = Buffer.from(await res.arrayBuffer());
4073
- const maxBytes = options?.maxBytes ?? 1048576;
4074
- if (content.byteLength > maxBytes) throw new IntegrityError(`${path}: ${content.byteLength} bytes exceeds the ${maxBytes}-byte limit`);
4075
- if (options?.expectedSha) {
4076
- const actualSha = gitBlobSha1(content);
4077
- if (actualSha !== options.expectedSha) throw new IntegrityError(`${path}: blob sha mismatch (expected ${options.expectedSha}, got ${actualSha}) — download may be corrupted or tampered with`);
5426
+ const identity = options?.commit ?? ref;
5427
+ const maxBytes = options?.maxBytes ?? 1048576;
5428
+ const key = `raw:${owner}/${repo}@${identity}:${path}#${options?.expectedSha ?? ""}#${maxBytes}`;
5429
+ const url = `${this.rawBaseUrl}/${owner}/${repo}/${identity}/${path}`;
5430
+ return this.sessionRequest(key, async () => {
5431
+ try {
5432
+ const res = await this.request(url);
5433
+ const content = Buffer.from(await res.arrayBuffer());
5434
+ if (content.byteLength > maxBytes) throw new IntegrityError(`${path}: ${content.byteLength} bytes exceeds the ${maxBytes}-byte limit`);
5435
+ if (options?.expectedSha) {
5436
+ const actualSha = gitBlobSha1(content);
5437
+ if (actualSha !== options.expectedSha) throw new IntegrityError(`${path}: blob sha mismatch (expected ${options.expectedSha}, got ${actualSha}) — download may be corrupted or tampered with`);
5438
+ }
5439
+ return content;
5440
+ } catch (error) {
5441
+ if (error instanceof IntegrityError) throw error;
5442
+ if (GithubService.isActionable(error)) throw error;
5443
+ if (GithubService.isAbsence(error)) return null;
5444
+ console.error(picocolors.default.red(`Failed to fetch file ${path}: ${error}`));
5445
+ return null;
4078
5446
  }
4079
- return content;
4080
- } catch (error) {
4081
- if (error instanceof IntegrityError) throw error;
4082
- console.error(picocolors.default.red(`Failed to fetch file ${path}: ${error}`));
4083
- return null;
4084
- }
5447
+ });
4085
5448
  }
4086
5449
  /**
4087
5450
  * Fetches raw file content as UTF-8 for established text-only callers.
@@ -4095,14 +5458,17 @@ var GithubService = class {
4095
5458
  * @param repo Repository name
4096
5459
  */
4097
5460
  async getLatestReleaseTag(owner, repo) {
4098
- try {
4099
- const res = await (0, cross_fetch.default)(`${this.baseUrl}/repos/${owner}/${repo}/releases/latest`, { headers: this.headers });
4100
- if (!res.ok) return null;
4101
- return (await res.json()).tag_name;
4102
- } catch (error) {
4103
- if (process.env.DEBUG) console.warn(`[GithubService] Failed to fetch latest release: ${error}`);
4104
- return null;
4105
- }
5461
+ const key = `latest-release:${owner}/${repo}`;
5462
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/releases/latest`;
5463
+ return this.sessionRequest(key, async () => {
5464
+ try {
5465
+ return (await (await this.request(url, { headers: this.headers })).json()).tag_name;
5466
+ } catch (error) {
5467
+ if (GithubService.isActionable(error)) throw error;
5468
+ if (process.env.DEBUG) console.warn(`[GithubService] Failed to fetch latest release: ${error}`);
5469
+ return null;
5470
+ }
5471
+ });
4106
5472
  }
4107
5473
  /**
4108
5474
  * Fetches basic repository information, such as the default branch.
@@ -4110,34 +5476,59 @@ var GithubService = class {
4110
5476
  * @param repo Repository name
4111
5477
  */
4112
5478
  async getRepoInfo(owner, repo) {
5479
+ const key = `repo-info:${owner}/${repo}`;
4113
5480
  const url = `${this.baseUrl}/repos/${owner}/${repo}`;
4114
- try {
4115
- const res = await (0, cross_fetch.default)(url, { headers: this.headers });
4116
- if (!res.ok) return null;
4117
- return await res.json();
4118
- } catch (error) {
4119
- console.error(picocolors.default.red(`Failed to fetch repo info: ${error}`));
4120
- return null;
4121
- }
5481
+ return this.sessionRequest(key, async () => {
5482
+ try {
5483
+ return await (await this.request(url, { headers: this.headers })).json();
5484
+ } catch (error) {
5485
+ if (GithubService.isActionable(error)) throw error;
5486
+ console.error(picocolors.default.red(`Failed to fetch repo info: ${error}`));
5487
+ return null;
5488
+ }
5489
+ });
4122
5490
  }
4123
5491
  /**
4124
5492
  * Downloads multiple text files concurrently with a limit. Existing text
4125
5493
  * consumers retain UTF-8 strings; package sync uses the byte variant below.
5494
+ *
5495
+ * Rejects with the first known `IntegrityError` or access-denied
5496
+ * `GithubRequestError` in input-task order after every started worker
5497
+ * settles, unless a throttle latch owns the terminal `rateLimited` result.
4126
5498
  */
4127
5499
  async downloadFilesConcurrent(tasks, concurrency = 10) {
4128
- return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, { expectedSha: task.sha }));
5500
+ return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, {
5501
+ expectedSha: task.sha,
5502
+ commit: task.commit
5503
+ }));
4129
5504
  }
4130
5505
  /**
4131
5506
  * Downloads multiple files without decoding their response bytes.
5507
+ *
5508
+ * Same terminal-failure contract as `downloadFilesConcurrent`, so package
5509
+ * callers cannot treat verification or authorization failure as absence.
4132
5510
  */
4133
5511
  async downloadFilesConcurrentBytes(tasks, concurrency = 10) {
4134
- return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, { expectedSha: task.sha }));
5512
+ return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, {
5513
+ expectedSha: task.sha,
5514
+ commit: task.commit
5515
+ }));
4135
5516
  }
5517
+ /**
5518
+ * Runs the batch, settles every started worker, then decides the terminal
5519
+ * outcome:
5520
+ * - a throttle latch keeps priority (`rateLimited` result, as before);
5521
+ * - otherwise the first `IntegrityError` or access-denied failure in
5522
+ * input-task order is rethrown, so callers never mistake verification or
5523
+ * authorization failure for missing data;
5524
+ * - a genuine missing file (404) stays a `failed` result.
5525
+ */
4136
5526
  async downloadFilesConcurrentWith(tasks, concurrency, download) {
4137
5527
  const outcomes = Array(tasks.length);
4138
5528
  let nextIndex = 0;
4139
5529
  const worker = async () => {
4140
5530
  while (nextIndex < tasks.length) {
5531
+ if (this.rateLimitError) return;
4141
5532
  const index = nextIndex++;
4142
5533
  const task = tasks[index];
4143
5534
  if (!task) continue;
@@ -4156,7 +5547,8 @@ var GithubService = class {
4156
5547
  outcomes[index] = {
4157
5548
  kind: "failed",
4158
5549
  path: task.path,
4159
- reason: error instanceof Error ? error.message : String(error)
5550
+ reason: error instanceof Error ? error.message : String(error),
5551
+ error
4160
5552
  };
4161
5553
  }
4162
5554
  }
@@ -4164,16 +5556,32 @@ var GithubService = class {
4164
5556
  await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
4165
5557
  const ok = [];
4166
5558
  const failed = [];
4167
- for (const outcome of outcomes) {
4168
- if (!outcome) continue;
4169
- if (outcome.kind === "ok") ok.push({
5559
+ for (let index = 0; index < tasks.length; index += 1) {
5560
+ const outcome = outcomes[index];
5561
+ const task = tasks[index];
5562
+ if (outcome?.kind === "ok") ok.push({
4170
5563
  path: outcome.path,
4171
5564
  content: outcome.content
4172
5565
  });
4173
- else failed.push({
5566
+ else if (outcome?.kind === "failed") failed.push({
4174
5567
  path: outcome.path,
4175
5568
  reason: outcome.reason
4176
5569
  });
5570
+ else if (task && this.rateLimitError) failed.push({
5571
+ path: task.path,
5572
+ reason: `not attempted: ${this.rateLimitError.message}`
5573
+ });
5574
+ }
5575
+ if (this.rateLimitError) return {
5576
+ ok,
5577
+ failed,
5578
+ rateLimited: this.rateLimitError
5579
+ };
5580
+ for (let index = 0; index < outcomes.length; index += 1) {
5581
+ const outcome = outcomes[index];
5582
+ if (outcome?.kind !== "failed" || outcome.error === void 0) continue;
5583
+ const error = outcome.error;
5584
+ if (error instanceof IntegrityError || GithubRequestError.isAccessDenial(error)) throw error;
4177
5585
  }
4178
5586
  return {
4179
5587
  ok,
@@ -4202,18 +5610,20 @@ var GithubService = class {
4202
5610
  * Returns null on non-200, invalid response, or network failure.
4203
5611
  */
4204
5612
  async resolveCommit(owner, repo, ref) {
5613
+ const key = `commit:${owner}/${repo}@${ref}`;
4205
5614
  const url = `${this.baseUrl}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
4206
- try {
4207
- const res = await (0, cross_fetch.default)(url, { headers: {
4208
- ...this.headers,
4209
- Accept: "application/vnd.github.sha"
4210
- } });
4211
- if (!res.ok) return null;
4212
- const text = (await res.text()).trim();
4213
- return /^[0-9a-f]{40}$/i.test(text) ? text : null;
4214
- } catch {
4215
- return null;
4216
- }
5615
+ return this.sessionRequest(key, async () => {
5616
+ try {
5617
+ const text = (await (await this.request(url, { headers: {
5618
+ ...this.headers,
5619
+ Accept: "application/vnd.github.sha"
5620
+ } })).text()).trim();
5621
+ return /^[0-9a-f]{40}$/i.test(text) ? text : null;
5622
+ } catch (error) {
5623
+ if (GithubService.isActionable(error)) throw error;
5624
+ return null;
5625
+ }
5626
+ });
4217
5627
  }
4218
5628
  /**
4219
5629
  * Fetches and validates a release MANIFEST.json for a release tag.
@@ -4225,34 +5635,35 @@ var GithubService = class {
4225
5635
  * failed verification must never look like "nothing to verify".
4226
5636
  */
4227
5637
  async getReleaseManifest(owner, repo, tag) {
5638
+ const key = `release-manifest:${owner}/${repo}@${tag}`;
4228
5639
  const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/MANIFEST.json`;
4229
- let res;
4230
- try {
4231
- res = await (0, cross_fetch.default)(url);
4232
- } catch (error) {
4233
- const detail = error instanceof Error ? error.message : String(error);
4234
- throw new Error(`Failed to fetch MANIFEST.json for ${tag}: ${detail}`, { cause: error });
4235
- }
4236
- if (res.status === 404) return null;
4237
- if (!res.ok) throw new Error(`Failed to fetch MANIFEST.json for ${tag}: HTTP ${res.status}`);
4238
- let data;
4239
- try {
4240
- data = await res.json();
4241
- } catch (error) {
4242
- throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: ${error instanceof Error ? error.message : String(error)}`);
4243
- }
4244
- if (typeof data !== "object" || data === null || Array.isArray(data)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: expected an object`);
4245
- const record = data;
4246
- if (record.schema_version !== 1) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: unsupported schema_version`);
4247
- if (record.tag !== tag) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: tag does not match`);
4248
- if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/i.test(record.commit)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid commit`);
4249
- if (typeof record.files !== "object" || record.files === null || Array.isArray(record.files)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: missing files map`);
4250
- const files = record.files;
4251
- for (const [key, value] of Object.entries(files)) {
4252
- if (typeof key !== "string" || typeof value !== "string") throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: non-string entry ${key}`);
4253
- if (!/^[0-9a-f]{64}$/i.test(value)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid digest for ${key}`);
4254
- }
4255
- return record;
5640
+ return this.sessionRequest(key, async () => {
5641
+ let res;
5642
+ try {
5643
+ res = await this.request(url);
5644
+ } catch (error) {
5645
+ if (GithubService.isAbsence(error)) return null;
5646
+ throw error;
5647
+ }
5648
+ let data;
5649
+ try {
5650
+ data = await res.json();
5651
+ } catch (error) {
5652
+ throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: ${error instanceof Error ? error.message : String(error)}`);
5653
+ }
5654
+ if (typeof data !== "object" || data === null || Array.isArray(data)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: expected an object`);
5655
+ const record = data;
5656
+ if (record.schema_version !== 1) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: unsupported schema_version`);
5657
+ if (record.tag !== tag) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: tag does not match`);
5658
+ if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/i.test(record.commit)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid commit`);
5659
+ if (typeof record.files !== "object" || record.files === null || Array.isArray(record.files)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: missing files map`);
5660
+ const files = record.files;
5661
+ for (const [entry, value] of Object.entries(files)) {
5662
+ if (typeof entry !== "string" || typeof value !== "string") throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: non-string entry ${entry}`);
5663
+ if (!/^[0-9a-f]{64}$/i.test(value)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid digest for ${entry}`);
5664
+ }
5665
+ return record;
5666
+ });
4256
5667
  }
4257
5668
  };
4258
5669
  //#endregion
@@ -5020,38 +6431,6 @@ async function removeEmptyParents(rootDir, dir) {
5020
6431
  }
5021
6432
  }
5022
6433
  //#endregion
5023
- //#region src/services/install/safePath.ts
5024
- /**
5025
- * Asserts that a target file path resides inside rootDir and that NO intermediate
5026
- * directory or terminal file under rootDir is a symbolic link.
5027
- *
5028
- * Requirements:
5029
- * - rootDir may itself be a symlink (or inside one); components under rootDir must NOT be.
5030
- * - Both existing files and dangling symlinks must be detected via lstat.
5031
- * - Fails closed if lexical containment is breached or if any component under rootDir is a symlink.
5032
- *
5033
- * @param rootDir The trusted root directory
5034
- * @param targetPath The destination file/directory path to check
5035
- */
5036
- async function assertNoSymlinksUnderRoot(rootDir, targetPath) {
5037
- const resolvedRoot = path.default.resolve(rootDir);
5038
- const resolvedTarget = path.default.resolve(targetPath);
5039
- const relative = path.default.relative(resolvedRoot, resolvedTarget);
5040
- if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new Error(`Path traversal or outside target detected: "${targetPath}" is outside "${rootDir}"`);
5041
- const segments = relative.split(path.default.sep).filter(Boolean);
5042
- let current = resolvedRoot;
5043
- for (const segment of segments) {
5044
- current = path.default.join(current, segment);
5045
- try {
5046
- if ((await fs_extra.default.lstat(current)).isSymbolicLink()) throw new Error(`Symlink path component not permitted: "${current}" is a symbolic link under "${rootDir}"`);
5047
- } catch (err) {
5048
- if (err instanceof Error && err.message.includes("Symlink path component")) throw err;
5049
- if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") break;
5050
- throw err;
5051
- }
5052
- }
5053
- }
5054
- //#endregion
5055
6434
  //#region src/services/install/OwnershipWriter.ts
5056
6435
  var PassthroughWriter = class {
5057
6436
  rootDir;
@@ -5085,7 +6464,7 @@ var OwnershipWriter = class {
5085
6464
  await assertNoSymlinksUnderRoot(this.opts.rootDir, absPath);
5086
6465
  const rel = toPosixRel(this.opts.rootDir, absPath);
5087
6466
  this.touched.add(rel);
5088
- const incoming = sha256(content);
6467
+ const incoming = sha256$1(content);
5089
6468
  const entry = {
5090
6469
  ...meta,
5091
6470
  sha256: incoming
@@ -5097,7 +6476,7 @@ var OwnershipWriter = class {
5097
6476
  this.next[rel] = entry;
5098
6477
  return;
5099
6478
  }
5100
- const current = sha256(await fs_extra.default.readFile(absPath));
6479
+ const current = sha256$1(await fs_extra.default.readFile(absPath));
5101
6480
  if (current === incoming) {
5102
6481
  this.plan.unchanged.push(rel);
5103
6482
  this.next[rel] = entry;
@@ -5140,7 +6519,7 @@ var OwnershipWriter = class {
5140
6519
  const abs = path.default.join(this.opts.rootDir, rel);
5141
6520
  await assertNoSymlinksUnderRoot(this.opts.rootDir, abs);
5142
6521
  if (!await fs_extra.default.pathExists(abs)) continue;
5143
- if (sha256(await fs_extra.default.readFile(abs)) !== prev.sha256) {
6522
+ if (sha256$1(await fs_extra.default.readFile(abs)) !== prev.sha256) {
5144
6523
  this.plan.keptOrphans.push(rel);
5145
6524
  continue;
5146
6525
  }
@@ -5170,6 +6549,8 @@ var OwnershipWriter = class {
5170
6549
  };
5171
6550
  //#endregion
5172
6551
  //#region src/services/SkillSyncService.ts
6552
+ var GIT_BLOB_SHA1_RE = /^[0-9a-f]{40}$/;
6553
+ var ATTRIBUTION_FAMILY_RE = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
5173
6554
  /**
5174
6555
  * A selected registry category or package could not be assembled completely.
5175
6556
  * Callers must not write installations or lockfiles from this partial result.
@@ -5189,6 +6570,7 @@ var SkillSyncService = class {
5189
6570
  githubService;
5190
6571
  failedPackagesByCategory = /* @__PURE__ */ new Map();
5191
6572
  failedCategories = /* @__PURE__ */ new Set();
6573
+ failureDetails = /* @__PURE__ */ new Map();
5192
6574
  constructor(githubService) {
5193
6575
  this.githubService = githubService;
5194
6576
  }
@@ -5198,6 +6580,7 @@ var SkillSyncService = class {
5198
6580
  async assembleSkills(categories, config, verifiers = {}, trees = {}, origins = {}, requireSkillDefinition = false) {
5199
6581
  this.failedPackagesByCategory.clear();
5200
6582
  this.failedCategories.clear();
6583
+ this.failureDetails.clear();
5201
6584
  const collected = [];
5202
6585
  const collectedOrigins = /* @__PURE__ */ new Map();
5203
6586
  const conflicts = [];
@@ -5211,21 +6594,31 @@ var SkillSyncService = class {
5211
6594
  const catConfig = config.skills[category];
5212
6595
  const ref = catConfig.ref || "main";
5213
6596
  console.log(picocolors.default.gray(` - Discovering ${category} (${ref})...`));
5214
- const treeData = trees[category] ?? await this.githubService.getRepoTree(owner, repo, ref);
6597
+ const resolvedSource = origins[category];
6598
+ const commit = resolvedSource?.commit && GIT_BLOB_SHA1_RE.test(resolvedSource.commit) ? resolvedSource.commit : void 0;
6599
+ const verifier = verifiers[category];
6600
+ if (verifier && commit) {
6601
+ const rejection = verifier.checkSourceCommit(commit);
6602
+ if (rejection) throw new IntegrityError(`${category}@${ref}: ${rejection}`);
6603
+ }
6604
+ const treeData = trees[category] ?? (commit ? await this.githubService.getRepoTree(owner, repo, ref, { commit }) : await this.githubService.getRepoTree(owner, repo, ref));
5215
6605
  if (!treeData) {
5216
6606
  this.failedCategories.add(category);
5217
6607
  console.log(picocolors.default.red(` ❌ Failed to fetch ${category}@${ref}.`));
5218
6608
  continue;
5219
6609
  }
5220
- const resolvedSource = origins[category];
5221
6610
  const origin = resolvedSource ? {
5222
6611
  category,
5223
6612
  ref: resolvedSource.ref,
5224
6613
  commit: resolvedSource.commit
5225
6614
  } : void 0;
6615
+ const source = commit && resolvedSource ? {
6616
+ requestedRef: resolvedSource.ref,
6617
+ resolvedCommit: commit
6618
+ } : void 0;
5226
6619
  const foldersToSync = resolveSkillFolders(category, catConfig, treeData.tree, requireSkillDefinition);
5227
6620
  for (const absOrRelSkill of foldersToSync) {
5228
- const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree, verifiers);
6621
+ const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree, verifiers, source);
5229
6622
  if (skill) {
5230
6623
  const collectedSkill = origin ? {
5231
6624
  ...skill,
@@ -5250,7 +6643,11 @@ var SkillSyncService = class {
5250
6643
  const failures = [
5251
6644
  ...this.failedCategories,
5252
6645
  ...conflicts,
5253
- ...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) => `${category}/${skill}`))
6646
+ ...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) => {
6647
+ const key = `${category}/${skill}`;
6648
+ const detail = this.failureDetails.get(key);
6649
+ return detail ? `${key} (${detail})` : key;
6650
+ }))
5254
6651
  ];
5255
6652
  if (failures.length > 0) throw new SkillAssemblyError(failures);
5256
6653
  return collected;
@@ -5260,6 +6657,7 @@ var SkillSyncService = class {
5260
6657
  */
5261
6658
  async writeSkills(skills, config, agents, writer = new PassthroughWriter(process.cwd()), rootDir = process.cwd()) {
5262
6659
  const overrides = config.custom_overrides || [];
6660
+ const written = /* @__PURE__ */ new Map();
5263
6661
  for (const agentId of agents) {
5264
6662
  const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
5265
6663
  if (!basePath) continue;
@@ -5269,9 +6667,43 @@ var SkillSyncService = class {
5269
6667
  const agentDef = SUPPORTED_AGENTS.find((agent) => agent.id === agentId);
5270
6668
  if (!agentDef?.path) continue;
5271
6669
  const basePath = agentDef.path;
5272
- for (const skill of skills) await this.writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, false, rootDir);
6670
+ for (const skill of skills) {
6671
+ const bindings = await this.writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, false, rootDir);
6672
+ for (const { name, binding } of bindings) {
6673
+ const key = `${skill.category}/${skill.skill}\0${name}`;
6674
+ const existing = written.get(key) ?? [];
6675
+ if (!existing.some((current) => current.agent === binding.agent && current.path === binding.path)) existing.push(binding);
6676
+ written.set(key, existing);
6677
+ }
6678
+ }
5273
6679
  console.log(picocolors.default.gray(` - Updated ${basePath}/ (${agentDef.name})`));
5274
6680
  }
6681
+ const registry = config.registry ?? "";
6682
+ const packages = {};
6683
+ for (const skill of skills) {
6684
+ const origin = skill.origin;
6685
+ if (!origin?.commit || !GIT_BLOB_SHA1_RE.test(origin.commit)) continue;
6686
+ const files = [];
6687
+ for (const file of skill.files) {
6688
+ if (!file.attribution) continue;
6689
+ const destinations = written.get(`${skill.category}/${skill.skill}\0${file.name}`);
6690
+ if (!destinations || destinations.length === 0) continue;
6691
+ files.push({
6692
+ ...file.attribution,
6693
+ name: file.name,
6694
+ destinations
6695
+ });
6696
+ }
6697
+ if (files.length === 0) continue;
6698
+ packages[`${skill.category}/${skill.skill}`] = {
6699
+ registry,
6700
+ declaringCategory: origin.category,
6701
+ requestedRef: origin.ref,
6702
+ resolvedCommit: origin.commit,
6703
+ files
6704
+ };
6705
+ }
6706
+ return packages;
5275
6707
  }
5276
6708
  async writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, preflightOnly = false, rootDir = basePath) {
5277
6709
  const isKiro = agentId === Agent.Kiro;
@@ -5297,50 +6729,136 @@ var SkillSyncService = class {
5297
6729
  const content = isKiro && fileItem.name === "SKILL.md" ? this.transformSkillForKiro(fileItem.content, skill.category) : fileItem.bytes ?? fileItem.content;
5298
6730
  pending.push({
5299
6731
  target: targetFilePath,
5300
- content
6732
+ content,
6733
+ name: fileItem.name,
6734
+ attributed: fileItem.attribution !== void 0
5301
6735
  });
5302
6736
  }
5303
- if (pending === null) return;
5304
- for (const { target, content } of pending) await writer.write(target, content, {
5305
- owner: "skill",
5306
- source,
5307
- agent: agentId
5308
- });
6737
+ if (pending === null) return [];
6738
+ const bindings = [];
6739
+ for (const { target, content, name, attributed } of pending) {
6740
+ await writer.write(target, content, {
6741
+ owner: "skill",
6742
+ source,
6743
+ agent: agentId
6744
+ });
6745
+ if (!attributed) continue;
6746
+ const installedDigest = await this.readInstalledDigest(target);
6747
+ const writtenDigest = sha256$1(content);
6748
+ if (installedDigest !== writtenDigest) continue;
6749
+ bindings.push({
6750
+ name,
6751
+ binding: {
6752
+ agent: agentId,
6753
+ path: toPosixRel(rootDir, target),
6754
+ sha256: writtenDigest
6755
+ }
6756
+ });
6757
+ }
6758
+ return bindings;
5309
6759
  }
5310
- async fetchSkill(owner, repo, ref, category, absOrRelSkill, tree, verifiers = {}) {
6760
+ /** Digest of the file as it exists on disk, or null when unreadable. */
6761
+ async readInstalledDigest(absPath) {
6762
+ try {
6763
+ return sha256$1(await fs_extra.default.readFile(absPath));
6764
+ } catch {
6765
+ return null;
6766
+ }
6767
+ }
6768
+ async fetchSkill(owner, repo, ref, category, absOrRelSkill, tree, verifiers = {}, source) {
5311
6769
  const [sourceCat, skillName] = absOrRelSkill.includes("/") ? absOrRelSkill.split("/") : [category, absOrRelSkill];
5312
6770
  if (!sourceCat || !skillName) return null;
5313
6771
  const prefix = `skills/${sourceCat}/${skillName}/`;
5314
6772
  const packageFiles = tree.filter((file) => file.type === "blob" && file.path.startsWith(prefix) && this.isPackageResource(file.path.slice(prefix.length)));
5315
- const rootAttributionFiles = tree.filter((file) => file.type === "blob" && this.isRootAttributionFile(file.path) && !packageFiles.some((packageFile) => packageFile.path.slice(prefix.length).toLowerCase() === file.path.toLowerCase()));
5316
- const downloadTasks = [...packageFiles, ...rootAttributionFiles].sort((left, right) => left.path.localeCompare(right.path)).map((file) => ({
6773
+ const localFamilies = new Set(packageFiles.map((file) => this.attributionFamily(file.path.slice(prefix.length))).filter((family) => family !== null));
6774
+ const rootAttributionFiles = tree.filter((file) => {
6775
+ if (file.type !== "blob" || !this.isRootAttributionFile(file.path)) return false;
6776
+ const family = this.attributionFamily(file.path);
6777
+ if (!family || localFamilies.has(family)) return false;
6778
+ return !packageFiles.some((packageFile) => packageFile.path.slice(prefix.length).toLowerCase() === file.path.toLowerCase());
6779
+ });
6780
+ const selectedFiles = [...packageFiles, ...rootAttributionFiles].sort((left, right) => left.path.localeCompare(right.path));
6781
+ const downloadTasks = selectedFiles.map((file) => ({
5317
6782
  owner,
5318
6783
  repo,
5319
6784
  ref,
5320
6785
  path: file.path,
5321
- sha: file.sha
6786
+ sha: file.sha,
6787
+ ...source ? { commit: source.resolvedCommit } : {}
5322
6788
  }));
5323
6789
  const { ok: files, failed } = await this.githubService.downloadFilesConcurrentBytes(downloadTasks);
5324
6790
  const verifier = verifiers[category];
6791
+ let integrityFailure;
5325
6792
  if (verifier) for (const f of files) {
5326
- if (this.isRootAttributionFile(f.path)) continue;
6793
+ if (this.isRootAttributionFile(f.path)) {
6794
+ const rejection = verifier.checkRootAttribution(f.path, f.content);
6795
+ if (rejection) {
6796
+ failed.push({
6797
+ path: f.path,
6798
+ reason: rejection
6799
+ });
6800
+ integrityFailure ??= {
6801
+ path: f.path,
6802
+ reason: rejection
6803
+ };
6804
+ }
6805
+ continue;
6806
+ }
5327
6807
  const rejection = verifier.check(f.path, f.content);
5328
- if (rejection) failed.push({
5329
- path: f.path,
5330
- reason: rejection
5331
- });
6808
+ if (rejection) {
6809
+ failed.push({
6810
+ path: f.path,
6811
+ reason: rejection
6812
+ });
6813
+ integrityFailure ??= {
6814
+ path: f.path,
6815
+ reason: rejection
6816
+ };
6817
+ }
5332
6818
  }
5333
6819
  for (const failure of failed) console.log(picocolors.default.red(` ❌ ${sourceCat}/${skillName}: ${failure.path} — ${failure.reason}`));
5334
- if (!packageFiles.some((file) => file.path === `${prefix}SKILL.md`) || failed.length > 0 || files.length !== downloadTasks.length) return null;
6820
+ if (integrityFailure) throw new IntegrityError(`Release manifest verification failed for ${sourceCat}/${skillName}: ${integrityFailure.path} — ${integrityFailure.reason}`);
6821
+ const hasSkillDefinition = packageFiles.some((file) => file.path === `${prefix}SKILL.md`);
6822
+ if (!hasSkillDefinition || failed.length > 0 || files.length !== downloadTasks.length) {
6823
+ this.markPackageFailed(sourceCat, skillName, failed[0]?.reason ?? (hasSkillDefinition ? "downloaded file count mismatch" : "missing SKILL.md"));
6824
+ return null;
6825
+ }
6826
+ const blobShaByPath = new Map(selectedFiles.map((file) => [file.path, file.sha]));
6827
+ const records = /* @__PURE__ */ new Map();
6828
+ if (source) for (const file of files) {
6829
+ const isPackageFile = file.path.startsWith(prefix);
6830
+ const name = isPackageFile ? file.path.slice(prefix.length) : file.path;
6831
+ if (this.attributionFamily(name) === null) continue;
6832
+ const blobSha1 = blobShaByPath.get(file.path);
6833
+ if (!blobSha1 || !GIT_BLOB_SHA1_RE.test(blobSha1)) {
6834
+ this.markPackageFailed(sourceCat, skillName, `missing Git blob identity for ${file.path}`);
6835
+ return null;
6836
+ }
6837
+ const basis = verifier?.hasManifestEntry(file.path) ? "release-manifest" : "commit-tree-blob";
6838
+ records.set(name, {
6839
+ sourcePath: file.path,
6840
+ sourceKind: isPackageFile ? "package" : "root",
6841
+ requestedRef: source.requestedRef,
6842
+ resolvedCommit: source.resolvedCommit,
6843
+ blobSha1,
6844
+ sha256: sha256$1(file.content),
6845
+ basis
6846
+ });
6847
+ }
5335
6848
  console.log(picocolors.default.gray(` + Fetched ${sourceCat}/${skillName} (${files.length} files)`));
5336
6849
  return {
5337
6850
  category: sourceCat,
5338
6851
  skill: skillName,
5339
- files: files.map((file) => ({
5340
- name: file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path,
5341
- content: file.content.toString("utf8"),
5342
- bytes: file.content
5343
- }))
6852
+ files: files.map((file) => {
6853
+ const name = file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path;
6854
+ const attribution = records.get(name);
6855
+ return {
6856
+ name,
6857
+ content: file.content.toString("utf8"),
6858
+ bytes: file.content,
6859
+ ...attribution ? { attribution } : {}
6860
+ };
6861
+ })
5344
6862
  };
5345
6863
  }
5346
6864
  /**
@@ -5386,16 +6904,24 @@ var SkillSyncService = class {
5386
6904
  isPackageResource(relativePath) {
5387
6905
  return relativePath === "SKILL.md" || /^(references|scripts|assets)\//.test(relativePath) || this.isRootAttributionFile(relativePath);
5388
6906
  }
6907
+ attributionFamily(filePath) {
6908
+ if (filePath.includes("/")) return null;
6909
+ const match = ATTRIBUTION_FAMILY_RE.exec(filePath);
6910
+ if (!match) return null;
6911
+ return match[1].toLowerCase() === "license" ? "license" : "notice";
6912
+ }
5389
6913
  isRootAttributionFile(filePath) {
5390
- return !filePath.includes("/") && /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i.test(filePath);
6914
+ return this.attributionFamily(filePath) !== null;
5391
6915
  }
5392
- markPackageFailed(category, skill) {
6916
+ markPackageFailed(category, skill, detail) {
5393
6917
  let failedSkills = this.failedPackagesByCategory.get(category);
5394
6918
  if (!failedSkills) {
5395
6919
  failedSkills = /* @__PURE__ */ new Set();
5396
6920
  this.failedPackagesByCategory.set(category, failedSkills);
5397
6921
  }
5398
6922
  failedSkills.add(skill);
6923
+ const key = `${category}/${skill}`;
6924
+ if (detail && !this.failureDetails.has(key)) this.failureDetails.set(key, detail);
5399
6925
  }
5400
6926
  isRecord(value) {
5401
6927
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -6260,15 +7786,17 @@ var WorkflowSyncService = class {
6260
7786
  * need the collected files; new callers must use `acquireWorkflows` so an
6261
7787
  * incomplete fetch is distinguishable from a deliberate empty selection.
6262
7788
  */
6263
- async assembleWorkflows(config, ref = "main", verifier) {
6264
- return (await this.acquireWorkflows(config, ref, verifier)).workflows;
7789
+ async assembleWorkflows(config, ref = "main", verifier, commit) {
7790
+ return (await this.acquireWorkflows(config, ref, verifier, commit)).workflows;
6265
7791
  }
6266
7792
  /**
6267
7793
  * Acquires the selected canonical workflows and reports whether the desired
6268
7794
  * set is complete. Partial discovery/download/integrity failures are
6269
- * `incomplete` and never authorize absence-based pruning.
7795
+ * `incomplete` and never authorize absence-based pruning. When `commit` is
7796
+ * supplied, discovery and downloads use that immutable identity instead of
7797
+ * the mutable ref string.
6270
7798
  */
6271
- async acquireWorkflows(config, ref = "main", verifier) {
7799
+ async acquireWorkflows(config, ref = "main", verifier, commit) {
6272
7800
  if (config.workflows === false || config.workflows === void 0) return {
6273
7801
  status: "disabled",
6274
7802
  workflows: [],
@@ -6289,8 +7817,12 @@ var WorkflowSyncService = class {
6289
7817
  }]
6290
7818
  };
6291
7819
  const { owner, repo } = githubMatch;
7820
+ if (verifier && commit) {
7821
+ const rejection = verifier.checkSourceCommit(commit);
7822
+ if (rejection) throw new IntegrityError(`workflows@${ref}: ${rejection}`);
7823
+ }
6292
7824
  console.log(picocolors.default.gray(` - Discovering workflows (${ref})...`));
6293
- const treeData = await this.githubService.getRepoTree(owner, repo, ref);
7825
+ const treeData = await this.githubService.getRepoTree(owner, repo, ref, commit ? { commit } : void 0);
6294
7826
  if (!treeData) {
6295
7827
  console.log(picocolors.default.red(` ❌ Failed to fetch workflows@${ref}.`));
6296
7828
  return {
@@ -6317,21 +7849,21 @@ var WorkflowSyncService = class {
6317
7849
  failures: []
6318
7850
  };
6319
7851
  }
6320
- const { ok: files, failed } = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
7852
+ const downloadResult = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
6321
7853
  owner,
6322
7854
  repo,
6323
7855
  ref,
6324
7856
  path: f.path,
6325
- sha: f.sha
7857
+ sha: f.sha,
7858
+ ...commit ? { commit } : {}
6326
7859
  })));
7860
+ if (downloadResult.rateLimited) throw downloadResult.rateLimited;
7861
+ const { ok: files, failed } = downloadResult;
6327
7862
  const okFiles = [];
6328
7863
  for (const f of files) {
6329
7864
  const rejection = verifier?.check(f.path, f.content);
6330
- if (rejection) failed.push({
6331
- path: f.path,
6332
- reason: rejection
6333
- });
6334
- else okFiles.push(f);
7865
+ if (rejection) throw new IntegrityError(`${f.path}: ${rejection}`);
7866
+ okFiles.push(f);
6335
7867
  }
6336
7868
  for (const failure of failed) console.log(picocolors.default.red(` ❌ ${failure.path} — ${failure.reason}`));
6337
7869
  const status = failed.length > 0 ? "incomplete" : "complete";
@@ -6401,7 +7933,7 @@ var WorkflowSyncService = class {
6401
7933
  source: `workflow:${workflowName}`,
6402
7934
  agent: SHARED_WORKFLOW_AGENT
6403
7935
  });
6404
- const canonicalDigest = sha256(fileItem.content);
7936
+ const canonicalDigest = sha256$1(fileItem.content);
6405
7937
  writtenDigests.set(canonicalPath, canonicalDigest);
6406
7938
  result.canonical.push({
6407
7939
  name: workflowName,
@@ -6433,7 +7965,7 @@ var WorkflowSyncService = class {
6433
7965
  continue;
6434
7966
  }
6435
7967
  if (this.isOverridden(targetFilePath, overrides)) continue;
6436
- const outputDigest = sha256(transformed.content);
7968
+ const outputDigest = sha256$1(transformed.content);
6437
7969
  const existingDigest = writtenDigests.get(targetFilePath);
6438
7970
  if (existingDigest === void 0) {
6439
7971
  await writer.write(targetFilePath, transformed.content, {
@@ -6452,7 +7984,7 @@ var WorkflowSyncService = class {
6452
7984
  agent: agentId,
6453
7985
  path: this.normalizePath(targetFilePath),
6454
7986
  outputDigest,
6455
- canonicalDigest: sha256(fileItem.content)
7987
+ canonicalDigest: sha256$1(fileItem.content)
6456
7988
  });
6457
7989
  }
6458
7990
  }
@@ -6760,33 +8292,66 @@ var SpecialistTransformer = class {
6760
8292
  };
6761
8293
  //#endregion
6762
8294
  //#region src/services/SpecialistSyncService.ts
6763
- /**
6764
- * Service responsible for syncing specialist skills from the internal registry
6765
- * to native agent configuration directories.
6766
- */
6767
8295
  var SpecialistSyncService = class {
6768
8296
  githubService;
6769
8297
  constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
6770
8298
  this.githubService = githubService;
6771
8299
  }
6772
- async assembleSpecialists(config, ref = "main", verifier) {
8300
+ /**
8301
+ * Acquires every remote specialist, reporting whether the selected set is
8302
+ * complete. Fetches are bound to `commit` when the caller resolved one, so a
8303
+ * tag that moved after resolution cannot supply different bytes.
8304
+ */
8305
+ async acquireSpecialists(config, ref = "main", verifier, commit) {
6773
8306
  const githubMatch = GithubService.parseGitHubUrl(config.registry);
6774
- if (!githubMatch) return [];
8307
+ if (!githubMatch) return {
8308
+ status: "incomplete",
8309
+ specialists: [],
8310
+ failures: [{
8311
+ path: "registry",
8312
+ reason: `Unsupported registry URL '${config.registry}'.`
8313
+ }]
8314
+ };
6775
8315
  const { owner, repo } = githubMatch;
6776
- const treeData = await this.githubService.getRepoTree(owner, repo, ref);
6777
- if (!treeData) return [];
8316
+ const treeOptions = commit ? { commit } : void 0;
8317
+ if (verifier && commit) {
8318
+ const rejection = verifier.checkSourceCommit(commit);
8319
+ if (rejection) throw new IntegrityError(`specialists@${ref}: ${rejection}`);
8320
+ }
8321
+ const treeData = await this.githubService.getRepoTree(owner, repo, ref, treeOptions);
8322
+ if (!treeData) return {
8323
+ status: "incomplete",
8324
+ specialists: [],
8325
+ failures: [{
8326
+ path: "specialists",
8327
+ reason: `Failed to fetch specialists@${ref}.`
8328
+ }]
8329
+ };
6778
8330
  const skillFiles = treeData.tree.filter((item) => item.type === "blob" && item.path.startsWith("skills/specialists/") && item.path.endsWith("/SKILL.md"));
6779
8331
  const specialists = [];
8332
+ const failures = [];
6780
8333
  for (const file of skillFiles) {
6781
8334
  const specialistName = file.path.split("/")[2];
6782
- if (!specialistName) continue;
6783
- const content = await this.githubService.getRawFile(owner, repo, ref, file.path);
6784
- if (!content) continue;
6785
- const rejection = verifier?.check(file.path, content);
6786
- if (rejection) {
6787
- console.log(picocolors.default.red(` ❌ ${file.path} — ${rejection}`));
8335
+ if (!specialistName) {
8336
+ failures.push({
8337
+ path: file.path,
8338
+ reason: "specialist directory name is missing"
8339
+ });
8340
+ continue;
8341
+ }
8342
+ const content = await this.githubService.getRawFile(owner, repo, ref, file.path, {
8343
+ expectedSha: file.sha,
8344
+ ...commit ? { commit } : {}
8345
+ });
8346
+ if (!content) {
8347
+ failures.push({
8348
+ path: file.path,
8349
+ reason: "download failed"
8350
+ });
6788
8351
  continue;
6789
8352
  }
8353
+ const rejection = verifier?.check(file.path, content);
8354
+ if (rejection) throw new IntegrityError(`${file.path}: ${rejection}`);
6790
8355
  specialists.push({
6791
8356
  category: "specialists",
6792
8357
  skill: specialistName,
@@ -6796,9 +8361,13 @@ var SpecialistSyncService = class {
6796
8361
  }]
6797
8362
  });
6798
8363
  }
6799
- return specialists;
8364
+ return {
8365
+ status: failures.length > 0 ? "incomplete" : "complete",
8366
+ specialists,
8367
+ failures
8368
+ };
6800
8369
  }
6801
- async syncCollectedSpecialists(rootDir, agents, specialists, writer = new PassthroughWriter(rootDir)) {
8370
+ async writeSpecialists(rootDir, agents, specialists, writer = new PassthroughWriter(rootDir)) {
6802
8371
  if (specialists.length === 0) return 0;
6803
8372
  for (const agentId of agents) {
6804
8373
  const agentDef = SUPPORTED_AGENTS.find((a) => a.id === agentId);
@@ -6825,20 +8394,18 @@ var SpecialistSyncService = class {
6825
8394
  return specialists.length;
6826
8395
  }
6827
8396
  /**
6828
- * Syncs all specialists from a source directory to active agents.
6829
- * @param rootDir Project root directory
6830
- * @param agents List of agents to sync for
6831
- * @param sourceDir Optional custom source directory for specialists
8397
+ * Reads every specialist from a project-local source directory. This is a
8398
+ * local acquisition with no network I/O; the caller publishes the result
8399
+ * through `writeSpecialists` after the shared preflight gates.
6832
8400
  */
6833
- async syncSpecialists(rootDir, agents, sourceDir, writer = new PassthroughWriter(rootDir)) {
6834
- const specialistsDir = sourceDir || path.default.join(rootDir, "skills/specialists");
6835
- if (!await fs_extra.default.pathExists(specialistsDir)) return 0;
6836
- const specialistFolders = (await fs_extra.default.readdir(specialistsDir)).filter((f) => {
6837
- return fs_extra.default.statSync(path.default.join(specialistsDir, f)).isDirectory();
8401
+ async readLocalSpecialists(sourceDir) {
8402
+ if (!await fs_extra.default.pathExists(sourceDir)) return [];
8403
+ const specialistFolders = (await fs_extra.default.readdir(sourceDir)).filter((f) => {
8404
+ return fs_extra.default.statSync(path.default.join(sourceDir, f)).isDirectory();
6838
8405
  });
6839
8406
  const collected = [];
6840
8407
  for (const folder of specialistFolders) {
6841
- const skillPath = path.default.join(specialistsDir, folder, "SKILL.md");
8408
+ const skillPath = path.default.join(sourceDir, folder, "SKILL.md");
6842
8409
  if (!await fs_extra.default.pathExists(skillPath)) continue;
6843
8410
  const content = await fs_extra.default.readFile(skillPath, "utf8");
6844
8411
  collected.push({
@@ -6850,7 +8417,7 @@ var SpecialistSyncService = class {
6850
8417
  }]
6851
8418
  });
6852
8419
  }
6853
- return this.syncCollectedSpecialists(rootDir, agents, collected, writer);
8420
+ return collected;
6854
8421
  }
6855
8422
  };
6856
8423
  //#endregion
@@ -7123,22 +8690,120 @@ var BackupService = class {
7123
8690
  };
7124
8691
  //#endregion
7125
8692
  //#region src/services/install/ManifestVerifier.ts
8693
+ function normalizeRepoPath(repoPath) {
8694
+ return repoPath.replace(/^(\.\/|\/)+/, "");
8695
+ }
8696
+ function sha256(content) {
8697
+ return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
8698
+ }
8699
+ function isListed(manifest, repoPath) {
8700
+ return Object.prototype.hasOwnProperty.call(manifest.files, repoPath);
8701
+ }
7126
8702
  var ManifestVerifier = class {
7127
8703
  manifest;
7128
8704
  constructor(manifest) {
7129
8705
  this.manifest = manifest;
7130
8706
  }
8707
+ /** Resolved commit this manifest claims, or null when no manifest exists. */
8708
+ get commit() {
8709
+ return this.manifest?.commit ?? null;
8710
+ }
8711
+ /**
8712
+ * A manifest used for selected-source verification must describe the exact
8713
+ * resolved commit. A syntactically valid but different commit is an
8714
+ * integrity failure, never a silently skipped verification.
8715
+ */
8716
+ checkSourceCommit(expected) {
8717
+ if (!this.manifest) return null;
8718
+ if (this.manifest.commit === expected) return null;
8719
+ return `release MANIFEST.json commit '${this.manifest.commit}' does not match the resolved source commit '${expected}'`;
8720
+ }
8721
+ /**
8722
+ * Whether the schema-1 files map binds this exact repo path. Root LICENSE
8723
+ * and NOTICE entries are ordinary files-map entries, not a separate
8724
+ * namespace.
8725
+ */
8726
+ hasManifestEntry(repoPath) {
8727
+ if (!this.manifest) return false;
8728
+ return isListed(this.manifest, normalizeRepoPath(repoPath));
8729
+ }
7131
8730
  /** Returns null when OK (or no manifest), else a rejection reason. */
7132
8731
  check(repoPath, content) {
7133
8732
  if (!this.manifest) return null;
7134
- const normalized = repoPath.replace(/^(\.\/|\/)+/, "");
7135
- if (!Object.prototype.hasOwnProperty.call(this.manifest.files, normalized)) return "not listed in release MANIFEST.json";
8733
+ const normalized = normalizeRepoPath(repoPath);
8734
+ if (!isListed(this.manifest, normalized)) return "not listed in release MANIFEST.json";
7136
8735
  const expectedSha = this.manifest.files[normalized];
7137
- if ((0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex") !== expectedSha) return "sha256 does not match release MANIFEST.json";
8736
+ if (sha256(content) !== expectedSha) return "sha256 does not match release MANIFEST.json";
8737
+ return null;
8738
+ }
8739
+ /**
8740
+ * Root LICENSE/NOTICE live outside every category release scope, so legacy
8741
+ * manifests do not list them. A listed root entry is always enforced; an
8742
+ * unlisted one leaves the caller's verified commit-tree/blob basis intact
8743
+ * instead of inventing a manifest verification that never happened.
8744
+ */
8745
+ checkRootAttribution(repoPath, content) {
8746
+ if (!this.manifest) return null;
8747
+ const normalized = normalizeRepoPath(repoPath);
8748
+ if (!isListed(this.manifest, normalized)) return null;
8749
+ if (sha256(content) !== this.manifest.files[normalized]) return "sha256 does not match release MANIFEST.json root attribution";
7138
8750
  return null;
7139
8751
  }
7140
8752
  };
7141
8753
  //#endregion
8754
+ //#region src/services/install/UpdatePolicy.ts
8755
+ /**
8756
+ * Automatic version-update policy for release-pinned categories, workflows,
8757
+ * and specialists.
8758
+ *
8759
+ * A newer advertised release may only replace a configured pin automatically
8760
+ * when both refs are canonical stable semantic-version refs for the same tag
8761
+ * prefix and the candidate is strictly newer. Branches, commits, custom refs,
8762
+ * prereleases, downgrades, and equal versions stay explicit: accepting them
8763
+ * would silently move a user to incomparable source.
8764
+ */
8765
+ var STABLE_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
8766
+ /**
8767
+ * @param currentRef currently configured ref, if the project already pinned one
8768
+ * @param candidateRef ref advertised by the registry for the same category
8769
+ * @param tagPrefix tag prefix published for that category (may be empty)
8770
+ * @returns true only for a strictly newer comparable stable release ref
8771
+ */
8772
+ function isAutomaticUpgrade(currentRef, candidateRef, tagPrefix) {
8773
+ if (typeof currentRef !== "string" || typeof candidateRef !== "string") return false;
8774
+ const current = parseStableVersion(currentRef, tagPrefix);
8775
+ const candidate = parseStableVersion(candidateRef, tagPrefix);
8776
+ if (!current || !candidate) return false;
8777
+ for (let index = 0; index < 3; index += 1) {
8778
+ const order = compareNumericStrings(current[index], candidate[index]);
8779
+ if (order !== 0) return order < 0;
8780
+ }
8781
+ return false;
8782
+ }
8783
+ /**
8784
+ * Strips the category tag prefix and requires a canonical `MAJOR.MINOR.PATCH`
8785
+ * core. Numeric components are returned as digit strings so comparison stays
8786
+ * exact beyond `Number.MAX_SAFE_INTEGER`.
8787
+ */
8788
+ function parseStableVersion(ref, tagPrefix) {
8789
+ if (!ref.startsWith(tagPrefix)) return null;
8790
+ const version = ref.slice(tagPrefix.length);
8791
+ const match = STABLE_VERSION.exec(version);
8792
+ if (!match || match[0] !== version) return null;
8793
+ const components = [
8794
+ match[1],
8795
+ match[2],
8796
+ match[3]
8797
+ ];
8798
+ for (const component of components) if (component.length > 1 && component.startsWith("0")) return null;
8799
+ return components;
8800
+ }
8801
+ function compareNumericStrings(left, right) {
8802
+ if (left.length !== right.length) return left.length < right.length ? -1 : 1;
8803
+ if (left === right) return 0;
8804
+ return left < right ? -1 : 1;
8805
+ }
8806
+ //#endregion
7142
8807
  //#region src/services/install/RefResolver.ts
7143
8808
  var RefResolver = class {
7144
8809
  github;
@@ -7225,21 +8890,21 @@ var RefResolver = class {
7225
8890
  //#endregion
7226
8891
  //#region src/services/SyncService.ts
7227
8892
  var defaultExec = (cmd, args) => {
7228
- const { promise, resolve, reject } = Promise.withResolvers();
7229
- (0, node_child_process.execFile)(cmd, args, (error, _stdout, stderr) => {
7230
- if (error) {
7231
- if (error.code === "ENOENT") return reject(error);
7232
- return resolve({
7233
- code: typeof error.code === "number" ? error.code : 1,
7234
- stderr: stderr || error.message
8893
+ return new Promise((resolve, reject) => {
8894
+ (0, node_child_process.execFile)(cmd, args, (error, _stdout, stderr) => {
8895
+ if (error) {
8896
+ if (error.code === "ENOENT") return reject(error);
8897
+ return resolve({
8898
+ code: typeof error.code === "number" ? error.code : 1,
8899
+ stderr: stderr || error.message
8900
+ });
8901
+ }
8902
+ resolve({
8903
+ code: 0,
8904
+ stderr: stderr || ""
7235
8905
  });
7236
- }
7237
- resolve({
7238
- code: 0,
7239
- stderr: stderr || ""
7240
8906
  });
7241
8907
  });
7242
- return promise;
7243
8908
  };
7244
8909
  /**
7245
8910
  * Rejects an incomplete or malformed repository tree. A truncated or shaped
@@ -7278,6 +8943,8 @@ function assertIncludeTargetExists(category, expression, targetCategory, targetS
7278
8943
  const skillPath = `${categoryPath}/${targetSkill}/SKILL.md`;
7279
8944
  if (!tree.tree.some((item) => item.type === "blob" && item.path === skillPath)) throw new SkillAssemblyError([`${category} include ${expression} (requested skill missing)`]);
7280
8945
  }
8946
+ /** Bounded attribution-family filename used for installed variant discovery. */
8947
+ var ATTRIBUTION_FILE_NAME_RE = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
7281
8948
  /**
7282
8949
  * Service responsible for coordinating the synchronization of agent skills and workflows.
7283
8950
  * It acts as a facade, delegating specialized synchronization tasks to SkillSyncService
@@ -7306,6 +8973,8 @@ var SyncService = class {
7306
8973
  };
7307
8974
  warnedMovedTags = /* @__PURE__ */ new Set();
7308
8975
  installScope = "all";
8976
+ /** Attribution records for the packages actually published by this run. */
8977
+ skillAttributionPackages = null;
7309
8978
  constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
7310
8979
  this.githubService = githubService;
7311
8980
  this.skillSyncService = new SkillSyncService(githubService);
@@ -7328,7 +8997,14 @@ var SyncService = class {
7328
8997
  async assembleSkills(categories, config, trees, origins, requireSkillDefinition = false) {
7329
8998
  await this.warnIfSyncingFromSameRepo(config);
7330
8999
  const skillCategories = categories.filter((category) => category !== "specialists");
7331
- return this.skillSyncService.assembleSkills(skillCategories, config, this.manifestVerifiers.skills, trees, origins, requireSkillDefinition);
9000
+ const sourceOrigins = origins ?? Object.fromEntries(skillCategories.filter((category) => this.resolvedRefs?.skills[category]).map((category) => {
9001
+ const source = this.resolvedRefs.skills[category];
9002
+ return [category, {
9003
+ ref: source.ref,
9004
+ commit: source.commit
9005
+ }];
9006
+ }));
9007
+ return this.skillSyncService.assembleSkills(skillCategories, config, this.manifestVerifiers.skills, trees, sourceOrigins, requireSkillDefinition);
7332
9008
  }
7333
9009
  /**
7334
9010
  * Prefetches only the skill sources for a scoped apply. Resolves every
@@ -7438,6 +9114,7 @@ var SyncService = class {
7438
9114
  const { lock, migratedFromV1 } = await this.lockfileService.load(process.cwd(), agents);
7439
9115
  this.dryRun = opts.dryRun;
7440
9116
  this.previousLock = lock;
9117
+ this.skillAttributionPackages = null;
7441
9118
  this.pendingWorkflowWrites = {
7442
9119
  canonical: [],
7443
9120
  bindings: []
@@ -7500,11 +9177,61 @@ var SyncService = class {
7500
9177
  entries
7501
9178
  };
7502
9179
  if (catalog) lock.workflowCatalog = catalog;
9180
+ const attribution = await this.buildAttributionCatalog(configured, entries);
9181
+ if (attribution) lock.attributionCatalog = attribution;
7503
9182
  await this.lockfileService.write(process.cwd(), lock);
7504
9183
  }
7505
9184
  return plan;
7506
9185
  }
7507
9186
  /**
9187
+ * Merges this run's published attribution over the previous catalog at file
9188
+ * granularity. A previous file survives only when the declaring category is
9189
+ * still configured, this run did not publish replacement bytes for that file,
9190
+ * and every recorded destination still survives ownership finalization with
9191
+ * the exact recorded bytes. Preserved overrides/untouched files therefore
9192
+ * keep their provenance instead of reading as removals; pruned destinations,
9193
+ * removed agents, unconfigured categories and edited copies still drop.
9194
+ */
9195
+ async buildAttributionCatalog(configured, entries) {
9196
+ const previous = this.previousLock?.attributionCatalog;
9197
+ const written = this.skillAttributionPackages;
9198
+ if (!written && !previous) return void 0;
9199
+ const mergedPackages = {};
9200
+ for (const [packageId, record] of Object.entries(previous?.packages ?? {})) {
9201
+ if (!configured.has(record.declaringCategory)) continue;
9202
+ const replacement = written?.[packageId];
9203
+ const retainedFiles = [];
9204
+ for (const file of record.files) {
9205
+ if (replacement?.files.some((candidate) => candidate.name === file.name)) continue;
9206
+ if (await this.attributionFileDestinationsSurvive(file, entries)) retainedFiles.push(file);
9207
+ }
9208
+ if (retainedFiles.length === 0) continue;
9209
+ mergedPackages[packageId] = {
9210
+ ...replacement ?? record,
9211
+ files: [...replacement?.files ?? [], ...retainedFiles]
9212
+ };
9213
+ }
9214
+ const merged = buildAttributionCatalog({
9215
+ ...mergedPackages,
9216
+ ...written ?? {}
9217
+ });
9218
+ if (previous) return merged;
9219
+ return Object.keys(merged.packages).length > 0 ? merged : void 0;
9220
+ }
9221
+ /**
9222
+ * A retained file must describe only bytes that are still installed: every
9223
+ * destination has to survive ownership finalization for the recorded agent
9224
+ * and still hash to its recorded digest.
9225
+ */
9226
+ async attributionFileDestinationsSurvive(file, entries) {
9227
+ for (const binding of file.destinations) {
9228
+ const entry = entries[binding.path];
9229
+ if (!entry || entry.agent !== binding.agent) return false;
9230
+ if (await readInstalledAttributionDigest(process.cwd(), binding.path) !== binding.sha256) return false;
9231
+ }
9232
+ return true;
9233
+ }
9234
+ /**
7508
9235
  * Builds the catalog from files that survived ownership reconciliation.
7509
9236
  * Canonical bodies that were kept edited/overridden and native exports whose
7510
9237
  * on-disk bytes differ from the deterministic output are omitted rather than
@@ -7539,7 +9266,7 @@ var SyncService = class {
7539
9266
  } catch {
7540
9267
  continue;
7541
9268
  }
7542
- const digest = sha256(canonicalBytes);
9269
+ const digest = sha256$1(canonicalBytes);
7543
9270
  if (digest !== planned.digest) continue;
7544
9271
  const nativeBindings = [];
7545
9272
  const nativeConflicts = [];
@@ -7550,7 +9277,7 @@ var SyncService = class {
7550
9277
  try {
7551
9278
  outputBytes = await fs_extra.default.readFile(outputPath);
7552
9279
  } catch {}
7553
- if (outputBytes === null || sha256(outputBytes) !== binding.outputDigest) {
9280
+ if (outputBytes === null || sha256$1(outputBytes) !== binding.outputDigest) {
7554
9281
  nativeConflicts.push({
7555
9282
  agent: binding.agent,
7556
9283
  path: binding.path
@@ -7700,28 +9427,244 @@ var SyncService = class {
7700
9427
  async writeSkills(skills, config) {
7701
9428
  if (!this.dryRun && this.installScope !== "skills") await this.notifyLegacyAgentFolder();
7702
9429
  const agents = await this.resolveTargetAgents(config);
7703
- await this.skillSyncService.writeSkills(skills, config, agents, this.writer);
9430
+ this.skillAttributionPackages = await this.skillSyncService.writeSkills(skills, config, agents, this.writer);
7704
9431
  }
7705
9432
  async assembleWorkflows(config) {
7706
9433
  const ref = this.resolvedRefs?.workflows.ref || config.workflows_ref || "main";
7707
- return this.workflowSyncService.assembleWorkflows(config, ref, this.manifestVerifiers.workflows);
9434
+ return this.workflowSyncService.assembleWorkflows(config, ref, this.manifestVerifiers.workflows, this.resolvedRefs?.workflows.commit ?? void 0);
7708
9435
  }
7709
9436
  async acquireWorkflows(config) {
7710
9437
  const ref = this.resolvedRefs?.workflows.ref || config.workflows_ref || "main";
7711
- return this.workflowSyncService.acquireWorkflows(config, ref, this.manifestVerifiers.workflows);
9438
+ return this.workflowSyncService.acquireWorkflows(config, ref, this.manifestVerifiers.workflows, this.resolvedRefs?.workflows.commit ?? void 0);
7712
9439
  }
7713
9440
  async writeWorkflows(workflows, config) {
7714
9441
  const agents = await this.resolveTargetAgents(config);
7715
9442
  this.pendingWorkflowWrites = await this.workflowSyncService.writeWorkflows(workflows, config, agents, this.writer);
7716
9443
  }
7717
- async syncSpecialists(config) {
7718
- const agents = await this.resolveTargetAgents(config);
7719
- if (agents.length === 0) return true;
9444
+ /**
9445
+ * Acquires every desired specialist before any managed publication. A local
9446
+ * `skills/specialists` directory is the project's own source and is complete
9447
+ * by construction; otherwise the pinned remote source is acquired and its
9448
+ * completeness is reported explicitly.
9449
+ */
9450
+ async acquireSpecialists(config) {
7720
9451
  const localRegistrySource = path.default.join(process.cwd(), "skills/specialists");
7721
- if (await fs_extra.default.pathExists(localRegistrySource)) return await this.specialistSyncService.syncSpecialists(process.cwd(), agents, localRegistrySource, this.writer) > 0;
9452
+ if (await fs_extra.default.pathExists(localRegistrySource)) return {
9453
+ status: "complete",
9454
+ specialists: await this.specialistSyncService.readLocalSpecialists(localRegistrySource),
9455
+ failures: []
9456
+ };
7722
9457
  const ref = this.resolvedRefs?.specialists.ref || config.specialists_ref || "main";
7723
- const specialists = await this.specialistSyncService.assembleSpecialists(config, ref, this.manifestVerifiers.specialists);
7724
- return await this.specialistSyncService.syncCollectedSpecialists(process.cwd(), agents, specialists, this.writer) > 0;
9458
+ return this.specialistSyncService.acquireSpecialists(config, ref, this.manifestVerifiers.specialists, this.resolvedRefs?.specialists.commit ?? void 0);
9459
+ }
9460
+ /**
9461
+ * Publishes the specialists acquired during preflight. Returns whether any
9462
+ * specialist is desired, which is the only evidence that authorizes pruning
9463
+ * previously installed specialist exports.
9464
+ */
9465
+ async writeSpecialists(acquisition, config) {
9466
+ const agents = await this.resolveTargetAgents(config);
9467
+ if (agents.length === 0) return true;
9468
+ await this.specialistSyncService.writeSpecialists(process.cwd(), agents, acquisition.specialists, this.writer);
9469
+ return acquisition.specialists.length > 0;
9470
+ }
9471
+ /** The typed throttle failure, if this operation latched one. */
9472
+ get rateLimitFailure() {
9473
+ return this.githubService.rateLimitFailure;
9474
+ }
9475
+ /**
9476
+ * Builds the attribution plan for the acquired (unpublished) skills and
9477
+ * compares it with the qualified state of the current installation. Nothing
9478
+ * is written: the command layer owns consent and publication.
9479
+ *
9480
+ * Destination bindings are derived from the same agent layout the ownership
9481
+ * writer will use, so the collector reads the bytes a previous run would
9482
+ * have installed at exactly those paths. The candidate digest recorded here
9483
+ * is the source digest; publication re-derives the installed digest from the
9484
+ * actual written bytes.
9485
+ */
9486
+ async planAttribution(skills, config) {
9487
+ const agents = await this.resolveTargetAgents(config);
9488
+ const overrides = config.custom_overrides ?? [];
9489
+ const registry = config.registry ?? "";
9490
+ const previousCatalog = this.previousLock?.attributionCatalog;
9491
+ const planned = {};
9492
+ /** `<packageId>\0<fileName>` -> effective destination paths. */
9493
+ const publishedPaths = /* @__PURE__ */ new Set();
9494
+ const agentDir = (agentId, skill) => {
9495
+ const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
9496
+ if (!basePath) return null;
9497
+ return agentId === Agent.Kiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
9498
+ };
9499
+ for (const skill of skills) {
9500
+ const origin = skill.origin;
9501
+ const packageId = `${skill.category}/${skill.skill}`;
9502
+ const files = [];
9503
+ for (const file of skill.files) {
9504
+ if (!file.attribution) continue;
9505
+ const destinations = [];
9506
+ for (const agentId of agents) {
9507
+ const dir = agentDir(agentId, skill);
9508
+ if (!dir) continue;
9509
+ const relative = toPosixRel(process.cwd(), path.default.join(dir, file.name));
9510
+ if (isOverriddenRel(relative, overrides)) continue;
9511
+ publishedPaths.add(`${packageId}\0${file.name}\0${relative}`);
9512
+ destinations.push({
9513
+ agent: agentId,
9514
+ path: relative,
9515
+ sha256: file.attribution.sha256
9516
+ });
9517
+ }
9518
+ if (destinations.length === 0) continue;
9519
+ files.push({
9520
+ ...file.attribution,
9521
+ name: file.name,
9522
+ destinations
9523
+ });
9524
+ }
9525
+ const previous = previousCatalog?.packages[packageId];
9526
+ const preserved = this.preservedAttributionFiles(packageId, previous, publishedPaths, overrides, config.prune !== false);
9527
+ if (files.length === 0) {
9528
+ if (previous && preserved.length === previous.files.length) planned[packageId] = {
9529
+ ...previous,
9530
+ files: preserved
9531
+ };
9532
+ continue;
9533
+ }
9534
+ if (!origin?.commit || !/^[0-9a-f]{40}$/.test(origin.commit)) continue;
9535
+ for (const file of preserved) if (!files.some((candidate) => candidate.name === file.name)) files.push(file);
9536
+ planned[packageId] = {
9537
+ registry,
9538
+ declaringCategory: origin.category,
9539
+ requestedRef: origin.ref,
9540
+ resolvedCommit: origin.commit,
9541
+ files
9542
+ };
9543
+ }
9544
+ for (const [packageId, record] of Object.entries(previousCatalog?.packages ?? {})) {
9545
+ if (planned[packageId]) continue;
9546
+ const preserved = this.preservedAttributionFiles(packageId, record, publishedPaths, overrides, config.prune !== false);
9547
+ if (preserved.length !== record.files.length) continue;
9548
+ planned[packageId] = {
9549
+ ...record,
9550
+ files: preserved
9551
+ };
9552
+ }
9553
+ const installedOnly = await this.discoverInstalledAttributionVariants(skills, planned, previousCatalog?.packages ?? {}, agents, overrides, publishedPaths);
9554
+ const installed = await collectInstalledAttributionDigests(process.cwd(), this.mergeAttributionDestinations(planned, previousCatalog?.packages ?? {}));
9555
+ const hasInstalledBytes = installedOnly.some((variant) => variant.sha256 !== null) || Object.values(installed).some((perFile) => Object.values(perFile).some((bindings) => bindings.some((binding) => binding.sha256 !== null)));
9556
+ return {
9557
+ planned,
9558
+ installed,
9559
+ installedOnly,
9560
+ comparison: compareAttribution({
9561
+ planned,
9562
+ previousCatalog,
9563
+ hasExistingInstallation: Object.keys(this.previousLock?.entries ?? {}).length > 0 || hasInstalledBytes,
9564
+ installed
9565
+ })
9566
+ };
9567
+ }
9568
+ /**
9569
+ * Previous records whose bytes this run will not touch: every recorded
9570
+ * destination is either a configured override or (when pruning is disabled)
9571
+ * a destination the ownership finalizer keeps. The record is returned
9572
+ * verbatim so the comparison sees preserved bytes, not a removal.
9573
+ */
9574
+ preservedAttributionFiles(packageId, previous, publishedPaths, overrides, prune) {
9575
+ if (!previous) return [];
9576
+ const preserved = [];
9577
+ for (const file of previous.files) if (file.destinations.every((destination) => {
9578
+ if (publishedPaths.has(`${packageId}\0${file.name}\0${destination.path}`)) return false;
9579
+ return isOverriddenRel(destination.path, overrides) || !prune;
9580
+ })) preserved.push({ ...file });
9581
+ return preserved;
9582
+ }
9583
+ /**
9584
+ * Bounded discovery of attribution-family files that exist in installed
9585
+ * package directories but appear in neither the candidate nor the catalog.
9586
+ * Only effective package directories are read — candidate packages plus
9587
+ * directories referenced by previous destinations — only top-level
9588
+ * LICENSE/NOTICE variants are considered, and the actual installed digest is
9589
+ * reported with no invented origin.
9590
+ */
9591
+ async discoverInstalledAttributionVariants(skills, planned, previous, agents, overrides, publishedPaths) {
9592
+ const knownPaths = /* @__PURE__ */ new Set();
9593
+ for (const record of [...Object.values(planned), ...Object.values(previous)]) for (const file of record.files) for (const destination of file.destinations) knownPaths.add(destination.path);
9594
+ const scan = /* @__PURE__ */ new Map();
9595
+ for (const skill of skills) {
9596
+ const packageId = `${skill.category}/${skill.skill}`;
9597
+ for (const agentId of agents) {
9598
+ const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
9599
+ if (!basePath) continue;
9600
+ const dir = agentId === Agent.Kiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
9601
+ scan.set(dir, {
9602
+ packageId,
9603
+ agent: agentId,
9604
+ dir
9605
+ });
9606
+ }
9607
+ }
9608
+ for (const [packageId, record] of Object.entries(previous)) for (const file of record.files) for (const destination of file.destinations) {
9609
+ const dir = path.default.dirname(path.default.join(process.cwd(), destination.path));
9610
+ scan.set(dir, {
9611
+ packageId,
9612
+ agent: destination.agent,
9613
+ dir
9614
+ });
9615
+ }
9616
+ const variants = /* @__PURE__ */ new Map();
9617
+ for (const { packageId, agent, dir } of scan.values()) {
9618
+ let entries;
9619
+ try {
9620
+ entries = await fs_extra.default.readdir(dir);
9621
+ } catch {
9622
+ continue;
9623
+ }
9624
+ for (const entry of entries) {
9625
+ if (!ATTRIBUTION_FILE_NAME_RE.test(entry)) continue;
9626
+ const relative = toPosixRel(process.cwd(), path.default.join(dir, entry));
9627
+ if (knownPaths.has(relative) || publishedPaths.has(`${packageId}\0${entry}\0${relative}`) || isOverriddenRel(relative, overrides)) continue;
9628
+ variants.set(relative, {
9629
+ packageId,
9630
+ name: entry,
9631
+ agent,
9632
+ path: relative,
9633
+ sha256: await readInstalledAttributionDigest(process.cwd(), relative)
9634
+ });
9635
+ }
9636
+ }
9637
+ return [...variants.values()].sort((left, right) => left.path.localeCompare(right.path));
9638
+ }
9639
+ /**
9640
+ * Unions the planned and previously recorded destination sets per package
9641
+ * and filename so the installed-state read covers both sides of the
9642
+ * comparison. Extra bindings never qualify a record; they only let the
9643
+ * comparison see the real bytes at every path it may judge.
9644
+ */
9645
+ mergeAttributionDestinations(planned, previous) {
9646
+ const merged = { ...planned };
9647
+ for (const [packageId, record] of Object.entries(previous)) {
9648
+ const base = merged[packageId];
9649
+ if (!base) {
9650
+ merged[packageId] = record;
9651
+ continue;
9652
+ }
9653
+ const files = base.files.map((file) => ({ ...file }));
9654
+ for (const priorFile of record.files) {
9655
+ const existing = files.find((file) => file.name === priorFile.name);
9656
+ if (!existing) {
9657
+ files.push({ ...priorFile });
9658
+ continue;
9659
+ }
9660
+ for (const destination of priorFile.destinations) if (!existing.destinations.some((current) => current.agent === destination.agent && current.path === destination.path)) existing.destinations.push(destination);
9661
+ }
9662
+ merged[packageId] = {
9663
+ ...base,
9664
+ files
9665
+ };
9666
+ }
9667
+ return merged;
7725
9668
  }
7726
9669
  async applyIndices(config, targetAgents, localMetadata, desiredSkillIds, options) {
7727
9670
  const agents = targetAgents || await this.resolveTargetAgents(config);
@@ -7796,22 +9739,25 @@ var SyncService = class {
7796
9739
  for (const [cat, catConfig] of Object.entries(config.skills)) {
7797
9740
  const remoteMetaCat = remoteMeta.categories?.[cat];
7798
9741
  if (!remoteMetaCat?.version) continue;
7799
- const latestRef = `${remoteMetaCat.tag_prefix || ""}${remoteMetaCat.version}`;
7800
- if (catConfig.ref !== latestRef) candidates.push({
9742
+ const prefix = remoteMetaCat.tag_prefix || "";
9743
+ const latestRef = `${prefix}${remoteMetaCat.version}`;
9744
+ if (catConfig.ref === void 0 || isAutomaticUpgrade(catConfig.ref, latestRef, prefix)) candidates.push({
7801
9745
  key: cat,
7802
9746
  ref: latestRef
7803
9747
  });
7804
9748
  }
7805
9749
  if (remoteMeta.releases?.workflows?.version) {
7806
- const latestWorkflowsRef = `${remoteMeta.releases.workflows.tag_prefix || ""}${remoteMeta.releases.workflows.version}`;
7807
- if (config.workflows_ref && config.workflows_ref !== latestWorkflowsRef) candidates.push({
9750
+ const prefix = remoteMeta.releases.workflows.tag_prefix || "";
9751
+ const latestWorkflowsRef = `${prefix}${remoteMeta.releases.workflows.version}`;
9752
+ if (config.workflows_ref && isAutomaticUpgrade(config.workflows_ref, latestWorkflowsRef, prefix)) candidates.push({
7808
9753
  key: "workflows",
7809
9754
  ref: latestWorkflowsRef
7810
9755
  });
7811
9756
  }
7812
9757
  if (remoteMeta.categories?.specialists?.version) {
7813
- const latestSpecialistsRef = `${remoteMeta.categories.specialists.tag_prefix || ""}${remoteMeta.categories.specialists.version}`;
7814
- if (config.specialists_ref && config.specialists_ref !== latestSpecialistsRef) candidates.push({
9758
+ const prefix = remoteMeta.categories.specialists.tag_prefix || "";
9759
+ const latestSpecialistsRef = `${prefix}${remoteMeta.categories.specialists.version}`;
9760
+ if (config.specialists_ref && isAutomaticUpgrade(config.specialists_ref, latestSpecialistsRef, prefix)) candidates.push({
7815
9761
  key: "specialists",
7816
9762
  ref: latestSpecialistsRef
7817
9763
  });
@@ -8624,26 +10570,66 @@ var VALID_SCOPES = [
8624
10570
  "snippets-only",
8625
10571
  "disabled"
8626
10572
  ];
10573
+ var VALID_VIAS = [
10574
+ "auto",
10575
+ "npx",
10576
+ "global"
10577
+ ];
10578
+ function parseVia(value) {
10579
+ return typeof value === "string" && VALID_VIAS.includes(value) ? value : null;
10580
+ }
10581
+ function defaultGlobalInstaller(spec) {
10582
+ if (process.platform === "win32") {
10583
+ (0, child_process.execFileSync)("npm.cmd", [
10584
+ "i",
10585
+ "-g",
10586
+ spec
10587
+ ], {
10588
+ stdio: "inherit",
10589
+ shell: true
10590
+ });
10591
+ return;
10592
+ }
10593
+ (0, child_process.execFileSync)("npm", [
10594
+ "i",
10595
+ "-g",
10596
+ spec
10597
+ ], { stdio: "inherit" });
10598
+ }
10599
+ function formatEntry(entry) {
10600
+ if (!entry) return "(none)";
10601
+ return entry.args.length ? `${entry.command} ${entry.args.join(" ")}` : entry.command;
10602
+ }
8627
10603
  /**
8628
10604
  * `ags mcp <action>` — manage the optional MCP server integration without
8629
10605
  * editing `.skillsrc` by hand.
8630
10606
  *
8631
10607
  * Sub-actions:
8632
- * status Show current scope, enabled state, and which configs are wired
8633
- * enable Set mcp.enabled=true; install configs at the configured scope
10608
+ * status Show scope, launch mode, resolved command, and installed configs
10609
+ * enable [--via=...] Set mcp.enabled=true; optionally install configs at the configured scope
8634
10610
  * disable Set mcp.enabled=false; existing configs are NOT removed
8635
10611
  * scope <scope> Change scope: project | user | snippets-only | disabled
8636
- * install [--scope=...] One-shot install at the given (or configured) scope
10612
+ * via <auto|npx|global> Change launch mode (default: auto)
10613
+ * install [--scope=...] [--via=...]
10614
+ * One-shot install at the given (or configured) scope/mode
8637
10615
  * uninstall [--from=...] Remove our entry from configs (project | user | all)
8638
- * snippets Generate fresh snippet files in ./mcp-config-snippets/
10616
+ * snippets [--via=...] Generate fresh snippet files in ./mcp-config-snippets/
10617
+ *
10618
+ * `--install-global` (on enable) installs `agent-skills-standard-mcp@<pin>`
10619
+ * globally, then verifies the installed binary reports the pin.
8639
10620
  */
8640
10621
  var McpCommand = class {
8641
10622
  configService;
8642
10623
  mcpService;
10624
+ globalInstaller = defaultGlobalInstaller;
8643
10625
  constructor(configService, mcpService) {
8644
10626
  this.configService = configService ?? new ConfigService();
8645
10627
  this.mcpService = mcpService ?? new McpConfigService();
8646
10628
  }
10629
+ /** For testing: replace the global npm installer (never run real npm in unit tests). */
10630
+ setGlobalInstallerForTesting(fn) {
10631
+ this.globalInstaller = fn ?? defaultGlobalInstaller;
10632
+ }
8647
10633
  async run(action, options = {}) {
8648
10634
  const config = await this.configService.loadConfig();
8649
10635
  if (!config) {
@@ -8654,13 +10640,14 @@ var McpCommand = class {
8654
10640
  const agents = config.agents ?? [];
8655
10641
  switch (action) {
8656
10642
  case "status": return this.actionStatus(config, cwd);
8657
- case "enable": return this.actionEnable(config);
10643
+ case "enable": return this.actionEnable(config, agents, cwd, options);
8658
10644
  case "disable": return this.actionDisable(config);
8659
10645
  case "scope": return this.actionScope(config, options);
10646
+ case "via": return this.actionVia(config, options);
8660
10647
  case "install": return this.actionInstall(config, agents, cwd, options);
8661
10648
  case "uninstall": return this.actionUninstall(config, agents, cwd, options);
8662
- case "snippets": return this.actionSnippets(config, agents, cwd);
8663
- default: console.log(picocolors.default.red(`Unknown action: ${action}`), "\nValid actions: status, enable, disable, scope, install, uninstall, snippets");
10649
+ case "snippets": return this.actionSnippets(config, agents, cwd, options);
10650
+ default: console.log(picocolors.default.red(`Unknown action: ${action}`), "\nValid actions: status, enable, disable, scope, via, install, uninstall, snippets");
8664
10651
  }
8665
10652
  }
8666
10653
  async actionStatus(config, cwd) {
@@ -8673,20 +10660,50 @@ var McpCommand = class {
8673
10660
  console.log(picocolors.default.bold("\n🔌 MCP integration status\n"));
8674
10661
  console.log(` enabled : ${mcp.enabled ? picocolors.default.green("yes") : picocolors.default.gray("no")}`);
8675
10662
  console.log(` scope : ${picocolors.default.cyan(mcp.scope)}`);
10663
+ console.log(` via : ${picocolors.default.cyan(mcp.via ?? "auto")}`);
8676
10664
  console.log(` prompted: ${mcp.prompted ? "yes" : "no"}`);
8677
10665
  if (mcp.version) console.log(` version : ${mcp.version}`);
10666
+ const pin = mcp.version ?? "0.8.0";
10667
+ const entry = this.mcpService.buildEntry({
10668
+ version: mcp.version,
10669
+ via: mcp.via
10670
+ });
10671
+ const launch = entry.args.length ? `${entry.command} ${entry.args.join(" ")}` : entry.command;
10672
+ console.log(` launch : ${picocolors.default.gray(launch)}`);
10673
+ console.log(` pin : ${pin}`);
10674
+ const detected = this.mcpService.detectGlobalVersion();
10675
+ console.log(` global : ${detected ? picocolors.default.green(detected) : picocolors.default.gray("not found")}`);
10676
+ if (detected && detected !== pin) {
10677
+ console.log(picocolors.default.yellow(`\n⚠️ Global ags-mcp is ${detected} but the CLI pin is ${pin}; "auto" will use npx until they match.`));
10678
+ console.log(picocolors.default.gray(` Update with: npm i -g ${PACKAGE}@${pin}`));
10679
+ }
8678
10680
  if (rows.length === 0) {
8679
10681
  console.log(picocolors.default.gray("\n (no agents configured)"));
8680
10682
  return;
8681
10683
  }
8682
10684
  console.log("\n Per-agent installation state:");
8683
10685
  let installedAnywhere = false;
10686
+ const drift = [];
8684
10687
  for (const row of rows) {
8685
- const project = row.project === void 0 ? "—" : row.project ? picocolors.default.green("✓") : picocolors.default.gray("✗");
8686
- const user = row.user === void 0 ? "—" : row.user ? picocolors.default.green("✓") : picocolors.default.gray("✗");
10688
+ const project = row.project === void 0 ? "—" : `${row.project ? picocolors.default.green("✓") : picocolors.default.gray("✗")} ${formatEntry(row.projectEntry)}`;
10689
+ const user = row.user === void 0 ? "—" : `${row.user ? picocolors.default.green("✓") : picocolors.default.gray("✗")} ${formatEntry(row.userEntry)}`;
8687
10690
  if (row.project === true || row.user === true) installedAnywhere = true;
10691
+ if (row.project === true && row.projectEntry && !sameMcpEntry(row.projectEntry, entry)) drift.push({
10692
+ agent: row.agent,
10693
+ scope: "project",
10694
+ configured: row.projectEntry
10695
+ });
10696
+ if (row.user === true && row.userEntry && !sameMcpEntry(row.userEntry, entry)) drift.push({
10697
+ agent: row.agent,
10698
+ scope: "user",
10699
+ configured: row.userEntry
10700
+ });
8688
10701
  console.log(` ${row.agent.padEnd(12)} project: ${project} user: ${user}`);
8689
10702
  }
10703
+ if (drift.length > 0) {
10704
+ for (const item of drift) console.log(picocolors.default.yellow(`\n⚠️ MCP pin drift: ${item.agent} ${item.scope} pins ${formatEntry(item.configured)} but this CLI expects ${launch}.`));
10705
+ console.log(picocolors.default.gray(" Run `ags sync` (or `ags mcp install`) to refresh; existing agents pick it up on their next MCP start."));
10706
+ }
8690
10707
  if (!mcp.enabled && installedAnywhere) {
8691
10708
  console.log(picocolors.default.yellow("\n⚠️ Mismatch detected: .skillsrc says mcp.enabled=false, but the MCP entry IS present in at least one runtime config."));
8692
10709
  console.log(picocolors.default.gray(" The MCP will work, but `sync` will not keep its config in step. Consider one of:"));
@@ -8697,12 +10714,38 @@ var McpCommand = class {
8697
10714
  console.log(picocolors.default.gray(" Run `ags mcp install` to write the entries, or `ags mcp disable` to clear the consent flag."));
8698
10715
  }
8699
10716
  }
8700
- async actionEnable(config) {
10717
+ async actionEnable(config, agents, cwd, options) {
8701
10718
  const mcp = config.mcp ?? defaultMcpConfig();
10719
+ const viaOverrideRaw = options.via;
10720
+ const viaOverride = viaOverrideRaw ? parseVia(viaOverrideRaw) : void 0;
10721
+ if (viaOverrideRaw && !viaOverride) {
10722
+ console.log(picocolors.default.red(`Invalid --via value. Valid: ${VALID_VIAS.join(" | ")}`));
10723
+ return;
10724
+ }
8702
10725
  if (mcp.scope === "disabled") {
8703
10726
  console.log(picocolors.default.yellow("⚠️ scope is currently \"disabled\". Set a real scope first: `ags mcp scope project`"));
8704
10727
  return;
8705
10728
  }
10729
+ if (options.installGlobal) {
10730
+ const pin = mcp.version ?? "0.8.0";
10731
+ const spec = `${PACKAGE}@${pin}`;
10732
+ try {
10733
+ this.globalInstaller(spec);
10734
+ } catch {
10735
+ console.log(picocolors.default.red(`❌ Global install failed. Run manually:`));
10736
+ console.log(picocolors.default.gray(` npm i -g ${spec}`));
10737
+ process.exitCode = 1;
10738
+ return;
10739
+ }
10740
+ const detected = this.mcpService.detectGlobalVersion();
10741
+ if (detected !== pin) {
10742
+ console.log(picocolors.default.red(`❌ Global ags-mcp reports ${detected ?? "no version"} but the CLI pin is ${pin}.`));
10743
+ console.log(picocolors.default.gray(` Run manually: npm i -g ${spec}`));
10744
+ process.exitCode = 1;
10745
+ return;
10746
+ }
10747
+ console.log(picocolors.default.green(`✅ Global ags-mcp ${pin} installed and verified.`));
10748
+ }
8706
10749
  config.mcp = {
8707
10750
  ...mcp,
8708
10751
  enabled: true,
@@ -8710,8 +10753,37 @@ var McpCommand = class {
8710
10753
  };
8711
10754
  await this.configService.saveConfig(config);
8712
10755
  console.log(picocolors.default.green("✅ MCP enabled."));
10756
+ if (viaOverride) {
10757
+ const report = await this.mcpService.install({
10758
+ rootDir: cwd,
10759
+ agents,
10760
+ mcp: {
10761
+ ...config.mcp,
10762
+ via: viaOverride
10763
+ }
10764
+ });
10765
+ console.log(picocolors.default.gray(` Installed configs using launch mode "${viaOverride}".`));
10766
+ this.printReport(report, cwd);
10767
+ return;
10768
+ }
8713
10769
  console.log(picocolors.default.gray(" Run `ags mcp install` to write configs now, or it happens on next `sync`."));
8714
10770
  }
10771
+ async actionVia(config, options) {
10772
+ const via = options.via ?? options._?.[0];
10773
+ if (!parseVia(via)) {
10774
+ console.log(picocolors.default.red(`Invalid via. Valid: ${VALID_VIAS.join(" | ")}`));
10775
+ return;
10776
+ }
10777
+ config.mcp = {
10778
+ ...config.mcp ?? defaultMcpConfig(),
10779
+ via,
10780
+ prompted: true
10781
+ };
10782
+ await this.configService.saveConfig(config);
10783
+ console.log(picocolors.default.green(`✅ MCP launch mode set to "${via}".`));
10784
+ if (via === "global") console.log(picocolors.default.yellow("⚠️ \"global\" requires a matching global install: `ags mcp enable --install-global`."));
10785
+ else if (via === "auto") console.log(picocolors.default.gray(" \"auto\" uses a version-matched global ags-mcp when present, otherwise npx."));
10786
+ }
8715
10787
  async actionDisable(config) {
8716
10788
  config.mcp = {
8717
10789
  ...config.mcp ?? defaultMcpConfig(),
@@ -8745,6 +10817,12 @@ var McpCommand = class {
8745
10817
  console.log(picocolors.default.red(`Invalid --scope value. Valid: ${VALID_SCOPES.join(" | ")}`));
8746
10818
  return;
8747
10819
  }
10820
+ const viaOverrideRaw = options.via;
10821
+ const viaOverride = viaOverrideRaw ? parseVia(viaOverrideRaw) : void 0;
10822
+ if (viaOverrideRaw && !viaOverride) {
10823
+ console.log(picocolors.default.red(`Invalid --via value. Valid: ${VALID_VIAS.join(" | ")}`));
10824
+ return;
10825
+ }
8748
10826
  if (effectiveScope === "disabled") {
8749
10827
  console.log(picocolors.default.yellow("Scope is \"disabled\" — nothing to install."));
8750
10828
  return;
@@ -8764,7 +10842,8 @@ var McpCommand = class {
8764
10842
  mcp: {
8765
10843
  ...mcp,
8766
10844
  enabled: true,
8767
- scope: effectiveScope
10845
+ scope: effectiveScope,
10846
+ ...viaOverride ? { via: viaOverride } : {}
8768
10847
  },
8769
10848
  userScopePrompt: userPrompt
8770
10849
  });
@@ -8792,15 +10871,22 @@ var McpCommand = class {
8792
10871
  console.log(picocolors.default.green(`✅ Removed agent-skills MCP entry from:`));
8793
10872
  for (const r of removed) console.log(` - ${picocolors.default.cyan(r.agent)} → ${picocolors.default.gray(r.file)}`);
8794
10873
  }
8795
- async actionSnippets(config, agents, cwd) {
10874
+ async actionSnippets(config, agents, cwd, options) {
8796
10875
  const mcp = config.mcp ?? defaultMcpConfig();
10876
+ const viaOverrideRaw = options.via;
10877
+ const viaOverride = viaOverrideRaw ? parseVia(viaOverrideRaw) : void 0;
10878
+ if (viaOverrideRaw && !viaOverride) {
10879
+ console.log(picocolors.default.red(`Invalid --via value. Valid: ${VALID_VIAS.join(" | ")}`));
10880
+ return;
10881
+ }
8797
10882
  const report = await this.mcpService.install({
8798
10883
  rootDir: cwd,
8799
10884
  agents,
8800
10885
  mcp: {
8801
10886
  ...mcp,
8802
10887
  enabled: true,
8803
- scope: "snippets-only"
10888
+ scope: "snippets-only",
10889
+ ...viaOverride ? { via: viaOverride } : {}
8804
10890
  }
8805
10891
  });
8806
10892
  if (report.snippets.length === 0) {
@@ -8899,12 +10985,14 @@ var RetrospectiveCommand = class {
8899
10985
  env;
8900
10986
  customExit;
8901
10987
  log;
10988
+ mcpVersion;
8902
10989
  constructor(deps = {}) {
8903
10990
  this.spawnFn = deps.spawnFn ?? defaultSpawn;
8904
10991
  this.platform = deps.platform ?? process.platform;
8905
10992
  this.env = deps.env ?? process.env;
8906
10993
  this.customExit = deps.exit;
8907
10994
  this.log = deps.log ?? console.log;
10995
+ this.mcpVersion = deps.mcpVersion ?? "0.8.0";
8908
10996
  }
8909
10997
  exit(code) {
8910
10998
  process.exitCode = code;
@@ -8933,8 +11021,8 @@ var RetrospectiveCommand = class {
8933
11021
  }
8934
11022
  }
8935
11023
  const envSpec = this.env.AGS_MCP_PACKAGE_SPEC;
8936
- if (!(typeof envSpec === "string" && envSpec.trim() !== "") && !isVersionAtLeast("0.6.1", "0.8.0")) {
8937
- this.log(`Session retrospectives need agent-skills-standard-mcp ${RETROSPECTIVE_MIN_MCP_VERSION} or newer, but this ags is pinned to ${MCP_COMPATIBLE_VERSION}. Upgrade ags (ags upgrade), or set AGS_MCP_PACKAGE_SPEC to a newer package for testing.`);
11024
+ if (!(typeof envSpec === "string" && envSpec.trim() !== "") && !isVersionAtLeast(this.mcpVersion, "0.8.0")) {
11025
+ this.log(`Session retrospectives need agent-skills-standard-mcp ${RETROSPECTIVE_MIN_MCP_VERSION} or newer, but this ags is pinned to ${this.mcpVersion}. Upgrade ags (ags upgrade), or set AGS_MCP_PACKAGE_SPEC to a newer package for testing.`);
8938
11026
  this.exit(1);
8939
11027
  return;
8940
11028
  }
@@ -8953,7 +11041,8 @@ var RetrospectiveCommand = class {
8953
11041
  }
8954
11042
  const argsToSpawn = [
8955
11043
  "-y",
8956
- typeof envSpec === "string" && envSpec.trim() !== "" ? envSpec : `${PACKAGE}@${MCP_COMPATIBLE_VERSION}`,
11044
+ "--prefer-offline",
11045
+ typeof envSpec === "string" && envSpec.trim() !== "" ? envSpec : `${PACKAGE}@${this.mcpVersion}`,
8957
11046
  "retrospective",
8958
11047
  mappedAction,
8959
11048
  ...forwarded
@@ -8983,9 +11072,25 @@ var RetrospectiveCommand = class {
8983
11072
  };
8984
11073
  //#endregion
8985
11074
  //#region src/commands/sync.ts
11075
+ /** A classified command failure; `cancelled` marks a declined required consent. */
11076
+ var SyncFailureError = class extends Error {
11077
+ failure;
11078
+ cancelled;
11079
+ constructor(failure, cancelled = false) {
11080
+ super(failure.message);
11081
+ this.failure = failure;
11082
+ this.cancelled = cancelled;
11083
+ this.name = "SyncFailureError";
11084
+ }
11085
+ };
8986
11086
  /**
8987
11087
  * Command for synchronizing skills and workflows from the remote registry to the local workspace.
8988
- * It handles configuration re-detection, fetching files, writing to disk, and updating indices.
11088
+ *
11089
+ * Publication order is preflight-first: configuration and pins are reconciled
11090
+ * in memory, every selected source is resolved and acquired, and the
11091
+ * attribution plan is computed before any managed file, lock entry, or saved
11092
+ * ref is written. A preflight failure therefore preserves the installed bytes,
11093
+ * the lock, and the saved refs.
8989
11094
  */
8990
11095
  var SyncCommand = class {
8991
11096
  configService;
@@ -9001,118 +11106,385 @@ var SyncCommand = class {
9001
11106
  this.hookService = hookService || new HookService();
9002
11107
  }
9003
11108
  /**
9004
- * Executes the synchronization flow.
9005
- * Reconciles dependencies, fetches skills and workflows from the registry, and updates AGENTS.md.
11109
+ * Executes the synchronization flow and returns a discriminated outcome.
11110
+ * The CLI boundary owns `process.exitCode`; services never exit the process.
9006
11111
  */
9007
11112
  async run(options = {}) {
11113
+ const restoreOutput = options.json ? this.redirectProgressToStderr() : () => {};
9008
11114
  try {
9009
- const config = await this.configService.loadConfig();
9010
- if (!config) {
9011
- console.log(picocolors.default.red("❌ Error: .skillsrc not found. Run `init` first."));
9012
- return;
9013
- }
9014
- const projectDeps = await this.detectionService.getProjectDeps();
9015
- const skillsChanged = await this.syncService.reconcileConfig(config, projectDeps);
9016
- const workflowsChanged = await this.syncService.reconcileWorkflows(config);
9017
- if (!options.dryRun && (skillsChanged || workflowsChanged)) await this.configService.saveConfig(config);
9018
- const resolvedRefs = await this.syncService.resolvePins(config);
9019
- if (Object.entries(resolvedRefs.newPins).length > 0) {
9020
- const pinnedList = [resolvedRefs.newPins.workflows_ref, resolvedRefs.newPins.specialists_ref].filter(Boolean).join(", ");
9021
- if (!options.dryRun) {
9022
- if (resolvedRefs.newPins.workflows_ref) config.workflows_ref = resolvedRefs.newPins.workflows_ref;
9023
- if (resolvedRefs.newPins.specialists_ref) config.specialists_ref = resolvedRefs.newPins.specialists_ref;
9024
- await this.configService.saveConfig(config);
9025
- console.log(picocolors.default.green(`📌 Pinned ${pinnedList} in .skillsrc`));
9026
- } else console.log(picocolors.default.gray(` (dry-run) would pin ${pinnedList} in .skillsrc`));
9027
- }
9028
- for (const warning of resolvedRefs.warnings) console.log(picocolors.default.yellow(warning));
9029
- const updates = await this.syncService.checkForUpdates(config);
9030
- let refsChanged = false;
9031
- if (updates && Object.keys(updates).length > 0) {
9032
- if (options.dryRun) {
9033
- if (!options.json) for (const [cat, ref] of Object.entries(updates)) console.log(picocolors.default.gray(` (dry-run) would offer: ${cat} -> ${ref}`));
9034
- } else {
9035
- console.log(picocolors.default.yellow("\n🚀 New skill versions detected:"));
9036
- for (const [cat, ref] of Object.entries(updates)) {
9037
- const currentRef = cat === "workflows" ? config.workflows_ref : cat === "specialists" ? config.specialists_ref : config.skills[cat]?.ref;
9038
- console.log(picocolors.default.gray(` - ${cat}: ${currentRef} -> ${ref}`));
9039
- }
9040
- let update = options.yes;
9041
- if (update === void 0) {
9042
- if (!process.stdin.isTTY) {
9043
- console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
9044
- update = false;
9045
- } else update = (await inquirer.default.prompt([{
9046
- type: "confirm",
9047
- name: "update",
9048
- message: "Do you want to update .skillsrc with these versions?",
9049
- default: true
9050
- }])).update;
9051
- }
9052
- if (update) {
9053
- for (const [cat, ref] of Object.entries(updates)) if (cat === "workflows") {
9054
- config.workflows_ref = ref;
9055
- refsChanged = true;
9056
- } else if (cat === "specialists") {
9057
- config.specialists_ref = ref;
9058
- refsChanged = true;
9059
- } else if (config.skills[cat]) {
9060
- config.skills[cat].ref = ref;
9061
- refsChanged = true;
9062
- }
9063
- await this.configService.saveConfig(config);
9064
- console.log(picocolors.default.green("✅ .skillsrc updated."));
9065
- } else console.log(picocolors.default.cyan("ℹ️ Skipping version updates, staying on pinned refs."));
11115
+ return await this.execute(options);
11116
+ } catch (error) {
11117
+ const failure = this.classify(error);
11118
+ const cancelled = error instanceof SyncFailureError && error.cancelled;
11119
+ this.reportFailure(failure, options, cancelled);
11120
+ return cancelled ? {
11121
+ status: "cancelled",
11122
+ failure
11123
+ } : {
11124
+ status: "failure",
11125
+ failure
11126
+ };
11127
+ } finally {
11128
+ restoreOutput();
11129
+ }
11130
+ }
11131
+ async execute(options) {
11132
+ const snapshot = await this.configService.readSnapshot();
11133
+ if (!snapshot) throw new SyncFailureError({
11134
+ code: "config-invalid",
11135
+ message: "❌ Error: .skillsrc not found. Run `init` first."
11136
+ });
11137
+ const config = structuredClone(snapshot.config);
11138
+ const projectDeps = await this.detectionService.getProjectDeps();
11139
+ const skillsChanged = await this.syncService.reconcileConfig(config, projectDeps);
11140
+ const workflowsChanged = await this.syncService.reconcileWorkflows(config);
11141
+ let configChanged = skillsChanged || workflowsChanged;
11142
+ const resolvedRefs = await this.syncService.resolvePins(config);
11143
+ if (Object.entries(resolvedRefs.newPins).length > 0) {
11144
+ const pinnedList = [resolvedRefs.newPins.workflows_ref, resolvedRefs.newPins.specialists_ref].filter(Boolean).join(", ");
11145
+ if (!options.dryRun) {
11146
+ if (resolvedRefs.newPins.workflows_ref) config.workflows_ref = resolvedRefs.newPins.workflows_ref;
11147
+ if (resolvedRefs.newPins.specialists_ref) config.specialists_ref = resolvedRefs.newPins.specialists_ref;
11148
+ configChanged = true;
11149
+ console.log(picocolors.default.green(`📌 Pinned ${pinnedList} in .skillsrc`));
11150
+ } else console.log(picocolors.default.gray(` (dry-run) would pin ${pinnedList} in .skillsrc`));
11151
+ }
11152
+ for (const warning of resolvedRefs.warnings) console.log(picocolors.default.yellow(warning));
11153
+ const updates = await this.syncService.checkForUpdates(config);
11154
+ let refsChanged = false;
11155
+ if (updates && Object.keys(updates).length > 0) {
11156
+ if (options.dryRun) {
11157
+ if (!options.json) for (const [cat, ref] of Object.entries(updates)) console.log(picocolors.default.gray(` (dry-run) would offer: ${cat} -> ${ref}`));
11158
+ } else {
11159
+ console.log(picocolors.default.yellow("\n🚀 New skill versions detected:"));
11160
+ for (const [cat, ref] of Object.entries(updates)) {
11161
+ const currentRef = cat === "workflows" ? config.workflows_ref : cat === "specialists" ? config.specialists_ref : config.skills[cat]?.ref;
11162
+ console.log(picocolors.default.gray(` - ${cat}: ${currentRef} -> ${ref}`));
9066
11163
  }
11164
+ let update = options.yes;
11165
+ if (update === void 0) {
11166
+ if (options.json || !process.stdin.isTTY) {
11167
+ console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
11168
+ update = false;
11169
+ } else update = (await inquirer.default.prompt([{
11170
+ type: "confirm",
11171
+ name: "update",
11172
+ message: "Do you want to update .skillsrc with these versions?",
11173
+ default: true
11174
+ }])).update;
11175
+ }
11176
+ if (update) {
11177
+ for (const [cat, ref] of Object.entries(updates)) if (cat === "workflows") {
11178
+ config.workflows_ref = ref;
11179
+ refsChanged = true;
11180
+ } else if (cat === "specialists") {
11181
+ config.specialists_ref = ref;
11182
+ refsChanged = true;
11183
+ } else if (config.skills[cat]) {
11184
+ config.skills[cat].ref = ref;
11185
+ refsChanged = true;
11186
+ }
11187
+ configChanged = true;
11188
+ console.log(picocolors.default.green("✅ .skillsrc updated."));
11189
+ } else console.log(picocolors.default.cyan("ℹ️ Skipping version updates, staying on pinned refs."));
9067
11190
  }
9068
- if (refsChanged) await this.syncService.resolvePins(config);
9069
- if (!options.json) console.log(picocolors.default.cyan(`🚀 Syncing skills from ${config.registry}...`));
9070
- await this.syncService.beginInstall(config, {
9071
- dryRun: Boolean(options.dryRun),
9072
- force: options.force ?? []
9073
- });
9074
- const skillCategories = Object.keys(config.skills).filter((category) => category !== "specialists");
9075
- const skills = await this.syncService.assembleSkills(skillCategories, config);
9076
- const workflowAcquisition = await this.syncService.acquireWorkflows(config);
9077
- const workflows = workflowAcquisition.workflows;
9078
- if (workflowAcquisition.status === "incomplete") {
9079
- console.error(picocolors.default.red("❌ Workflow acquisition incomplete; existing workflows were preserved and no success is claimed."));
9080
- for (const failure of workflowAcquisition.failures) console.error(picocolors.default.red(` ❌ ${failure.path} — ${failure.reason}`));
9081
- }
9082
- await this.syncService.writeSkills(skills, config);
9083
- await this.syncService.writeWorkflows(workflows, config);
9084
- const specialistsOk = await this.syncService.syncSpecialists(config);
9085
- const plan = await this.syncService.completeInstall(config, {
9086
- skills,
9087
- workflows,
9088
- specialistsOk,
9089
- refreshIndices: true,
9090
- completedSkillCategories: skillCategories,
9091
- workflowAcquisition
11191
+ }
11192
+ if (refsChanged) await this.syncService.resolvePins(config);
11193
+ if (!options.json) console.log(picocolors.default.cyan(`🚀 Syncing skills from ${config.registry}...`));
11194
+ await this.syncService.beginInstall(config, {
11195
+ dryRun: Boolean(options.dryRun),
11196
+ force: options.force ?? []
11197
+ });
11198
+ const skillCategories = Object.keys(config.skills).filter((category) => category !== "specialists");
11199
+ const skills = await this.syncService.assembleSkills(skillCategories, config);
11200
+ const workflowAcquisition = await this.syncService.acquireWorkflows(config);
11201
+ if (workflowAcquisition.status === "incomplete") throw new SyncFailureError({
11202
+ code: "sync-failed",
11203
+ message: "Workflow acquisition incomplete; existing files, lock and saved refs were preserved.",
11204
+ details: { workflowFailures: workflowAcquisition.failures }
11205
+ });
11206
+ const workflows = workflowAcquisition.workflows;
11207
+ const specialistAcquisition = await this.syncService.acquireSpecialists(config);
11208
+ if (specialistAcquisition.status === "incomplete") throw new SyncFailureError({
11209
+ code: "sync-failed",
11210
+ message: "Specialist acquisition incomplete; existing files, lock and saved refs were preserved.",
11211
+ details: { specialistFailures: specialistAcquisition.failures }
11212
+ });
11213
+ const latched = this.syncService.rateLimitFailure;
11214
+ if (latched) throw latched;
11215
+ let attribution = null;
11216
+ let attributionRequiresConsent = false;
11217
+ if (!options.dryRun) {
11218
+ const plan = await this.syncService.planAttribution(skills, config);
11219
+ attribution = plan.comparison;
11220
+ attributionRequiresConsent = plan.comparison.requiresConsent || plan.installedOnly.length > 0;
11221
+ this.printAttributionPreview(plan);
11222
+ await this.ensureAttributionConsent(plan, options);
11223
+ }
11224
+ let publishedSnapshot = snapshot;
11225
+ if (!options.dryRun) publishedSnapshot = configChanged ? await this.configService.saveReviewedConfig(snapshot, config) : await this.assertSnapshotUnchanged(snapshot);
11226
+ await this.syncService.writeSkills(skills, config);
11227
+ await this.syncService.writeWorkflows(workflows, config);
11228
+ const specialistsOk = await this.syncService.writeSpecialists(specialistAcquisition, config);
11229
+ const plan = await this.syncService.completeInstall(config, {
11230
+ skills,
11231
+ workflows,
11232
+ specialistsOk,
11233
+ refreshIndices: true,
11234
+ completedSkillCategories: skillCategories,
11235
+ workflowAcquisition
11236
+ });
11237
+ if (options.dryRun) {
11238
+ if (options.json) this.writeJson({
11239
+ schema_version: 1,
11240
+ kind: "sync.plan",
11241
+ data: plan
9092
11242
  });
9093
- if (options.dryRun) {
9094
- if (options.json) console.log(JSON.stringify({
9095
- schema_version: 1,
9096
- kind: "sync.plan",
9097
- data: plan
9098
- }));
9099
- else {
9100
- console.log(picocolors.default.cyan("🔍 Dry run — no files written"));
9101
- this.printPlanCounts(plan);
9102
- if (options.verbose) this.printVerbosePlan(plan);
9103
- console.log(picocolors.default.gray(" (dry-run) skipped: MCP registration, hooks, capability notes"));
11243
+ else {
11244
+ console.log(picocolors.default.cyan("🔍 Dry run — no files written"));
11245
+ this.printPlanCounts(plan);
11246
+ if (options.verbose) this.printVerbosePlan(plan);
11247
+ console.log(picocolors.default.gray(" (dry-run) skipped: MCP registration, hooks, capability notes"));
11248
+ }
11249
+ return {
11250
+ status: "dry-run",
11251
+ plan
11252
+ };
11253
+ }
11254
+ this.printSummary(plan);
11255
+ await this.syncService.discloseCapabilities(config);
11256
+ console.log(picocolors.default.green("✅ All skills synced successfully!"));
11257
+ await this.runMcpPhase(config, options, publishedSnapshot);
11258
+ await this.runHookPhase(config, options);
11259
+ if (options.json) this.writeJson({
11260
+ schema_version: 1,
11261
+ kind: "sync.result",
11262
+ data: {
11263
+ status: "success",
11264
+ added: plan.added.length,
11265
+ updated: plan.updated.length,
11266
+ unchanged: plan.unchanged.length,
11267
+ kept: plan.kept.length,
11268
+ unknown: plan.unknown.length,
11269
+ pruned: plan.pruned.length,
11270
+ keptOrphans: plan.keptOrphans.length,
11271
+ attribution: attribution ? {
11272
+ required: attributionRequiresConsent,
11273
+ changes: attribution.packages.filter((change) => change.requiresConsent).length
11274
+ } : null
11275
+ }
11276
+ });
11277
+ return {
11278
+ status: "success",
11279
+ plan
11280
+ };
11281
+ }
11282
+ /**
11283
+ * Confirms the configuration on disk is still the snapshot read at preflight
11284
+ * without writing anything. Runs immediately before the first managed write.
11285
+ */
11286
+ async assertSnapshotUnchanged(snapshot) {
11287
+ const current = await this.configService.readSnapshot();
11288
+ if (!current || current.sourcePath !== snapshot.sourcePath || current.fingerprint !== snapshot.fingerprint) throw new SyncFailureError({
11289
+ code: "config-invalid",
11290
+ message: "❌ .skillsrc changed after preflight; nothing was published. Re-run `ags sync`."
11291
+ });
11292
+ return snapshot;
11293
+ }
11294
+ /**
11295
+ * In JSON mode stdout carries exactly one envelope. Every progress line,
11296
+ * including service-level logging, is redirected to stderr for the run.
11297
+ */
11298
+ redirectProgressToStderr() {
11299
+ const originalLog = console.log;
11300
+ const originalInfo = console.info;
11301
+ console.log = (...args) => console.error(...args);
11302
+ console.info = (...args) => console.error(...args);
11303
+ return () => {
11304
+ console.log = originalLog;
11305
+ console.info = originalInfo;
11306
+ };
11307
+ }
11308
+ writeJson(value) {
11309
+ process.stdout.write(`${JSON.stringify(value)}\n`);
11310
+ }
11311
+ /** Maps a thrown value to a stable, caller-visible failure contract. */
11312
+ classify(error) {
11313
+ if (error instanceof SyncFailureError) return error.failure;
11314
+ const latched = this.syncService.rateLimitFailure;
11315
+ if (latched) return this.githubError(latched);
11316
+ if (error instanceof GithubRequestError) return this.githubError(error);
11317
+ if (error instanceof ConfigValidationError) return {
11318
+ code: "config-invalid",
11319
+ message: `❌ Invalid .skillsrc: ${error.message}`,
11320
+ details: {
11321
+ configPath: error.configPath,
11322
+ logicalPath: error.logicalPath
11323
+ }
11324
+ };
11325
+ if (error instanceof IntegrityError) return {
11326
+ code: "integrity",
11327
+ message: error.message
11328
+ };
11329
+ if (error instanceof Error) return {
11330
+ code: "sync-failed",
11331
+ message: error.message
11332
+ };
11333
+ return {
11334
+ code: "sync-failed",
11335
+ message: String(error)
11336
+ };
11337
+ }
11338
+ githubError(error) {
11339
+ const auth = error.authenticated ? "a GitHub token was sent" : "no GitHub token was sent";
11340
+ const code = error.kind === "rate-limited" || error.kind === "access-denied" ? error.kind : error.kind === "integrity" ? "integrity" : "sync-failed";
11341
+ let hint;
11342
+ if (error.kind === "rate-limited") {
11343
+ const timing = error.resetAt ? ` It resets at ${error.resetAt}.` : error.retryAfterSeconds !== void 0 ? ` Retry after ${error.retryAfterSeconds}s.` : "";
11344
+ hint = `GitHub ${error.rateLimitKind ?? "primary"} rate limit reached; no further requests were scheduled.${timing} ${auth}; set GITHUB_TOKEN for a higher limit.`;
11345
+ } else if (error.kind === "access-denied") hint = `GitHub denied access to ${error.endpoint}. ${auth}; verify repository access or set GITHUB_TOKEN.`;
11346
+ else if (error.kind === "integrity") hint = "A downloaded source failed integrity verification; nothing was published.";
11347
+ else hint = `GitHub request failed at ${error.endpoint}. ${auth}; retry when the network or the reference is available.`;
11348
+ return {
11349
+ code,
11350
+ message: error.message,
11351
+ hint,
11352
+ github: error.toJSON()
11353
+ };
11354
+ }
11355
+ reportFailure(failure, options, cancelled) {
11356
+ if (options.json) {
11357
+ this.writeJson({
11358
+ schema_version: 1,
11359
+ kind: "sync.error",
11360
+ data: {
11361
+ code: failure.code,
11362
+ message: failure.message,
11363
+ ...failure.hint ? { hint: failure.hint } : {},
11364
+ ...failure.github ? { github: failure.github } : {},
11365
+ ...failure.details ? { details: failure.details } : {},
11366
+ ...cancelled ? { cancelled: true } : {}
9104
11367
  }
9105
- return;
11368
+ });
11369
+ return;
11370
+ }
11371
+ console.error(picocolors.default.red("❌ Sync failed:"), failure.message);
11372
+ if (failure.hint) console.error(picocolors.default.yellow(` ${failure.hint}`));
11373
+ const acquisitionFailures = failure.details?.workflowFailures ?? failure.details?.specialistFailures;
11374
+ if (Array.isArray(acquisitionFailures)) for (const item of acquisitionFailures) console.error(picocolors.default.red(` ❌ ${String(item.path)} — ${String(item.reason)}`));
11375
+ }
11376
+ /**
11377
+ * Discloses a first installation and previews every transition with both
11378
+ * origins, the installed digest, explicitly unknown old origins, and
11379
+ * installed-only attribution files that no candidate/catalog record covers.
11380
+ */
11381
+ printAttributionPreview(plan) {
11382
+ const { comparison, installed, installedOnly } = plan;
11383
+ if (comparison.firstInstall && installedOnly.length === 0) {
11384
+ for (const record of comparison.disclosures) {
11385
+ const files = record.files.map((file) => `${file.name} (${file.basis}, ${file.resolvedCommit.slice(0, 12)})`).join(", ");
11386
+ console.error(picocolors.default.gray(` ℹ️ First install — attribution disclosed from ${record.registry}@${record.requestedRef}: ${files}`));
9106
11387
  }
9107
- this.printSummary(plan);
9108
- await this.syncService.discloseCapabilities(config);
9109
- if (workflowAcquisition.status !== "incomplete") console.log(picocolors.default.green("✅ All skills synced successfully!"));
9110
- await this.runMcpPhase(config, options);
9111
- await this.runHookPhase(config, options);
9112
- } catch (error) {
9113
- if (error instanceof Error) console.error(picocolors.default.red("❌ Sync failed:"), error.message);
9114
- else console.error(picocolors.default.red("❌ Sync failed:"), String(error));
11388
+ return;
9115
11389
  }
11390
+ const relevant = comparison.packages.filter((change) => change.status !== "unchanged" || change.requiresConsent || change.files.some((file) => file.previous === null));
11391
+ if (relevant.length === 0 && installedOnly.length === 0) return;
11392
+ console.error(picocolors.default.yellow("\n⚠️ Attribution changes require consent:"));
11393
+ for (const change of relevant) {
11394
+ console.error(picocolors.default.yellow(` ${change.status} ${change.packageId} (${change.reason})`));
11395
+ for (const file of change.files.slice(0, 12)) {
11396
+ const installedNow = installed[change.packageId]?.[file.name] ?? [];
11397
+ const installedDigest = installedNow.find((binding) => binding.sha256 !== null)?.sha256 ?? null;
11398
+ console.error(picocolors.default.gray(` ${file.status} ${file.name}`));
11399
+ console.error(picocolors.default.gray(` old: ${this.describeAttributionOrigin(file.previous, file.previousSha256 ?? installedDigest, "old")}`));
11400
+ console.error(picocolors.default.gray(` new: ${this.describeAttributionOrigin(file.planned, file.plannedSha256, "new")}`));
11401
+ console.error(picocolors.default.gray(` installed: ${installedNow.length === 0 ? "not recorded" : installedNow.map((binding) => `${binding.agent}:${binding.path} sha256=${binding.sha256 ? binding.sha256.slice(0, 12) : "unreadable"}`).join("; ")}`));
11402
+ }
11403
+ if (change.files.length > 12) console.error(picocolors.default.gray(` … +${change.files.length - 12} more files`));
11404
+ }
11405
+ for (const variant of installedOnly.slice(0, 12)) console.error(picocolors.default.yellow(` installed-only ${variant.name} @ ${variant.path} (${variant.agent}): origin unknown, installed sha256 ${variant.sha256 ? variant.sha256.slice(0, 12) : "unreadable"}`));
11406
+ if (installedOnly.length > 12) console.error(picocolors.default.gray(` … +${installedOnly.length - 12} more installed-only files`));
11407
+ }
11408
+ describeAttributionOrigin(record, digest, kind) {
11409
+ if (record) return `${record.sourcePath}@${record.requestedRef}/${record.resolvedCommit.slice(0, 12)} basis=${record.basis} sha256=${record.sha256.slice(0, 12)}`;
11410
+ if (digest) return `unknown origin (qualified record unavailable) sha256=${digest.slice(0, 12)}`;
11411
+ return kind === "new" ? "not shipped by the selected source" : "none";
11412
+ }
11413
+ /**
11414
+ * Requires separate attribution consent for any computed transition or any
11415
+ * installed attribution file that cannot be qualified. Ordinary `--yes` is
11416
+ * not consent; a non-interactive run without the flag fails preflight, and an
11417
+ * interactive decline cancels the run, both before any publication.
11418
+ */
11419
+ async ensureAttributionConsent(plan, options) {
11420
+ const { comparison, installed, installedOnly } = plan;
11421
+ if (!(comparison.requiresConsent || installedOnly.length > 0)) return;
11422
+ const details = {
11423
+ legacyUnqualified: comparison.legacyUnqualified,
11424
+ installedOnly: installedOnly.map((variant) => ({
11425
+ packageId: variant.packageId,
11426
+ name: variant.name,
11427
+ agent: variant.agent,
11428
+ path: variant.path,
11429
+ sha256: variant.sha256
11430
+ })),
11431
+ packages: comparison.packages.filter((change) => change.requiresConsent || installedOnly.some((variant) => variant.packageId === change.packageId)).map((change) => ({
11432
+ packageId: change.packageId,
11433
+ status: change.status,
11434
+ reason: change.reason,
11435
+ files: change.files.map((file) => ({
11436
+ name: file.name,
11437
+ status: file.status,
11438
+ previousSha256: file.previousSha256,
11439
+ plannedSha256: file.plannedSha256,
11440
+ previous: file.previous ? {
11441
+ sourcePath: file.previous.sourcePath,
11442
+ sourceKind: file.previous.sourceKind,
11443
+ requestedRef: file.previous.requestedRef,
11444
+ resolvedCommit: file.previous.resolvedCommit,
11445
+ blobSha1: file.previous.blobSha1,
11446
+ basis: file.previous.basis,
11447
+ sha256: file.previous.sha256
11448
+ } : null,
11449
+ planned: file.planned ? {
11450
+ sourcePath: file.planned.sourcePath,
11451
+ sourceKind: file.planned.sourceKind,
11452
+ requestedRef: file.planned.requestedRef,
11453
+ resolvedCommit: file.planned.resolvedCommit,
11454
+ blobSha1: file.planned.blobSha1,
11455
+ basis: file.planned.basis,
11456
+ sha256: file.planned.sha256
11457
+ } : null,
11458
+ installed: (installed[change.packageId]?.[file.name] ?? []).map((binding) => ({
11459
+ agent: binding.agent,
11460
+ path: binding.path,
11461
+ sha256: binding.sha256
11462
+ }))
11463
+ }))
11464
+ }))
11465
+ };
11466
+ if (options.acceptAttributionChanges) return;
11467
+ const message = "Attribution changes require explicit consent; re-run with --accept-attribution-changes (ordinary --yes does not authorize attribution changes).";
11468
+ if (options.json || !process.stdin.isTTY) throw new SyncFailureError({
11469
+ code: "attribution-consent-required",
11470
+ message,
11471
+ hint: "Review the preview above, then re-run with --accept-attribution-changes.",
11472
+ details
11473
+ });
11474
+ await this.promptAttributionConsent(details);
11475
+ }
11476
+ async promptAttributionConsent(details) {
11477
+ if ((await inquirer.default.prompt([{
11478
+ type: "confirm",
11479
+ name: "accept",
11480
+ message: "Apply these attribution changes to the installed files?",
11481
+ default: false
11482
+ }])).accept) return;
11483
+ throw new SyncFailureError({
11484
+ code: "attribution-consent-required",
11485
+ message: "Attribution changes were declined; nothing was published.",
11486
+ details
11487
+ }, true);
9116
11488
  }
9117
11489
  /**
9118
11490
  * Phase 7 — MCP integration. Three paths:
@@ -9123,7 +11495,7 @@ var SyncCommand = class {
9123
11495
  * In non-interactive mode (no TTY) and never-prompted: skip with a hint, do
9124
11496
  * not assume consent. Users can opt in later via `ags mcp enable`.
9125
11497
  */
9126
- async runMcpPhase(config, options) {
11498
+ async runMcpPhase(config, options, snapshot) {
9127
11499
  const mcp = config.mcp ? { ...config.mcp } : defaultMcpConfig();
9128
11500
  const agents = config.agents ?? [];
9129
11501
  const snippetOnlyOverride = options.snippets === true && (!mcp.enabled || mcp.scope === "disabled");
@@ -9133,17 +11505,19 @@ var SyncCommand = class {
9133
11505
  mcp.snippets = true;
9134
11506
  } else if (options.snippets !== void 0) mcp.snippets = options.snippets;
9135
11507
  if (!snippetOnlyOverride && (!mcp.prompted || !mcp.enabled && mcp.prompted)) {
9136
- if (!process.stdin.isTTY && !options.yes) {
11508
+ if ((options.json || !process.stdin.isTTY) && !options.yes) {
9137
11509
  if (!mcp.prompted) console.log(picocolors.default.gray("\nℹ️ MCP integration not yet configured. Run `ags mcp enable` (or `ags mcp scope project`) when ready."));
9138
11510
  return;
9139
11511
  }
9140
11512
  const decided = await this.promptMcpConsent(options, mcp.prompted && !mcp.enabled);
9141
- config.mcp = {
11513
+ const reviewed = structuredClone(config);
11514
+ reviewed.mcp = {
9142
11515
  enabled: decided.enabled,
9143
11516
  scope: decided.enabled ? decided.scope : "disabled",
9144
11517
  prompted: true
9145
11518
  };
9146
- await this.configService.saveConfig(config);
11519
+ config.mcp = reviewed.mcp;
11520
+ await this.configService.saveReviewedConfig(snapshot, reviewed);
9147
11521
  }
9148
11522
  const finalMcp = config.mcp ? { ...config.mcp } : defaultMcpConfig();
9149
11523
  if (snippetOnlyOverride) {
@@ -9153,6 +11527,10 @@ var SyncCommand = class {
9153
11527
  } else if (options.snippets !== void 0) finalMcp.snippets = options.snippets;
9154
11528
  if (!finalMcp.enabled || finalMcp.scope === "disabled") return;
9155
11529
  const userScopePrompt = options.yes ? async () => true : async (agent, file) => {
11530
+ if (options.json || !process.stdin.isTTY) {
11531
+ console.log(picocolors.default.gray(` ℹ️ Skipping user-scope MCP config for ${agent} (${file}); re-run interactively or with --yes to authorize.`));
11532
+ return false;
11533
+ }
9156
11534
  const { ok } = await inquirer.default.prompt([{
9157
11535
  type: "confirm",
9158
11536
  name: "ok",
@@ -9233,7 +11611,7 @@ var SyncCommand = class {
9233
11611
  return;
9234
11612
  }
9235
11613
  const promptPermission = options.yes ? async () => true : async (agent) => {
9236
- if (!process.stdin.isTTY) {
11614
+ if (options.json || !process.stdin.isTTY) {
9237
11615
  console.log(picocolors.default.gray(`\nℹ️ Skipping MCP permission grant for ${agent} (non-interactive; use --yes to auto-confirm).`));
9238
11616
  return false;
9239
11617
  }
@@ -9343,7 +11721,11 @@ var AuditCommand = class {
9343
11721
  * It checks the latest version on npm and performs the upgrade using the detected package manager.
9344
11722
  */
9345
11723
  var UpgradeCommand = class {
11724
+ deps;
9346
11725
  currentVersion = package_default.version;
11726
+ constructor(deps = {}) {
11727
+ this.deps = deps;
11728
+ }
9347
11729
  /**
9348
11730
  * Executes the upgrade flow.
9349
11731
  * @param options Command options, including `dryRun` to check for updates without installing.
@@ -9388,6 +11770,7 @@ var UpgradeCommand = class {
9388
11770
  const fallbackCmd = this.getUpgradeCommand(pm, "latest");
9389
11771
  (0, child_process.execSync)(fallbackCmd, { stdio: "inherit" });
9390
11772
  }
11773
+ await this.printMcpRefreshHint();
9391
11774
  if (pm === "pnpm") {
9392
11775
  const installedVersion = this.getInstalledVersionFromPnpm(latestVersion);
9393
11776
  if (installedVersion !== latestVersion) {
@@ -9439,6 +11822,12 @@ var UpgradeCommand = class {
9439
11822
  this.printManualInstructions(pm, latestVersion);
9440
11823
  }
9441
11824
  }
11825
+ async printMcpRefreshHint() {
11826
+ try {
11827
+ if (!(this.deps.loadMcpEnabled ? await this.deps.loadMcpEnabled() : (await new ConfigService().loadConfig())?.mcp?.enabled ?? false)) return;
11828
+ console.log(picocolors.default.gray(`MCP configs are not rewritten by upgrade — run \`ags sync\` (or \`ags mcp install\`) to move them to ${MCP_COMPATIBLE_VERSION}.`));
11829
+ } catch {}
11830
+ }
9442
11831
  detectPackageManager() {
9443
11832
  const execPath = process.argv[1] || "";
9444
11833
  if (execPath.includes("pnpm") || (process.env.npm_config_user_agent || "").includes("pnpm")) return "pnpm";
@@ -10251,7 +12640,7 @@ var UninstallService = class {
10251
12640
  missing.push(rel);
10252
12641
  continue;
10253
12642
  }
10254
- if (sha256(await fs_extra.default.readFile(absPath)) === entry.sha256) remove.push(rel);
12643
+ if (sha256$1(await fs_extra.default.readFile(absPath)) === entry.sha256) remove.push(rel);
10255
12644
  else keepEdited.push(rel);
10256
12645
  }
10257
12646
  const removedAgents = hasAgents ? [...selection.agents] : [];
@@ -10266,7 +12655,7 @@ var UninstallService = class {
10266
12655
  }
10267
12656
  const entry = lock.entries[rel];
10268
12657
  const content = await fs_extra.default.readFile(absPath);
10269
- if (entry && sha256(content) === entry.sha256) remove.push(rel);
12658
+ if (entry && sha256$1(content) === entry.sha256) remove.push(rel);
10270
12659
  else keepEdited.push(rel);
10271
12660
  }
10272
12661
  return {
@@ -11376,7 +13765,7 @@ var PolicyCommand = class {
11376
13765
  };
11377
13766
  //#endregion
11378
13767
  //#region src/index.ts
11379
- dotenv.default.config();
13768
+ dotenv.default.config({ quiet: true });
11380
13769
  var _agsExecPath = process.argv[1] ?? "";
11381
13770
  if (_agsExecPath.includes("/pnpm/") && _agsExecPath.includes("/node_modules/agent-skills-standard/") && !_agsExecPath.includes(`agent-skills-standard@${package_default.version}`)) process.stderr.write(`\n${picocolors.default.yellow("⚠️ ags is running from a stale global shim — reported version will be wrong.")}\n ${picocolors.default.cyan("Quickfix:")} ${picocolors.default.bold("npx agent-skills-standard@latest upgrade")} ` + picocolors.default.gray("(auto-detects & removes the stale shim)") + "\n\n");
11382
13771
  var program = new commander.Command();
@@ -11384,8 +13773,9 @@ program.name("agent-skills-standard").description("A CLI to manage and sync AI a
11384
13773
  program.command("init").description("Initialize a .skillsrc configuration file interactively").action(async () => {
11385
13774
  await new InitCommand().run();
11386
13775
  });
11387
- 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").option("--dry-run", "Show what sync would change without writing anything").option("--force <paths...>", "Overwrite these files even if you edited them (backed up first)").option("--verbose", "With --dry-run: list every path").option("--json", "With --dry-run: print the plan as JSON (sync.plan)").action(async (options) => {
11388
- await new SyncCommand().run(options);
13776
+ 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").option("--dry-run", "Show what sync would change without writing anything").option("--force <paths...>", "Overwrite these files even if you edited them (backed up first)").option("--verbose", "With --dry-run: list every path").option("--json", "Emit one versioned JSON envelope on stdout (sync.plan for --dry-run, sync.result/sync.error otherwise)").option("--accept-attribution-changes", "Consent to the previewed attribution changes for this run only (ordinary --yes is not consent)").action(async (options) => {
13777
+ const outcome = await new SyncCommand().run(options);
13778
+ process.exitCode = outcome.status === "success" || outcome.status === "dry-run" ? 0 : 1;
11389
13779
  });
11390
13780
  program.command("verify").description("Check installed 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)").option("--strict", "Fail if any locked ref has moved (resolves to a different commit)").option("--attestation", "Verify build-provenance attestations using the GitHub CLI (gh)").action(async (options) => {
11391
13781
  await new VerifyCommand().run(options);
@@ -11414,9 +13804,12 @@ program.command("feedback").description("Report skill improvement opportunities
11414
13804
  program.command("upgrade").description("Upgrade the CLI to the latest version").option("--dry-run", "Check for updates without installing").action(async (options) => {
11415
13805
  await new UpgradeCommand().run(options);
11416
13806
  });
11417
- program.command("mcp <action> [scope]").description("Manage the optional MCP server integration. Actions: status | enable | disable | scope <project|user|snippets-only|disabled> | install | uninstall | snippets").option("--scope <scope>", "Override scope for install (project|user|snippets-only|disabled)").option("--from <from>", "For uninstall: project | user | all (default: project)").action(async (action, positional, opts) => {
13807
+ program.command("mcp <action> [scope]").description("Manage the optional MCP server integration. Actions: status | enable | disable | scope <project|user|snippets-only|disabled> | via <auto|npx|global> | install | uninstall | snippets").option("--scope <scope>", "Override scope for install (project|user|snippets-only|disabled)").option("--via <via>", "Override launch mode for this run (auto|npx|global)").option("--install-global", "On enable: install agent-skills-standard-mcp@<pin> globally, then verify its version").option("--from <from>", "For uninstall: project | user | all (default: project)").action(async (action, positional, opts) => {
11418
13808
  const merged = { ...opts };
11419
- if (positional) merged.scope = opts.scope ?? positional;
13809
+ if (positional) {
13810
+ if (action === "via") merged.via = opts.via ?? positional;
13811
+ else merged.scope = opts.scope ?? positional;
13812
+ }
11420
13813
  await new McpCommand().run(action, merged);
11421
13814
  });
11422
13815
  program.command("retrospective <action> [args...]").description("Enable, disable or inspect local session retrospectives (consent and the cross-session index). Runs the pinned agent-skills-standard-mcp package. Actions: enable | disable | status | index").allowUnknownOption(true).option("--store-root <path>", "Store root directory").option("--source-root <path>", "Additional source root for transcripts (repeatable)", (val, prev) => prev ? [...prev, val] : [val]).option("--since <YYYY-MM-DD>", "Index sessions since date").option("--until <YYYY-MM-DD>", "Index sessions until date").option("--folder <name>", "Index sessions from folder").action(async (action, args = [], options = {}) => {