agent-skills-standard 2.7.0 → 2.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2388 -382
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38,8 +38,11 @@ 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);
|
|
41
43
|
let os = require("os");
|
|
42
44
|
os = __toESM(os);
|
|
45
|
+
let node_stream = require("node:stream");
|
|
43
46
|
let node_child_process = require("node:child_process");
|
|
44
47
|
let node_os = require("node:os");
|
|
45
48
|
node_os = __toESM(node_os);
|
|
@@ -50,7 +53,7 @@ let crypto = require("crypto");
|
|
|
50
53
|
crypto = __toESM(crypto);
|
|
51
54
|
var package_default = {
|
|
52
55
|
name: "agent-skills-standard",
|
|
53
|
-
version: "2.7.
|
|
56
|
+
version: "2.7.2",
|
|
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: {
|
|
@@ -1455,10 +1458,62 @@ var SkillConfigSchema = zod.z.object({
|
|
|
1455
1458
|
mcp: McpConfigSchema.optional(),
|
|
1456
1459
|
telemetry: zod.z.boolean().optional()
|
|
1457
1460
|
});
|
|
1461
|
+
/**
|
|
1462
|
+
* Known top-level runtime fields a reviewed publication may replace. Unlisted
|
|
1463
|
+
* (unknown/extension) root fields are always preserved verbatim.
|
|
1464
|
+
*/
|
|
1465
|
+
var REVIEWED_ROOT_FIELDS = [
|
|
1466
|
+
"registry",
|
|
1467
|
+
"agents",
|
|
1468
|
+
"custom_overrides",
|
|
1469
|
+
"workflows",
|
|
1470
|
+
"prune",
|
|
1471
|
+
"workflows_ref",
|
|
1472
|
+
"specialists_ref",
|
|
1473
|
+
"telemetry",
|
|
1474
|
+
"skills_mode"
|
|
1475
|
+
];
|
|
1476
|
+
/** Known MCP fields; unknown MCP extension keys survive an MCP consent update. */
|
|
1477
|
+
var MCP_FIELD_KEYS = [
|
|
1478
|
+
"enabled",
|
|
1479
|
+
"scope",
|
|
1480
|
+
"prompted",
|
|
1481
|
+
"version",
|
|
1482
|
+
"snippets"
|
|
1483
|
+
];
|
|
1458
1484
|
var DEFAULT_SDLC_SUPPORT_CATEGORIES = ["quality-engineering"];
|
|
1459
1485
|
var PRIMARY_CONFIG_FILE = ".skillsrc";
|
|
1460
1486
|
var LEGACY_YAML_CONFIG_FILE = ".skillsrc.yaml";
|
|
1461
1487
|
var CONFIG_CONFLICT_MESSAGE = "Configuration changed on disk since it was opened; reload before applying.";
|
|
1488
|
+
var RESERVED_SELECTION_KEYS = {
|
|
1489
|
+
ref: "set `ref` on the category itself, e.g. `skills.<category>.ref`",
|
|
1490
|
+
include: "move the allowlist under the category it applies to, e.g. `skills.<category>.include`",
|
|
1491
|
+
exclude: "move the exclusion list under the category it applies to, e.g. `skills.<category>.exclude`; a global `skills.exclude` is not supported"
|
|
1492
|
+
};
|
|
1493
|
+
function isReservedSelectionKey(key) {
|
|
1494
|
+
return Object.prototype.hasOwnProperty.call(RESERVED_SELECTION_KEYS, key);
|
|
1495
|
+
}
|
|
1496
|
+
function isLegacySectionKey(trimmed) {
|
|
1497
|
+
if (trimmed === "workflows:") return "workflows";
|
|
1498
|
+
if (trimmed === "mcp:") return "mcp";
|
|
1499
|
+
if (trimmed.startsWith("custom_overrides:")) return "custom_overrides";
|
|
1500
|
+
return null;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Invalid configuration that names both the file and the logical key needing
|
|
1504
|
+
* repair. Callers surface the message verbatim rather than guessing a
|
|
1505
|
+
* migration or rewriting the user's document.
|
|
1506
|
+
*/
|
|
1507
|
+
var ConfigValidationError = class extends Error {
|
|
1508
|
+
configPath;
|
|
1509
|
+
logicalPath;
|
|
1510
|
+
constructor(configPath, logicalPath, detail) {
|
|
1511
|
+
super(`Invalid configuration at ${configPath}: "${logicalPath}" ${detail}`);
|
|
1512
|
+
this.configPath = configPath;
|
|
1513
|
+
this.logicalPath = logicalPath;
|
|
1514
|
+
this.name = "ConfigValidationError";
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1462
1517
|
/**
|
|
1463
1518
|
* Service for managing the `.skillsrc` configuration file.
|
|
1464
1519
|
* Handles loading, saving, and initial construction of the configuration based on project metadata.
|
|
@@ -1498,12 +1553,32 @@ var ConfigService = class {
|
|
|
1498
1553
|
if (trimmed.startsWith("- ")) return trimmed.replace(/^-\s+/, "").split(/\s+-\s+/).map((item) => item.trim()).filter(Boolean);
|
|
1499
1554
|
return trimmed.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1500
1555
|
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Parses one inline legacy value with the document YAML parser so quoted
|
|
1558
|
+
* scalars and flow collections keep their YAML meaning. Shapes the
|
|
1559
|
+
* line-oriented fallback cannot represent unambiguously are refused instead
|
|
1560
|
+
* of being published as raw token strings.
|
|
1561
|
+
*/
|
|
1562
|
+
parseLegacyYamlValue(raw, configPath, logicalPath) {
|
|
1563
|
+
let parsed;
|
|
1564
|
+
try {
|
|
1565
|
+
parsed = js_yaml.load(raw);
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
throw new ConfigValidationError(configPath, logicalPath, `has an inline value ("${raw}") that is not valid YAML: ${error.message}`);
|
|
1568
|
+
}
|
|
1569
|
+
if (raw.startsWith("{") || raw.startsWith("[")) {
|
|
1570
|
+
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.`);
|
|
1571
|
+
return parsed;
|
|
1572
|
+
}
|
|
1573
|
+
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 {..}/[..].`);
|
|
1574
|
+
return parsed;
|
|
1575
|
+
}
|
|
1501
1576
|
parseLegacyBoolean(value) {
|
|
1502
1577
|
const normalized = value.trim().toLowerCase();
|
|
1503
1578
|
if (normalized === "true") return true;
|
|
1504
1579
|
if (normalized === "false") return false;
|
|
1505
1580
|
}
|
|
1506
|
-
migrateLegacyTextConfig(content) {
|
|
1581
|
+
migrateLegacyTextConfig(content, configPath) {
|
|
1507
1582
|
const lines = content.split(/\r?\n/);
|
|
1508
1583
|
const registryLine = lines.find((line) => line.trim().startsWith("registry:"));
|
|
1509
1584
|
const agentsIndex = lines.findIndex((line) => line.trim() === "agents:");
|
|
@@ -1523,35 +1598,74 @@ var ConfigService = class {
|
|
|
1523
1598
|
agents.push(match[1].trim());
|
|
1524
1599
|
}
|
|
1525
1600
|
config.agents = agents;
|
|
1526
|
-
const
|
|
1527
|
-
|
|
1528
|
-
"workflows",
|
|
1529
|
-
"mcp"
|
|
1530
|
-
]);
|
|
1601
|
+
const skills = {};
|
|
1602
|
+
config.skills = skills;
|
|
1531
1603
|
let currentCategory = null;
|
|
1604
|
+
let currentCategoryIndent = 0;
|
|
1605
|
+
let pendingList = null;
|
|
1532
1606
|
for (let index = skillsIndex + 1; index < lines.length; index += 1) {
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
if (trimmed
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1607
|
+
const line = lines[index];
|
|
1608
|
+
const trimmed = line.trim();
|
|
1609
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1610
|
+
const indent = line.length - line.trimStart().length;
|
|
1611
|
+
const sectionKey = isLegacySectionKey(trimmed);
|
|
1612
|
+
if (sectionKey) {
|
|
1613
|
+
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.");
|
|
1614
|
+
break;
|
|
1615
|
+
}
|
|
1616
|
+
if (trimmed.startsWith("- ")) {
|
|
1617
|
+
const item = trimmed.slice(2).trim();
|
|
1618
|
+
if (!pendingList || !currentCategory) continue;
|
|
1619
|
+
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.`);
|
|
1620
|
+
if (item) {
|
|
1621
|
+
const entry = skills[currentCategory];
|
|
1622
|
+
const value = this.parseLegacyYamlValue(item, configPath, `skills.${currentCategory}.${pendingList.key}`);
|
|
1623
|
+
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.`);
|
|
1624
|
+
const current = entry[pendingList.key];
|
|
1625
|
+
entry[pendingList.key] = Array.isArray(current) ? [...current, value] : [value];
|
|
1626
|
+
}
|
|
1540
1627
|
continue;
|
|
1541
1628
|
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1629
|
+
const keyMatch = trimmed.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
|
|
1630
|
+
if (!keyMatch) continue;
|
|
1631
|
+
const key = keyMatch[1];
|
|
1632
|
+
const inlineValue = (keyMatch[2] ?? "").trim();
|
|
1633
|
+
const isNestedProperty = currentCategory !== null && indent > currentCategoryIndent;
|
|
1634
|
+
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.");
|
|
1635
|
+
if (isReservedSelectionKey(key)) {
|
|
1636
|
+
if (!currentCategory || indent < currentCategoryIndent) throw new ConfigValidationError(configPath, key, `cannot infer which category this ${key} applies to. Correction: ${RESERVED_SELECTION_KEYS[key]}.`);
|
|
1637
|
+
pendingList = null;
|
|
1638
|
+
const entry = skills[currentCategory];
|
|
1639
|
+
if (key === "ref") {
|
|
1640
|
+
if (!inlineValue) throw new ConfigValidationError(configPath, `skills.${currentCategory}.ref`, "must name a git ref.");
|
|
1641
|
+
entry.ref = inlineValue;
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
entry[key] = inlineValue ? this.parseLegacyList(inlineValue) : [];
|
|
1645
|
+
if (!inlineValue) pendingList = {
|
|
1646
|
+
key,
|
|
1647
|
+
indent
|
|
1648
|
+
};
|
|
1546
1649
|
continue;
|
|
1547
1650
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1651
|
+
if (isNestedProperty && currentCategory) {
|
|
1652
|
+
pendingList = null;
|
|
1653
|
+
const entry = skills[currentCategory];
|
|
1654
|
+
if (inlineValue) entry[key] = this.parseLegacyYamlValue(inlineValue, configPath, `skills.${currentCategory}.${key}`);
|
|
1655
|
+
else {
|
|
1656
|
+
entry[key] = null;
|
|
1657
|
+
pendingList = {
|
|
1658
|
+
key,
|
|
1659
|
+
indent
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1551
1662
|
continue;
|
|
1552
1663
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1664
|
+
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}".`);
|
|
1665
|
+
currentCategory = key;
|
|
1666
|
+
currentCategoryIndent = indent;
|
|
1667
|
+
pendingList = null;
|
|
1668
|
+
skills[currentCategory] = {};
|
|
1555
1669
|
}
|
|
1556
1670
|
const overridesLine = lines.find((line) => line.trim().startsWith("custom_overrides:"));
|
|
1557
1671
|
if (overridesLine) {
|
|
@@ -1586,7 +1700,27 @@ var ConfigService = class {
|
|
|
1586
1700
|
}
|
|
1587
1701
|
return config;
|
|
1588
1702
|
}
|
|
1589
|
-
|
|
1703
|
+
describeValue(value) {
|
|
1704
|
+
if (Array.isArray(value)) return "a list";
|
|
1705
|
+
if (value === null) return "null";
|
|
1706
|
+
return `a ${typeof value}`;
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Rejects reserved selection keys and non-mapping category values before any
|
|
1710
|
+
* registry request, naming the exact logical key. Category names themselves
|
|
1711
|
+
* are never checked against a local allowlist: a project may use any category
|
|
1712
|
+
* the remote registry actually publishes.
|
|
1713
|
+
*/
|
|
1714
|
+
assertValidSkillCategories(skills, configPath) {
|
|
1715
|
+
if (skills === void 0) return;
|
|
1716
|
+
if (!this.isRecord(skills)) throw new ConfigValidationError(configPath, "skills", `must be a mapping of category name to its selection, not ${this.describeValue(skills)}.`);
|
|
1717
|
+
for (const [category, value] of Object.entries(skills)) {
|
|
1718
|
+
if (isReservedSelectionKey(category)) throw new ConfigValidationError(configPath, `skills.${category}`, `is a reserved selection key, not a category. Correction: ${RESERVED_SELECTION_KEYS[category]}.`);
|
|
1719
|
+
if (value === null || value === void 0) continue;
|
|
1720
|
+
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)}.`);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
normalizeRawConfig(rawConfig, configPath) {
|
|
1590
1724
|
if (!this.isRecord(rawConfig)) return {
|
|
1591
1725
|
config: {},
|
|
1592
1726
|
migrated: false
|
|
@@ -1607,10 +1741,11 @@ var ConfigService = class {
|
|
|
1607
1741
|
}
|
|
1608
1742
|
return agent;
|
|
1609
1743
|
});
|
|
1744
|
+
this.assertValidSkillCategories(normalized.skills, configPath);
|
|
1610
1745
|
if (this.isRecord(normalized.skills)) {
|
|
1611
1746
|
const normalizedSkills = {};
|
|
1612
1747
|
for (const [category, value] of Object.entries(normalized.skills)) {
|
|
1613
|
-
if (
|
|
1748
|
+
if (value === null || value === void 0) {
|
|
1614
1749
|
normalizedSkills[category] = {};
|
|
1615
1750
|
migrated = true;
|
|
1616
1751
|
continue;
|
|
@@ -1648,20 +1783,29 @@ var ConfigService = class {
|
|
|
1648
1783
|
const configPath = await this.resolveConfigPath(cwd);
|
|
1649
1784
|
if (!configPath) return null;
|
|
1650
1785
|
const content = await fs_extra.default.readFile(configPath, "utf8");
|
|
1786
|
+
return this.parseConfigSource(content, configPath, cwd);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Parses already-read bytes with the same parser as `readConfigSource`. Used
|
|
1790
|
+
* by reviewed publication so its baseline comes from the verified file
|
|
1791
|
+
* content rather than caller-held snapshot objects.
|
|
1792
|
+
*/
|
|
1793
|
+
parseConfigSource(content, configPath, cwd) {
|
|
1651
1794
|
let rawConfig;
|
|
1652
1795
|
let migratedFromText = false;
|
|
1653
1796
|
try {
|
|
1654
1797
|
rawConfig = js_yaml.load(content);
|
|
1655
1798
|
} catch (parseError) {
|
|
1656
|
-
rawConfig = this.migrateLegacyTextConfig(content);
|
|
1799
|
+
rawConfig = this.migrateLegacyTextConfig(content, configPath);
|
|
1657
1800
|
migratedFromText = rawConfig !== null;
|
|
1658
1801
|
if (!migratedFromText) throw parseError;
|
|
1659
1802
|
}
|
|
1660
|
-
const { config: normalizedConfig, migrated } = this.normalizeRawConfig(rawConfig);
|
|
1803
|
+
const { config: normalizedConfig, migrated } = this.normalizeRawConfig(rawConfig, configPath);
|
|
1661
1804
|
const parsed = SkillConfigSchema.safeParse(normalizedConfig);
|
|
1662
1805
|
if (!parsed.success) throw new Error(`Invalid .skillsrc format: ${parsed.error.message}`);
|
|
1663
1806
|
return {
|
|
1664
1807
|
config: parsed.data,
|
|
1808
|
+
document: normalizedConfig,
|
|
1665
1809
|
raw: this.isRecord(rawConfig) ? { ...rawConfig } : {},
|
|
1666
1810
|
sourcePath: configPath,
|
|
1667
1811
|
fingerprint: (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex"),
|
|
@@ -1680,10 +1824,11 @@ var ConfigService = class {
|
|
|
1680
1824
|
try {
|
|
1681
1825
|
const source = await this.readConfigSource(cwd);
|
|
1682
1826
|
if (!source) return null;
|
|
1683
|
-
if (source.migratedFromText || source.migrated || source.shouldRenameLegacyYamlFile) await fs_extra.default.outputFile(this.getPrimaryConfigPath(cwd), this.dumpConfig(source.
|
|
1827
|
+
if (source.migratedFromText || source.migrated || source.shouldRenameLegacyYamlFile) await fs_extra.default.outputFile(this.getPrimaryConfigPath(cwd), this.dumpConfig(source.document));
|
|
1684
1828
|
if (source.shouldRenameLegacyYamlFile) await fs_extra.default.remove(source.sourcePath);
|
|
1685
1829
|
return source.config;
|
|
1686
1830
|
} catch (error) {
|
|
1831
|
+
if (error instanceof ConfigValidationError) throw error;
|
|
1687
1832
|
throw new Error(`Failed to load config: ${error}`, { cause: error });
|
|
1688
1833
|
}
|
|
1689
1834
|
}
|
|
@@ -1707,28 +1852,92 @@ var ConfigService = class {
|
|
|
1707
1852
|
* unrelated root values and category extension fields survive.
|
|
1708
1853
|
*/
|
|
1709
1854
|
async saveSelection(snapshot, next, cwd = process.cwd()) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1855
|
+
return this.commitReviewedSnapshot(snapshot, next, cwd, (baseline, validated) => {
|
|
1856
|
+
const raw = baseline.raw;
|
|
1857
|
+
const merged = { ...raw };
|
|
1858
|
+
merged.skills = this.mergeSkillEntries(raw.skills, validated.skills);
|
|
1859
|
+
if (validated.skills_mode) merged.skills_mode = validated.skills_mode;
|
|
1860
|
+
else delete merged.skills_mode;
|
|
1861
|
+
return merged;
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Publishes a complete reviewed configuration (accepted category/workflow/
|
|
1866
|
+
* specialist pins and later MCP consent updates) without dropping unknown
|
|
1867
|
+
* data. Changed known runtime fields are applied over the source document,
|
|
1868
|
+
* skill entries merge while preserving category extensions, and unknown root
|
|
1869
|
+
* fields plus unknown MCP extension fields survive.
|
|
1870
|
+
*/
|
|
1871
|
+
async saveReviewedConfig(snapshot, next, cwd = process.cwd()) {
|
|
1872
|
+
return this.commitReviewedSnapshot(snapshot, next, cwd, (baseline, validated, configPath) => this.buildReviewedDocument(baseline.config, baseline.raw, validated, configPath));
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Shared publication: validate, verify the snapshot still matches the file
|
|
1876
|
+
* bytes, re-parse those bytes as the only baseline, build and validate the
|
|
1877
|
+
* normalized document, then write. Any failure refuses before the filesystem
|
|
1878
|
+
* is touched. `snapshot.config`/`snapshot.raw` are never used as a baseline
|
|
1879
|
+
* because callers can mutate them in place.
|
|
1880
|
+
*/
|
|
1881
|
+
async commitReviewedSnapshot(snapshot, next, cwd, buildDocument) {
|
|
1882
|
+
this.assertValidSkillCategories(next.skills, snapshot.sourcePath);
|
|
1883
|
+
const parsedNext = SkillConfigSchema.safeParse(next);
|
|
1884
|
+
if (!parsedNext.success) throw new Error(`Invalid .skillsrc format: ${parsedNext.error.message}`);
|
|
1885
|
+
const validated = parsedNext.data;
|
|
1713
1886
|
if (await this.resolveConfigPath(cwd) !== snapshot.sourcePath) throw new Error(CONFIG_CONFLICT_MESSAGE);
|
|
1714
1887
|
const currentContent = await fs_extra.default.readFile(snapshot.sourcePath, "utf8");
|
|
1715
1888
|
if ((0, node_crypto.createHash)("sha256").update(currentContent, "utf8").digest("hex") !== snapshot.fingerprint) throw new Error(CONFIG_CONFLICT_MESSAGE);
|
|
1716
|
-
const
|
|
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;
|
|
1889
|
+
const baseline = this.parseConfigSource(currentContent, snapshot.sourcePath, cwd);
|
|
1720
1890
|
const primaryConfigPath = this.getPrimaryConfigPath(cwd);
|
|
1891
|
+
const merged = buildDocument(baseline, validated, primaryConfigPath);
|
|
1892
|
+
const { config: normalizedDocument } = this.normalizeRawConfig(merged, primaryConfigPath);
|
|
1893
|
+
const persisted = SkillConfigSchema.safeParse(normalizedDocument);
|
|
1894
|
+
if (!persisted.success) throw new Error(`Invalid .skillsrc format: ${persisted.error.message}`);
|
|
1721
1895
|
const content = this.dumpConfig(merged);
|
|
1722
1896
|
await fs_extra.default.outputFile(primaryConfigPath, content);
|
|
1723
1897
|
const legacyYamlPath = this.getLegacyYamlConfigPath(cwd);
|
|
1724
1898
|
if (snapshot.sourcePath === legacyYamlPath && await fs_extra.default.pathExists(legacyYamlPath)) await fs_extra.default.remove(legacyYamlPath);
|
|
1725
1899
|
return {
|
|
1726
|
-
config:
|
|
1900
|
+
config: persisted.data,
|
|
1727
1901
|
raw: merged,
|
|
1728
1902
|
sourcePath: primaryConfigPath,
|
|
1729
1903
|
fingerprint: (0, node_crypto.createHash)("sha256").update(content, "utf8").digest("hex")
|
|
1730
1904
|
};
|
|
1731
1905
|
}
|
|
1906
|
+
buildReviewedDocument(previous, raw, next, configPath) {
|
|
1907
|
+
const merged = { ...raw };
|
|
1908
|
+
merged.skills = this.mergeSkillEntries(raw.skills, next.skills);
|
|
1909
|
+
const previousFields = previous;
|
|
1910
|
+
const nextFields = next;
|
|
1911
|
+
for (const field of REVIEWED_ROOT_FIELDS) {
|
|
1912
|
+
if (this.valuesEqual(nextFields[field], previousFields[field])) continue;
|
|
1913
|
+
if (nextFields[field] === void 0) delete merged[field];
|
|
1914
|
+
else merged[field] = nextFields[field];
|
|
1915
|
+
}
|
|
1916
|
+
if (!this.valuesEqual(next.mcp, previous.mcp)) {
|
|
1917
|
+
if (next.mcp === void 0) {
|
|
1918
|
+
const unknownKeys = this.isRecord(raw.mcp) ? Object.keys(raw.mcp).filter((key) => !MCP_FIELD_KEYS.includes(key)) : [];
|
|
1919
|
+
if (unknownKeys.length > 0) throw new ConfigValidationError(configPath, "mcp", `cannot be removed because it contains unrecognized extension keys (${unknownKeys.join(", ")}); remove them explicitly first.`);
|
|
1920
|
+
delete merged.mcp;
|
|
1921
|
+
} else {
|
|
1922
|
+
const mcp = { ...this.isRecord(raw.mcp) ? raw.mcp : {} };
|
|
1923
|
+
const nextMcp = next.mcp;
|
|
1924
|
+
for (const key of MCP_FIELD_KEYS) if (Object.prototype.hasOwnProperty.call(nextMcp, key)) mcp[key] = nextMcp[key];
|
|
1925
|
+
else delete mcp[key];
|
|
1926
|
+
merged.mcp = mcp;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
return merged;
|
|
1930
|
+
}
|
|
1931
|
+
valuesEqual(left, right) {
|
|
1932
|
+
if (left === right) return true;
|
|
1933
|
+
if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => this.valuesEqual(value, right[index]));
|
|
1934
|
+
if (this.isRecord(left) && this.isRecord(right)) {
|
|
1935
|
+
const leftKeys = Object.keys(left);
|
|
1936
|
+
const rightKeys = Object.keys(right);
|
|
1937
|
+
return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && this.valuesEqual(left[key], right[key]));
|
|
1938
|
+
}
|
|
1939
|
+
return false;
|
|
1940
|
+
}
|
|
1732
1941
|
mergeSkillEntries(rawSkills, nextSkills) {
|
|
1733
1942
|
const existing = this.isRecord(rawSkills) ? rawSkills : {};
|
|
1734
1943
|
const merged = {};
|
|
@@ -1752,6 +1961,7 @@ var ConfigService = class {
|
|
|
1752
1961
|
async saveConfig(config, cwd = process.cwd()) {
|
|
1753
1962
|
const primaryConfigPath = this.getPrimaryConfigPath(cwd);
|
|
1754
1963
|
const legacyYamlPath = this.getLegacyYamlConfigPath(cwd);
|
|
1964
|
+
this.assertValidSkillCategories(config.skills, primaryConfigPath);
|
|
1755
1965
|
await fs_extra.default.outputFile(primaryConfigPath, this.dumpConfig(config));
|
|
1756
1966
|
if (legacyYamlPath !== primaryConfigPath && await fs_extra.default.pathExists(legacyYamlPath)) await fs_extra.default.remove(legacyYamlPath);
|
|
1757
1967
|
}
|
|
@@ -2606,8 +2816,485 @@ var HookService = class {
|
|
|
2606
2816
|
}
|
|
2607
2817
|
};
|
|
2608
2818
|
//#endregion
|
|
2819
|
+
//#region src/services/install/safePath.ts
|
|
2820
|
+
/**
|
|
2821
|
+
* Asserts that a target file path resides inside rootDir and that NO intermediate
|
|
2822
|
+
* directory or terminal file under rootDir is a symbolic link.
|
|
2823
|
+
*
|
|
2824
|
+
* Requirements:
|
|
2825
|
+
* - rootDir may itself be a symlink (or inside one); components under rootDir must NOT be.
|
|
2826
|
+
* - Both existing files and dangling symlinks must be detected via lstat.
|
|
2827
|
+
* - Fails closed if lexical containment is breached or if any component under rootDir is a symlink.
|
|
2828
|
+
*
|
|
2829
|
+
* @param rootDir The trusted root directory
|
|
2830
|
+
* @param targetPath The destination file/directory path to check
|
|
2831
|
+
*/
|
|
2832
|
+
async function assertNoSymlinksUnderRoot(rootDir, targetPath) {
|
|
2833
|
+
const resolvedRoot = path.default.resolve(rootDir);
|
|
2834
|
+
const resolvedTarget = path.default.resolve(targetPath);
|
|
2835
|
+
const relative = path.default.relative(resolvedRoot, resolvedTarget);
|
|
2836
|
+
if (relative === ".." || relative.startsWith(`..${path.default.sep}`) || path.default.isAbsolute(relative)) throw new Error(`Path traversal or outside target detected: "${targetPath}" is outside "${rootDir}"`);
|
|
2837
|
+
const segments = relative.split(path.default.sep).filter(Boolean);
|
|
2838
|
+
let current = resolvedRoot;
|
|
2839
|
+
for (const segment of segments) {
|
|
2840
|
+
current = path.default.join(current, segment);
|
|
2841
|
+
try {
|
|
2842
|
+
if ((await fs_extra.default.lstat(current)).isSymbolicLink()) throw new Error(`Symlink path component not permitted: "${current}" is a symbolic link under "${rootDir}"`);
|
|
2843
|
+
} catch (err) {
|
|
2844
|
+
if (err instanceof Error && err.message.includes("Symlink path component")) throw err;
|
|
2845
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") break;
|
|
2846
|
+
throw err;
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
var DIGEST_RE$1 = /^[0-9a-f]{64}$/;
|
|
2851
|
+
var GIT_BLOB_SHA1_RE$1 = /^[0-9a-f]{40}$/;
|
|
2852
|
+
var COMMIT_RE = /^[0-9a-f]{40}$/;
|
|
2853
|
+
var PACKAGE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2854
|
+
var CATEGORY_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2855
|
+
var ATTRIBUTION_FILE_NAME_RE$1 = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
|
|
2856
|
+
function isBoundedRelativePath$1(value) {
|
|
2857
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
2858
|
+
if (value.includes("\0") || value.includes("\\")) return false;
|
|
2859
|
+
if (node_path.default.posix.isAbsolute(value) || node_path.default.isAbsolute(value)) return false;
|
|
2860
|
+
if (node_path.default.posix.normalize(value) !== value) return false;
|
|
2861
|
+
return !value.split("/").includes("..");
|
|
2862
|
+
}
|
|
2863
|
+
function isBoundedAttributionFileName(value) {
|
|
2864
|
+
return typeof value === "string" && ATTRIBUTION_FILE_NAME_RE$1.test(value);
|
|
2865
|
+
}
|
|
2866
|
+
function fail$1(where, message) {
|
|
2867
|
+
throw new Error(`Invalid .skills-lock.json attributionCatalog: ${where} ${message}`);
|
|
2868
|
+
}
|
|
2869
|
+
function assertExactKeys(where, value, keys) {
|
|
2870
|
+
const allowed = new Set(keys);
|
|
2871
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) fail$1(where, `has unexpected field '${key}'`);
|
|
2872
|
+
}
|
|
2873
|
+
function assertString(where, field, value) {
|
|
2874
|
+
if (typeof value !== "string" || value.length === 0) fail$1(where, `has an invalid ${field}`);
|
|
2875
|
+
return value;
|
|
2876
|
+
}
|
|
2877
|
+
function assertRecord(where, value) {
|
|
2878
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail$1(where, "must be an object");
|
|
2879
|
+
return value;
|
|
2880
|
+
}
|
|
2881
|
+
function assertDigest(where, field, value) {
|
|
2882
|
+
if (typeof value !== "string" || !DIGEST_RE$1.test(value)) fail$1(where, `has an invalid ${field}`);
|
|
2883
|
+
return value;
|
|
2884
|
+
}
|
|
2885
|
+
function assertCommit(where, field, value) {
|
|
2886
|
+
if (typeof value !== "string" || !COMMIT_RE.test(value)) fail$1(where, `has an invalid ${field} (expected a resolved commit)`);
|
|
2887
|
+
return value;
|
|
2888
|
+
}
|
|
2889
|
+
function assertBlobSha1(where, value) {
|
|
2890
|
+
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)");
|
|
2891
|
+
return value;
|
|
2892
|
+
}
|
|
2893
|
+
function parseDestination(where, value) {
|
|
2894
|
+
const raw = assertRecord(where, value);
|
|
2895
|
+
assertExactKeys(where, raw, [
|
|
2896
|
+
"agent",
|
|
2897
|
+
"path",
|
|
2898
|
+
"sha256"
|
|
2899
|
+
]);
|
|
2900
|
+
const agent = assertString(where, "destination agent", raw.agent);
|
|
2901
|
+
const destinationPath = assertString(where, "destination path", raw.path);
|
|
2902
|
+
if (!isBoundedRelativePath$1(destinationPath)) fail$1(where, "has an unsafe destination path");
|
|
2903
|
+
return {
|
|
2904
|
+
agent,
|
|
2905
|
+
path: destinationPath,
|
|
2906
|
+
sha256: assertDigest(where, "destination sha256", raw.sha256)
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
function parseFile(where, value) {
|
|
2910
|
+
const raw = assertRecord(where, value);
|
|
2911
|
+
assertExactKeys(where, raw, [
|
|
2912
|
+
"name",
|
|
2913
|
+
"sourcePath",
|
|
2914
|
+
"sourceKind",
|
|
2915
|
+
"requestedRef",
|
|
2916
|
+
"resolvedCommit",
|
|
2917
|
+
"blobSha1",
|
|
2918
|
+
"sha256",
|
|
2919
|
+
"basis",
|
|
2920
|
+
"destinations"
|
|
2921
|
+
]);
|
|
2922
|
+
const name = raw.name;
|
|
2923
|
+
if (!isBoundedAttributionFileName(name)) fail$1(where, "has an invalid installed filename");
|
|
2924
|
+
const sourcePath = assertString(where, "sourcePath", raw.sourcePath);
|
|
2925
|
+
if (!isBoundedRelativePath$1(sourcePath)) fail$1(where, "has an unsafe sourcePath");
|
|
2926
|
+
if (raw.sourceKind !== "package" && raw.sourceKind !== "root") fail$1(where, "has an invalid sourceKind");
|
|
2927
|
+
if (raw.basis !== "release-manifest" && raw.basis !== "commit-tree-blob") fail$1(where, "has an invalid verification basis");
|
|
2928
|
+
if (!Array.isArray(raw.destinations) || raw.destinations.length === 0) fail$1(where, "has no published destinations");
|
|
2929
|
+
const destinations = raw.destinations.map((binding, index) => parseDestination(`${where} destination[${index}]`, binding));
|
|
2930
|
+
return {
|
|
2931
|
+
name,
|
|
2932
|
+
sourcePath,
|
|
2933
|
+
sourceKind: raw.sourceKind,
|
|
2934
|
+
requestedRef: assertString(where, "requestedRef", raw.requestedRef),
|
|
2935
|
+
resolvedCommit: assertCommit(where, "resolvedCommit", raw.resolvedCommit),
|
|
2936
|
+
blobSha1: assertBlobSha1(where, raw.blobSha1),
|
|
2937
|
+
sha256: assertDigest(where, "sha256", raw.sha256),
|
|
2938
|
+
basis: raw.basis,
|
|
2939
|
+
destinations
|
|
2940
|
+
};
|
|
2941
|
+
}
|
|
2942
|
+
function parsePackage(where, value) {
|
|
2943
|
+
const raw = assertRecord(where, value);
|
|
2944
|
+
assertExactKeys(where, raw, [
|
|
2945
|
+
"registry",
|
|
2946
|
+
"declaringCategory",
|
|
2947
|
+
"requestedRef",
|
|
2948
|
+
"resolvedCommit",
|
|
2949
|
+
"files"
|
|
2950
|
+
]);
|
|
2951
|
+
if (typeof raw.declaringCategory !== "string" || !CATEGORY_TOKEN_RE.test(raw.declaringCategory)) fail$1(where, "has an invalid declaringCategory");
|
|
2952
|
+
if (!Array.isArray(raw.files) || raw.files.length === 0) fail$1(where, "has no attribution files");
|
|
2953
|
+
const fileNames = /* @__PURE__ */ new Set();
|
|
2954
|
+
const files = raw.files.map((file, index) => {
|
|
2955
|
+
const parsed = parseFile(`${where} files[${index}]`, file);
|
|
2956
|
+
if (fileNameKey(parsed.name, fileNames)) fail$1(where, `has a duplicate installed filename '${parsed.name}'`);
|
|
2957
|
+
return parsed;
|
|
2958
|
+
});
|
|
2959
|
+
return {
|
|
2960
|
+
registry: assertString(where, "registry", raw.registry),
|
|
2961
|
+
declaringCategory: raw.declaringCategory,
|
|
2962
|
+
requestedRef: assertString(where, "requestedRef", raw.requestedRef),
|
|
2963
|
+
resolvedCommit: assertCommit(where, "resolvedCommit", raw.resolvedCommit),
|
|
2964
|
+
files
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
function fileNameKey(name, seen) {
|
|
2968
|
+
const key = name.toLowerCase();
|
|
2969
|
+
if (seen.has(key)) return true;
|
|
2970
|
+
seen.add(key);
|
|
2971
|
+
return false;
|
|
2972
|
+
}
|
|
2973
|
+
/**
|
|
2974
|
+
* Validates and narrows the optional attribution catalog. Unknown versions and
|
|
2975
|
+
* malformed qualified records fail closed so no caller can republish them.
|
|
2976
|
+
*/
|
|
2977
|
+
function validateAttributionCatalog(catalog) {
|
|
2978
|
+
const raw = assertRecord("root", catalog);
|
|
2979
|
+
assertExactKeys("root", raw, ["version", "packages"]);
|
|
2980
|
+
if (raw.version !== 1) fail$1("root", `has unsupported version ${String(raw.version)} (this CLI only reads version 1)`);
|
|
2981
|
+
const rawPackages = assertRecord("packages", raw.packages);
|
|
2982
|
+
const packages = {};
|
|
2983
|
+
for (const [packageId, record] of Object.entries(rawPackages)) {
|
|
2984
|
+
if (!PACKAGE_ID_RE.test(packageId)) fail$1(`package '${packageId}'`, "is not a bounded category/skill key");
|
|
2985
|
+
packages[packageId] = parsePackage(`package '${packageId}'`, record);
|
|
2986
|
+
}
|
|
2987
|
+
return {
|
|
2988
|
+
version: 1,
|
|
2989
|
+
packages
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
function sameOrigin(left, right) {
|
|
2993
|
+
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;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* A previous record is trustworthy only when every recorded destination
|
|
2997
|
+
* exists and its own bytes still match its own recorded digest. A pooled
|
|
2998
|
+
* digest anywhere is not evidence: one intact copy never qualifies an edited
|
|
2999
|
+
* or missing sibling, and digests swapped between agents never match.
|
|
3000
|
+
*/
|
|
3001
|
+
function qualifiesAgainstInstalled(previous, installed) {
|
|
3002
|
+
if (!installed || installed.length === 0) return false;
|
|
3003
|
+
return previous.destinations.every((recorded) => {
|
|
3004
|
+
const actual = installed.find((candidate) => candidate.agent === recorded.agent && candidate.path === recorded.path);
|
|
3005
|
+
return actual !== void 0 && actual.sha256 !== null && actual.sha256 === recorded.sha256;
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
function compareFiles(previous, planned, unqualifiedNames, originMoved) {
|
|
3009
|
+
const previousByName = new Map(previous.files.map((file) => [file.name, file]));
|
|
3010
|
+
const plannedByName = new Map(planned.files.map((file) => [file.name, file]));
|
|
3011
|
+
const names = [.../* @__PURE__ */ new Set([...previousByName.keys(), ...plannedByName.keys()])].sort();
|
|
3012
|
+
const files = [];
|
|
3013
|
+
let sawAdded = false;
|
|
3014
|
+
let sawRemoved = false;
|
|
3015
|
+
let sawBytesChanged = false;
|
|
3016
|
+
let sawOriginChanged = false;
|
|
3017
|
+
for (const name of names) {
|
|
3018
|
+
const oldFile = previousByName.get(name) ?? null;
|
|
3019
|
+
const newFile = plannedByName.get(name) ?? null;
|
|
3020
|
+
const trustworthyOld = (oldFile ? unqualifiedNames.has(name) : false) ? null : oldFile;
|
|
3021
|
+
if (!oldFile && newFile) {
|
|
3022
|
+
sawAdded = true;
|
|
3023
|
+
files.push({
|
|
3024
|
+
name,
|
|
3025
|
+
status: "added",
|
|
3026
|
+
previousSha256: null,
|
|
3027
|
+
plannedSha256: newFile.sha256,
|
|
3028
|
+
previous: null,
|
|
3029
|
+
planned: newFile
|
|
3030
|
+
});
|
|
3031
|
+
continue;
|
|
3032
|
+
}
|
|
3033
|
+
if (oldFile && !newFile) {
|
|
3034
|
+
sawRemoved = true;
|
|
3035
|
+
files.push({
|
|
3036
|
+
name,
|
|
3037
|
+
status: "removed",
|
|
3038
|
+
previousSha256: oldFile.sha256,
|
|
3039
|
+
plannedSha256: null,
|
|
3040
|
+
previous: trustworthyOld,
|
|
3041
|
+
planned: null
|
|
3042
|
+
});
|
|
3043
|
+
continue;
|
|
3044
|
+
}
|
|
3045
|
+
if (oldFile && newFile) {
|
|
3046
|
+
if (oldFile.sha256 !== newFile.sha256) {
|
|
3047
|
+
sawBytesChanged = true;
|
|
3048
|
+
files.push({
|
|
3049
|
+
name,
|
|
3050
|
+
status: "changed",
|
|
3051
|
+
previousSha256: oldFile.sha256,
|
|
3052
|
+
plannedSha256: newFile.sha256,
|
|
3053
|
+
previous: trustworthyOld,
|
|
3054
|
+
planned: newFile
|
|
3055
|
+
});
|
|
3056
|
+
continue;
|
|
3057
|
+
}
|
|
3058
|
+
if (!sameOrigin(oldFile, newFile) || originMoved) {
|
|
3059
|
+
sawOriginChanged = true;
|
|
3060
|
+
files.push({
|
|
3061
|
+
name,
|
|
3062
|
+
status: "origin-changed",
|
|
3063
|
+
previousSha256: oldFile.sha256,
|
|
3064
|
+
plannedSha256: newFile.sha256,
|
|
3065
|
+
previous: trustworthyOld,
|
|
3066
|
+
planned: newFile
|
|
3067
|
+
});
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
files.push({
|
|
3071
|
+
name,
|
|
3072
|
+
status: "unchanged",
|
|
3073
|
+
previousSha256: oldFile.sha256,
|
|
3074
|
+
plannedSha256: newFile.sha256,
|
|
3075
|
+
previous: trustworthyOld,
|
|
3076
|
+
planned: newFile
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
return {
|
|
3081
|
+
files,
|
|
3082
|
+
sawAdded,
|
|
3083
|
+
sawRemoved,
|
|
3084
|
+
sawBytesChanged,
|
|
3085
|
+
sawOriginChanged
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Pure comparison of qualified attribution metadata. No writes, no prompts:
|
|
3090
|
+
* the command layer owns consent, publication and exit behavior.
|
|
3091
|
+
*/
|
|
3092
|
+
function compareAttribution(input) {
|
|
3093
|
+
const previousPackages = input.previousCatalog?.packages ?? {};
|
|
3094
|
+
const packageIds = [.../* @__PURE__ */ new Set([...Object.keys(previousPackages), ...Object.keys(input.planned)])].sort();
|
|
3095
|
+
const packages = [];
|
|
3096
|
+
const legacyUnqualified = [];
|
|
3097
|
+
const disclosures = [];
|
|
3098
|
+
let requiresConsent = false;
|
|
3099
|
+
for (const packageId of packageIds) {
|
|
3100
|
+
const previous = previousPackages[packageId] ?? null;
|
|
3101
|
+
const planned = input.planned[packageId] ?? null;
|
|
3102
|
+
if (!input.hasExistingInstallation) {
|
|
3103
|
+
if (!planned) continue;
|
|
3104
|
+
packages.push({
|
|
3105
|
+
packageId,
|
|
3106
|
+
status: "added",
|
|
3107
|
+
reason: "first-install",
|
|
3108
|
+
previous: null,
|
|
3109
|
+
planned,
|
|
3110
|
+
files: planned.files.map((file) => ({
|
|
3111
|
+
name: file.name,
|
|
3112
|
+
status: "added",
|
|
3113
|
+
previousSha256: null,
|
|
3114
|
+
plannedSha256: file.sha256,
|
|
3115
|
+
previous: null,
|
|
3116
|
+
planned: file
|
|
3117
|
+
})),
|
|
3118
|
+
requiresConsent: false
|
|
3119
|
+
});
|
|
3120
|
+
disclosures.push(planned);
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
if (!previous) {
|
|
3124
|
+
if (!planned) continue;
|
|
3125
|
+
const installed = input.installed?.[packageId];
|
|
3126
|
+
if (Object.values(installed ?? {}).some((bindings) => bindings.some((binding) => binding.sha256 !== null))) {
|
|
3127
|
+
legacyUnqualified.push(packageId);
|
|
3128
|
+
requiresConsent = true;
|
|
3129
|
+
packages.push({
|
|
3130
|
+
packageId,
|
|
3131
|
+
status: "legacy-unqualified",
|
|
3132
|
+
reason: "unqualified-origin",
|
|
3133
|
+
previous: null,
|
|
3134
|
+
planned,
|
|
3135
|
+
files: planned.files.map((file) => ({
|
|
3136
|
+
name: file.name,
|
|
3137
|
+
status: "added",
|
|
3138
|
+
previousSha256: null,
|
|
3139
|
+
plannedSha256: file.sha256,
|
|
3140
|
+
previous: null,
|
|
3141
|
+
planned: file
|
|
3142
|
+
})),
|
|
3143
|
+
requiresConsent: true
|
|
3144
|
+
});
|
|
3145
|
+
continue;
|
|
3146
|
+
}
|
|
3147
|
+
requiresConsent = true;
|
|
3148
|
+
packages.push({
|
|
3149
|
+
packageId,
|
|
3150
|
+
status: "added",
|
|
3151
|
+
reason: "added",
|
|
3152
|
+
previous: null,
|
|
3153
|
+
planned,
|
|
3154
|
+
files: planned.files.map((file) => ({
|
|
3155
|
+
name: file.name,
|
|
3156
|
+
status: "added",
|
|
3157
|
+
previousSha256: null,
|
|
3158
|
+
plannedSha256: file.sha256,
|
|
3159
|
+
previous: null,
|
|
3160
|
+
planned: file
|
|
3161
|
+
})),
|
|
3162
|
+
requiresConsent: true
|
|
3163
|
+
});
|
|
3164
|
+
continue;
|
|
3165
|
+
}
|
|
3166
|
+
const installed = input.installed?.[packageId];
|
|
3167
|
+
const unqualifiedNames = new Set(previous.files.filter((file) => !qualifiesAgainstInstalled(file, installed?.[file.name])).map((file) => file.name));
|
|
3168
|
+
if (!planned) {
|
|
3169
|
+
requiresConsent = true;
|
|
3170
|
+
packages.push({
|
|
3171
|
+
packageId,
|
|
3172
|
+
status: "removed",
|
|
3173
|
+
reason: "removed",
|
|
3174
|
+
previous: unqualifiedNames.size > 0 ? null : previous,
|
|
3175
|
+
planned: null,
|
|
3176
|
+
files: previous.files.map((file) => ({
|
|
3177
|
+
name: file.name,
|
|
3178
|
+
status: "removed",
|
|
3179
|
+
previousSha256: file.sha256,
|
|
3180
|
+
plannedSha256: null,
|
|
3181
|
+
previous: unqualifiedNames.has(file.name) ? null : file,
|
|
3182
|
+
planned: null
|
|
3183
|
+
})),
|
|
3184
|
+
requiresConsent: true
|
|
3185
|
+
});
|
|
3186
|
+
continue;
|
|
3187
|
+
}
|
|
3188
|
+
const compared = compareFiles(previous, planned, unqualifiedNames, previous.registry !== planned.registry || previous.declaringCategory !== planned.declaringCategory || previous.requestedRef !== planned.requestedRef || previous.resolvedCommit !== planned.resolvedCommit);
|
|
3189
|
+
if (unqualifiedNames.size > 0) {
|
|
3190
|
+
legacyUnqualified.push(packageId);
|
|
3191
|
+
requiresConsent = true;
|
|
3192
|
+
packages.push({
|
|
3193
|
+
packageId,
|
|
3194
|
+
status: "legacy-unqualified",
|
|
3195
|
+
reason: installed && Object.keys(installed).length > 0 ? "installed-bytes-mismatch" : "unqualified-origin",
|
|
3196
|
+
previous: null,
|
|
3197
|
+
planned,
|
|
3198
|
+
files: compared.files,
|
|
3199
|
+
requiresConsent: true
|
|
3200
|
+
});
|
|
3201
|
+
continue;
|
|
3202
|
+
}
|
|
3203
|
+
let reason = "unchanged";
|
|
3204
|
+
let status = "unchanged";
|
|
3205
|
+
if (compared.sawRemoved) {
|
|
3206
|
+
reason = "removed";
|
|
3207
|
+
status = "changed";
|
|
3208
|
+
} else if (compared.sawAdded) {
|
|
3209
|
+
reason = "added";
|
|
3210
|
+
status = "changed";
|
|
3211
|
+
} else if (compared.sawBytesChanged) {
|
|
3212
|
+
reason = "changed";
|
|
3213
|
+
status = "changed";
|
|
3214
|
+
} else if (compared.sawOriginChanged) {
|
|
3215
|
+
reason = "origin-changed";
|
|
3216
|
+
status = "origin-changed";
|
|
3217
|
+
}
|
|
3218
|
+
if (status !== "unchanged") requiresConsent = true;
|
|
3219
|
+
packages.push({
|
|
3220
|
+
packageId,
|
|
3221
|
+
status,
|
|
3222
|
+
reason,
|
|
3223
|
+
previous,
|
|
3224
|
+
planned,
|
|
3225
|
+
files: compared.files,
|
|
3226
|
+
requiresConsent: status !== "unchanged"
|
|
3227
|
+
});
|
|
3228
|
+
}
|
|
3229
|
+
return {
|
|
3230
|
+
firstInstall: !input.hasExistingInstallation,
|
|
3231
|
+
requiresConsent,
|
|
3232
|
+
packages,
|
|
3233
|
+
legacyUnqualified,
|
|
3234
|
+
disclosures
|
|
3235
|
+
};
|
|
3236
|
+
}
|
|
3237
|
+
/**
|
|
3238
|
+
* Digest of an installed attribution file, or null when it cannot be read
|
|
3239
|
+
* inside the project root. The shared install path boundary rejects traversal
|
|
3240
|
+
* and any symlink component (terminal, parent, or dangling) before the bytes
|
|
3241
|
+
* are read, so external bytes can never qualify a record.
|
|
3242
|
+
*/
|
|
3243
|
+
async function readInstalledAttributionDigest(rootDir, relativePath) {
|
|
3244
|
+
try {
|
|
3245
|
+
const absPath = node_path.default.join(rootDir, relativePath);
|
|
3246
|
+
await assertNoSymlinksUnderRoot(rootDir, absPath);
|
|
3247
|
+
const bytes = await fs_extra.default.readFile(absPath);
|
|
3248
|
+
return (0, node_crypto.createHash)("sha256").update(bytes).digest("hex");
|
|
3249
|
+
} catch {
|
|
3250
|
+
return null;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
3254
|
+
* Reads the bytes actually installed at every recorded destination, preserving
|
|
3255
|
+
* agent/path identity. A missing or unsafe destination is reported with
|
|
3256
|
+
* `sha256: null`, which the comparison treats as unqualified rather than as
|
|
3257
|
+
* agreement.
|
|
3258
|
+
*/
|
|
3259
|
+
async function collectInstalledAttributionDigests(rootDir, packages) {
|
|
3260
|
+
const state = {};
|
|
3261
|
+
for (const [packageId, record] of Object.entries(packages)) {
|
|
3262
|
+
const perFile = {};
|
|
3263
|
+
for (const file of record.files) {
|
|
3264
|
+
perFile[file.name] = [];
|
|
3265
|
+
for (const binding of file.destinations) perFile[file.name].push({
|
|
3266
|
+
agent: binding.agent,
|
|
3267
|
+
path: binding.path,
|
|
3268
|
+
sha256: await readInstalledAttributionDigest(rootDir, binding.path)
|
|
3269
|
+
});
|
|
3270
|
+
}
|
|
3271
|
+
state[packageId] = perFile;
|
|
3272
|
+
}
|
|
3273
|
+
return state;
|
|
3274
|
+
}
|
|
3275
|
+
function buildAttributionCatalog(packages) {
|
|
3276
|
+
const sorted = {};
|
|
3277
|
+
for (const packageId of Object.keys(packages).sort()) sorted[packageId] = packages[packageId];
|
|
3278
|
+
return {
|
|
3279
|
+
version: 1,
|
|
3280
|
+
packages: sorted
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
//#endregion
|
|
2609
3284
|
//#region src/services/LockfileService.ts
|
|
2610
3285
|
var LOCKFILE_NAME = ".skills-lock.json";
|
|
3286
|
+
/** Top-level names this writer owns; anything else is preserved verbatim. */
|
|
3287
|
+
var KNOWN_LOCK_KEYS = {
|
|
3288
|
+
version: true,
|
|
3289
|
+
registry: true,
|
|
3290
|
+
generatedAt: true,
|
|
3291
|
+
sources: true,
|
|
3292
|
+
entries: true,
|
|
3293
|
+
generation: true,
|
|
3294
|
+
disclosed: true,
|
|
3295
|
+
workflowCatalog: true,
|
|
3296
|
+
attributionCatalog: true
|
|
3297
|
+
};
|
|
2611
3298
|
/**
|
|
2612
3299
|
* Reserved ownership agent for the shared canonical workflow body installed at
|
|
2613
3300
|
* `.agents/workflows/*.md`. It is not a provider and must never be added to the
|
|
@@ -2647,7 +3334,7 @@ function parseGenerationLedger(value) {
|
|
|
2647
3334
|
entries
|
|
2648
3335
|
};
|
|
2649
3336
|
}
|
|
2650
|
-
function sha256(content) {
|
|
3337
|
+
function sha256$1(content) {
|
|
2651
3338
|
return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
|
|
2652
3339
|
}
|
|
2653
3340
|
/**
|
|
@@ -2687,7 +3374,8 @@ var lockV2Schema = zod.z.object({
|
|
|
2687
3374
|
sha256: zod.z.string()
|
|
2688
3375
|
})),
|
|
2689
3376
|
disclosed: zod.z.record(zod.z.string(), zod.z.array(zod.z.string())).optional(),
|
|
2690
|
-
workflowCatalog: zod.z.unknown().optional()
|
|
3377
|
+
workflowCatalog: zod.z.unknown().optional(),
|
|
3378
|
+
attributionCatalog: zod.z.unknown().optional()
|
|
2691
3379
|
}).passthrough();
|
|
2692
3380
|
var lockVersionProbe = zod.z.object({ version: zod.z.unknown() });
|
|
2693
3381
|
/**
|
|
@@ -2701,12 +3389,13 @@ function parseLockV2(raw) {
|
|
|
2701
3389
|
const where = issue?.path.length ? issue.path.join(".") : "root";
|
|
2702
3390
|
throw new Error(`Invalid .skills-lock.json: ${where} ${issue?.message ?? "is invalid"}`);
|
|
2703
3391
|
}
|
|
2704
|
-
const { workflowCatalog: rawCatalog, ...rest } = parsed.data;
|
|
3392
|
+
const { workflowCatalog: rawCatalog, attributionCatalog: rawAttribution, ...rest } = parsed.data;
|
|
2705
3393
|
const generation = parseGenerationLedger(raw.generation);
|
|
2706
3394
|
return {
|
|
2707
3395
|
...rest,
|
|
2708
3396
|
...generation ? { generation } : {},
|
|
2709
|
-
...rawCatalog === void 0 ? {} : { workflowCatalog: validateWorkflowCatalog(rawCatalog) }
|
|
3397
|
+
...rawCatalog === void 0 ? {} : { workflowCatalog: validateWorkflowCatalog(rawCatalog) },
|
|
3398
|
+
...rawAttribution === void 0 ? {} : { attributionCatalog: validateAttributionCatalog(rawAttribution) }
|
|
2710
3399
|
};
|
|
2711
3400
|
}
|
|
2712
3401
|
var DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
@@ -2835,21 +3524,27 @@ var LockfileService = class {
|
|
|
2835
3524
|
async write(rootDir, lock) {
|
|
2836
3525
|
const file = this.lockfilePath(rootDir);
|
|
2837
3526
|
if (lock.workflowCatalog !== void 0) validateWorkflowCatalog(lock.workflowCatalog);
|
|
3527
|
+
if (lock.attributionCatalog !== void 0) validateAttributionCatalog(lock.attributionCatalog);
|
|
3528
|
+
let existingRaw;
|
|
2838
3529
|
let existingDisclosed;
|
|
2839
3530
|
let existingGeneration;
|
|
3531
|
+
let existingAttribution;
|
|
2840
3532
|
if (await fs_extra.default.pathExists(file)) {
|
|
2841
|
-
let raw;
|
|
2842
3533
|
try {
|
|
2843
|
-
|
|
3534
|
+
existingRaw = await readJsonStrict(file);
|
|
2844
3535
|
} catch {}
|
|
3536
|
+
const raw = existingRaw;
|
|
2845
3537
|
if (raw) {
|
|
2846
3538
|
existingDisclosed = raw.disclosed;
|
|
2847
|
-
if (raw.version === 2)
|
|
2848
|
-
|
|
3539
|
+
if (raw.version === 2) {
|
|
3540
|
+
existingGeneration = parseGenerationLedger(raw.generation);
|
|
3541
|
+
if (raw.attributionCatalog !== void 0) existingAttribution = validateAttributionCatalog(raw.attributionCatalog);
|
|
3542
|
+
} else if (raw.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
|
|
2849
3543
|
}
|
|
2850
3544
|
}
|
|
2851
3545
|
const generation = lock.generation === void 0 ? existingGeneration : parseGenerationLedger(lock.generation);
|
|
2852
3546
|
const disclosed = lock.disclosed ?? existingDisclosed;
|
|
3547
|
+
const attribution = lock.attributionCatalog === void 0 ? existingAttribution : lock.attributionCatalog;
|
|
2853
3548
|
const sortedEntries = {};
|
|
2854
3549
|
for (const k of Object.keys(lock.entries).sort()) sortedEntries[k] = lock.entries[k];
|
|
2855
3550
|
const sortedGenerationEntries = {};
|
|
@@ -2861,10 +3556,16 @@ var LockfileService = class {
|
|
|
2861
3556
|
version: 1,
|
|
2862
3557
|
entries: sortedGenerationEntries
|
|
2863
3558
|
} } : {},
|
|
2864
|
-
...disclosed ? { disclosed } : {}
|
|
3559
|
+
...disclosed ? { disclosed } : {},
|
|
3560
|
+
...attribution ? { attributionCatalog: attribution } : {}
|
|
2865
3561
|
};
|
|
2866
3562
|
if (!generation) delete out.generation;
|
|
2867
3563
|
if (!disclosed) delete out.disclosed;
|
|
3564
|
+
if (!attribution) delete out.attributionCatalog;
|
|
3565
|
+
for (const [key, value] of Object.entries(existingRaw ?? {})) {
|
|
3566
|
+
if (KNOWN_LOCK_KEYS[key] || key in out) continue;
|
|
3567
|
+
out[key] = value;
|
|
3568
|
+
}
|
|
2868
3569
|
await this.publishAtomic(file, out);
|
|
2869
3570
|
}
|
|
2870
3571
|
/**
|
|
@@ -2894,6 +3595,7 @@ var LockfileService = class {
|
|
|
2894
3595
|
if (raw?.version === 2) parseGenerationLedger(raw.generation);
|
|
2895
3596
|
else if (raw?.generation !== void 0) throw new Error("Unsupported repository-generation lock namespace");
|
|
2896
3597
|
if (raw.workflowCatalog !== void 0) validateWorkflowCatalog(raw.workflowCatalog);
|
|
3598
|
+
if (raw.attributionCatalog !== void 0) validateAttributionCatalog(raw.attributionCatalog);
|
|
2897
3599
|
await this.publishAtomic(file, {
|
|
2898
3600
|
...raw,
|
|
2899
3601
|
disclosed
|
|
@@ -2909,7 +3611,7 @@ var LockfileService = class {
|
|
|
2909
3611
|
missing.push(rel);
|
|
2910
3612
|
continue;
|
|
2911
3613
|
}
|
|
2912
|
-
if (sha256(await fs_extra.default.readFile(absPath)) !== entry.sha256) mismatches.push(rel);
|
|
3614
|
+
if (sha256$1(await fs_extra.default.readFile(absPath)) !== entry.sha256) mismatches.push(rel);
|
|
2913
3615
|
}
|
|
2914
3616
|
return {
|
|
2915
3617
|
ok: mismatches.length === 0 && missing.length === 0,
|
|
@@ -4009,6 +4711,302 @@ var InitService = class {
|
|
|
4009
4711
|
await fs_extra.default.outputFile(configPath, commentHeader + js_yaml.dump(config));
|
|
4010
4712
|
}
|
|
4011
4713
|
};
|
|
4714
|
+
var AMBIGUOUS_ERROR_BODY_TIMEOUT_MS = 1e3;
|
|
4715
|
+
var RATE_LIMIT_EVIDENCE = /rate[-_ ]?limit|abuse[-_ ]?detection|too many requests/i;
|
|
4716
|
+
/**
|
|
4717
|
+
* Drops credentials, query strings, and fragments. GitHub tokens can appear in
|
|
4718
|
+
* query strings on some endpoints, so only origin and path are retained.
|
|
4719
|
+
*/
|
|
4720
|
+
function sanitizeEndpoint(url) {
|
|
4721
|
+
try {
|
|
4722
|
+
const parsed = new URL(url);
|
|
4723
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
4724
|
+
} catch {
|
|
4725
|
+
return url.split(/[?#]/)[0] ?? url;
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
function readHeader(res, name) {
|
|
4729
|
+
const headers = res.headers;
|
|
4730
|
+
if (!headers || typeof headers.get !== "function") return void 0;
|
|
4731
|
+
const value = headers.get(name);
|
|
4732
|
+
return typeof value === "string" ? value : void 0;
|
|
4733
|
+
}
|
|
4734
|
+
/** `Retry-After` in seconds. Malformed, negative, or non-integer values are ignored. */
|
|
4735
|
+
function parseRetryAfterSeconds(value) {
|
|
4736
|
+
if (value === void 0) return void 0;
|
|
4737
|
+
const trimmed = value.trim();
|
|
4738
|
+
if (!/^\d+$/.test(trimmed)) return void 0;
|
|
4739
|
+
const seconds = Number(trimmed);
|
|
4740
|
+
return Number.isSafeInteger(seconds) ? seconds : void 0;
|
|
4741
|
+
}
|
|
4742
|
+
/** `x-ratelimit-reset` is epoch seconds. Malformed or implausible values are ignored. */
|
|
4743
|
+
function parseResetAt(value) {
|
|
4744
|
+
if (value === void 0) return void 0;
|
|
4745
|
+
const trimmed = value.trim();
|
|
4746
|
+
if (!/^\d+$/.test(trimmed)) return void 0;
|
|
4747
|
+
const seconds = Number(trimmed);
|
|
4748
|
+
if (!Number.isSafeInteger(seconds) || seconds <= 0) return void 0;
|
|
4749
|
+
const date = /* @__PURE__ */ new Date(seconds * 1e3);
|
|
4750
|
+
const time = date.getTime();
|
|
4751
|
+
if (Number.isNaN(time)) return void 0;
|
|
4752
|
+
const year = date.getUTCFullYear();
|
|
4753
|
+
if (year < 1970 || year > 2100) return void 0;
|
|
4754
|
+
return date.toISOString();
|
|
4755
|
+
}
|
|
4756
|
+
function parseRemaining(value) {
|
|
4757
|
+
if (value === void 0) return void 0;
|
|
4758
|
+
const trimmed = value.trim();
|
|
4759
|
+
if (!/^\d+$/.test(trimmed)) return void 0;
|
|
4760
|
+
const remaining = Number(trimmed);
|
|
4761
|
+
return Number.isSafeInteger(remaining) ? remaining : void 0;
|
|
4762
|
+
}
|
|
4763
|
+
function readNodeBody(stream) {
|
|
4764
|
+
return new Promise((resolve) => {
|
|
4765
|
+
const chunks = [];
|
|
4766
|
+
let total = 0;
|
|
4767
|
+
let settled = false;
|
|
4768
|
+
const finish = (text) => {
|
|
4769
|
+
if (settled) return;
|
|
4770
|
+
settled = true;
|
|
4771
|
+
clearTimeout(timer);
|
|
4772
|
+
stream.removeListener("data", onData);
|
|
4773
|
+
stream.removeListener("end", onEnd);
|
|
4774
|
+
stream.removeListener("error", onError);
|
|
4775
|
+
if (text === null) stream.destroy();
|
|
4776
|
+
else stream.pause();
|
|
4777
|
+
resolve(text);
|
|
4778
|
+
};
|
|
4779
|
+
const onData = (chunk) => {
|
|
4780
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
4781
|
+
total += bytes.byteLength;
|
|
4782
|
+
if (total > 8192) {
|
|
4783
|
+
finish(null);
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
chunks.push(bytes);
|
|
4787
|
+
};
|
|
4788
|
+
const onEnd = () => finish(Buffer.concat(chunks).toString("utf8"));
|
|
4789
|
+
const onError = () => finish(null);
|
|
4790
|
+
const timer = setTimeout(() => finish(null), AMBIGUOUS_ERROR_BODY_TIMEOUT_MS);
|
|
4791
|
+
stream.on("data", onData);
|
|
4792
|
+
stream.on("end", onEnd);
|
|
4793
|
+
stream.on("error", onError);
|
|
4794
|
+
});
|
|
4795
|
+
}
|
|
4796
|
+
function readWebChunk(reader, timeoutMs) {
|
|
4797
|
+
return new Promise((resolve) => {
|
|
4798
|
+
let settled = false;
|
|
4799
|
+
const timer = setTimeout(() => {
|
|
4800
|
+
if (settled) return;
|
|
4801
|
+
settled = true;
|
|
4802
|
+
resolve("timeout");
|
|
4803
|
+
}, timeoutMs);
|
|
4804
|
+
reader.read().then((result) => {
|
|
4805
|
+
if (settled) return;
|
|
4806
|
+
settled = true;
|
|
4807
|
+
clearTimeout(timer);
|
|
4808
|
+
resolve(result);
|
|
4809
|
+
}, () => {
|
|
4810
|
+
if (settled) return;
|
|
4811
|
+
settled = true;
|
|
4812
|
+
clearTimeout(timer);
|
|
4813
|
+
resolve("timeout");
|
|
4814
|
+
});
|
|
4815
|
+
});
|
|
4816
|
+
}
|
|
4817
|
+
/**
|
|
4818
|
+
* Abandons a WHATWG reader without waiting on `cancel()`. A cancellation
|
|
4819
|
+
* promise may never settle; awaiting it would extend the classification
|
|
4820
|
+
* deadline. The lock is released when possible, otherwise the runtime owns the
|
|
4821
|
+
* reader after the pending read settles.
|
|
4822
|
+
*/
|
|
4823
|
+
function abandonWebReader(reader) {
|
|
4824
|
+
try {
|
|
4825
|
+
reader.cancel().catch(() => void 0);
|
|
4826
|
+
} catch {}
|
|
4827
|
+
try {
|
|
4828
|
+
reader.releaseLock();
|
|
4829
|
+
} catch {}
|
|
4830
|
+
}
|
|
4831
|
+
async function readWebBody(stream) {
|
|
4832
|
+
const reader = stream.getReader();
|
|
4833
|
+
const decoder = new TextDecoder();
|
|
4834
|
+
let text = "";
|
|
4835
|
+
let total = 0;
|
|
4836
|
+
const deadline = Date.now() + AMBIGUOUS_ERROR_BODY_TIMEOUT_MS;
|
|
4837
|
+
try {
|
|
4838
|
+
for (;;) {
|
|
4839
|
+
const remaining = deadline - Date.now();
|
|
4840
|
+
if (remaining <= 0) {
|
|
4841
|
+
abandonWebReader(reader);
|
|
4842
|
+
return null;
|
|
4843
|
+
}
|
|
4844
|
+
const chunk = await readWebChunk(reader, remaining);
|
|
4845
|
+
if (chunk === "timeout") {
|
|
4846
|
+
abandonWebReader(reader);
|
|
4847
|
+
return null;
|
|
4848
|
+
}
|
|
4849
|
+
if (chunk.done) return text + decoder.decode();
|
|
4850
|
+
total += chunk.value.byteLength;
|
|
4851
|
+
if (total > 8192) {
|
|
4852
|
+
abandonWebReader(reader);
|
|
4853
|
+
return null;
|
|
4854
|
+
}
|
|
4855
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
4856
|
+
}
|
|
4857
|
+
} catch {
|
|
4858
|
+
return null;
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
/**
|
|
4862
|
+
* Releases a response body that will never be read. Node streams are destroyed
|
|
4863
|
+
* synchronously; WHATWG cancellation is started with rejection handling and
|
|
4864
|
+
* never awaited, so a stalled cancel cannot hold up classification or latch.
|
|
4865
|
+
*/
|
|
4866
|
+
function abandonResponseBody(res) {
|
|
4867
|
+
const body = res.body;
|
|
4868
|
+
if (body instanceof node_stream.Readable) {
|
|
4869
|
+
body.destroy();
|
|
4870
|
+
return;
|
|
4871
|
+
}
|
|
4872
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) try {
|
|
4873
|
+
body.cancel().catch(() => void 0);
|
|
4874
|
+
} catch {}
|
|
4875
|
+
}
|
|
4876
|
+
/**
|
|
4877
|
+
* Reads only the ambiguous 403 body, bounded in bytes and time, for both the
|
|
4878
|
+
* Node stream that `cross-fetch` returns and a WHATWG stream. Any timeout,
|
|
4879
|
+
* oversize read, or stream error releases the body and returns null.
|
|
4880
|
+
*/
|
|
4881
|
+
function readAmbiguousErrorBody(res) {
|
|
4882
|
+
const body = res.body;
|
|
4883
|
+
if (body instanceof node_stream.Readable) return readNodeBody(body);
|
|
4884
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return readWebBody(body);
|
|
4885
|
+
return Promise.resolve(null);
|
|
4886
|
+
}
|
|
4887
|
+
var GithubRequestError = class GithubRequestError extends Error {
|
|
4888
|
+
kind;
|
|
4889
|
+
endpoint;
|
|
4890
|
+
authenticated;
|
|
4891
|
+
status;
|
|
4892
|
+
statusText;
|
|
4893
|
+
retryAfterSeconds;
|
|
4894
|
+
resetAt;
|
|
4895
|
+
rateLimitKind;
|
|
4896
|
+
constructor(init) {
|
|
4897
|
+
super(init.message, { cause: init.cause });
|
|
4898
|
+
this.name = "GithubRequestError";
|
|
4899
|
+
this.kind = init.kind;
|
|
4900
|
+
this.endpoint = sanitizeEndpoint(init.endpoint);
|
|
4901
|
+
this.authenticated = init.authenticated;
|
|
4902
|
+
this.status = init.status;
|
|
4903
|
+
this.statusText = init.statusText;
|
|
4904
|
+
this.retryAfterSeconds = init.retryAfterSeconds;
|
|
4905
|
+
this.resetAt = init.resetAt;
|
|
4906
|
+
this.rateLimitKind = init.rateLimitKind;
|
|
4907
|
+
}
|
|
4908
|
+
/** Serialization allowlist: no token, no headers, no cause chain. */
|
|
4909
|
+
toJSON() {
|
|
4910
|
+
return {
|
|
4911
|
+
name: "GithubRequestError",
|
|
4912
|
+
kind: this.kind,
|
|
4913
|
+
status: this.status ?? null,
|
|
4914
|
+
endpoint: this.endpoint,
|
|
4915
|
+
authenticated: this.authenticated,
|
|
4916
|
+
retryAfterSeconds: this.retryAfterSeconds ?? null,
|
|
4917
|
+
resetAt: this.resetAt ?? null,
|
|
4918
|
+
rateLimitKind: this.rateLimitKind ?? null,
|
|
4919
|
+
message: this.message
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
static isRateLimit(error) {
|
|
4923
|
+
return error instanceof GithubRequestError && error.kind === "rate-limited";
|
|
4924
|
+
}
|
|
4925
|
+
static isAccessDenial(error) {
|
|
4926
|
+
return error instanceof GithubRequestError && error.kind === "access-denied";
|
|
4927
|
+
}
|
|
4928
|
+
};
|
|
4929
|
+
function buildFailure(res) {
|
|
4930
|
+
const status = typeof res.status === "number" && Number.isInteger(res.status) ? res.status : 0;
|
|
4931
|
+
const statusText = typeof res.statusText === "string" ? res.statusText : void 0;
|
|
4932
|
+
return {
|
|
4933
|
+
status,
|
|
4934
|
+
statusText,
|
|
4935
|
+
statusLabel: `${status || "unknown"}${statusText ? ` ${statusText}` : ""}`,
|
|
4936
|
+
retryAfterSeconds: parseRetryAfterSeconds(readHeader(res, "retry-after")),
|
|
4937
|
+
resetAt: parseResetAt(readHeader(res, "x-ratelimit-reset"))
|
|
4938
|
+
};
|
|
4939
|
+
}
|
|
4940
|
+
function classifyGithubStatus(res, endpoint, authenticated) {
|
|
4941
|
+
const { status, statusText, statusLabel, retryAfterSeconds, resetAt } = buildFailure(res);
|
|
4942
|
+
const primary = parseRemaining(readHeader(res, "x-ratelimit-remaining")) === 0;
|
|
4943
|
+
if (status === 404) return new GithubRequestError({
|
|
4944
|
+
kind: "not-found",
|
|
4945
|
+
message: `GitHub resource not found: ${statusLabel}`,
|
|
4946
|
+
endpoint,
|
|
4947
|
+
authenticated,
|
|
4948
|
+
status,
|
|
4949
|
+
statusText
|
|
4950
|
+
});
|
|
4951
|
+
if (status === 401) return new GithubRequestError({
|
|
4952
|
+
kind: "access-denied",
|
|
4953
|
+
message: `GitHub access denied: ${statusLabel}`,
|
|
4954
|
+
endpoint,
|
|
4955
|
+
authenticated,
|
|
4956
|
+
status,
|
|
4957
|
+
statusText,
|
|
4958
|
+
resetAt
|
|
4959
|
+
});
|
|
4960
|
+
if (status === 403 || status === 429) {
|
|
4961
|
+
if (primary || status === 429 || retryAfterSeconds !== void 0) {
|
|
4962
|
+
const rateLimitKind = primary ? "primary" : retryAfterSeconds !== void 0 || status === 429 ? "secondary" : void 0;
|
|
4963
|
+
return new GithubRequestError({
|
|
4964
|
+
kind: "rate-limited",
|
|
4965
|
+
message: `GitHub rate limit exceeded (${rateLimitKind ?? "unclassified"}): ${statusLabel}`,
|
|
4966
|
+
endpoint,
|
|
4967
|
+
authenticated,
|
|
4968
|
+
status,
|
|
4969
|
+
statusText,
|
|
4970
|
+
retryAfterSeconds,
|
|
4971
|
+
resetAt,
|
|
4972
|
+
rateLimitKind
|
|
4973
|
+
});
|
|
4974
|
+
}
|
|
4975
|
+
return "ambiguous";
|
|
4976
|
+
}
|
|
4977
|
+
return new GithubRequestError({
|
|
4978
|
+
kind: "transport",
|
|
4979
|
+
message: `GitHub API Error: ${statusLabel}`,
|
|
4980
|
+
endpoint,
|
|
4981
|
+
authenticated,
|
|
4982
|
+
status,
|
|
4983
|
+
statusText
|
|
4984
|
+
});
|
|
4985
|
+
}
|
|
4986
|
+
/** Resolves an ambiguous 403 by reading a bounded body for throttle evidence. */
|
|
4987
|
+
async function classifyAmbiguous403(res, endpoint, authenticated) {
|
|
4988
|
+
const { status, statusText, statusLabel, resetAt } = buildFailure(res);
|
|
4989
|
+
const body = await readAmbiguousErrorBody(res);
|
|
4990
|
+
if (body !== null && RATE_LIMIT_EVIDENCE.test(body)) return new GithubRequestError({
|
|
4991
|
+
kind: "rate-limited",
|
|
4992
|
+
message: `GitHub rate limit exceeded (secondary): ${statusLabel}`,
|
|
4993
|
+
endpoint,
|
|
4994
|
+
authenticated,
|
|
4995
|
+
status,
|
|
4996
|
+
statusText,
|
|
4997
|
+
resetAt,
|
|
4998
|
+
rateLimitKind: "secondary"
|
|
4999
|
+
});
|
|
5000
|
+
return new GithubRequestError({
|
|
5001
|
+
kind: "access-denied",
|
|
5002
|
+
message: `GitHub access denied: ${statusLabel}`,
|
|
5003
|
+
endpoint,
|
|
5004
|
+
authenticated,
|
|
5005
|
+
status,
|
|
5006
|
+
statusText,
|
|
5007
|
+
resetAt
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
4012
5010
|
//#endregion
|
|
4013
5011
|
//#region src/services/GithubService.ts
|
|
4014
5012
|
/** A downloaded file failed its integrity or size check. */
|
|
@@ -4024,13 +5022,32 @@ function gitBlobSha1(content) {
|
|
|
4024
5022
|
return (0, node_crypto.createHash)("sha1").update(Buffer.concat([header, content])).digest("hex");
|
|
4025
5023
|
}
|
|
4026
5024
|
/**
|
|
5025
|
+
* Detaches a cached value from the caller that receives it. Buffers are
|
|
5026
|
+
* copied; plain objects are structured-cloned. The session cache therefore
|
|
5027
|
+
* never shares mutable state with consumers.
|
|
5028
|
+
*/
|
|
5029
|
+
function detachValue(value) {
|
|
5030
|
+
if (Buffer.isBuffer(value)) return Buffer.from(value);
|
|
5031
|
+
if (value !== null && typeof value === "object") return structuredClone(value);
|
|
5032
|
+
return value;
|
|
5033
|
+
}
|
|
5034
|
+
/**
|
|
4027
5035
|
* Service for interacting with the GitHub API and fetching raw file content.
|
|
4028
5036
|
* Handles repository tree discovery, file downloads, and URL parsing.
|
|
5037
|
+
*
|
|
5038
|
+
* One instance is operation-scoped: identical requests are coalesced and
|
|
5039
|
+
* reused for the lifetime of the operation, the first confirmed throttling
|
|
5040
|
+
* response latches the whole operation, and `resetOperation()` clears both
|
|
5041
|
+
* when a client is reused for another command.
|
|
4029
5042
|
*/
|
|
4030
|
-
var GithubService = class {
|
|
5043
|
+
var GithubService = class GithubService {
|
|
4031
5044
|
token;
|
|
4032
5045
|
baseUrl = "https://api.github.com";
|
|
4033
5046
|
rawBaseUrl = "https://raw.githubusercontent.com";
|
|
5047
|
+
sessionCache = /* @__PURE__ */ new Map();
|
|
5048
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
5049
|
+
rateLimitError = null;
|
|
5050
|
+
operationEpoch = 0;
|
|
4034
5051
|
constructor(token) {
|
|
4035
5052
|
this.token = token;
|
|
4036
5053
|
}
|
|
@@ -4039,49 +5056,155 @@ var GithubService = class {
|
|
|
4039
5056
|
if (this.token) h.Authorization = `token ${this.token}`;
|
|
4040
5057
|
return h;
|
|
4041
5058
|
}
|
|
5059
|
+
/** True once this operation has observed confirmed throttling. */
|
|
5060
|
+
get rateLimited() {
|
|
5061
|
+
return this.rateLimitError !== null;
|
|
5062
|
+
}
|
|
5063
|
+
/** The first throttling failure that latched this operation, if any. */
|
|
5064
|
+
get rateLimitFailure() {
|
|
5065
|
+
return this.rateLimitError;
|
|
5066
|
+
}
|
|
5067
|
+
/**
|
|
5068
|
+
* Clears session caches and the throttle latch. Call only between commands
|
|
5069
|
+
* that reuse the same client. Requests already in flight settle normally,
|
|
5070
|
+
* but their results are not retained.
|
|
5071
|
+
*/
|
|
5072
|
+
resetOperation() {
|
|
5073
|
+
this.sessionCache.clear();
|
|
5074
|
+
this.inFlight.clear();
|
|
5075
|
+
this.rateLimitError = null;
|
|
5076
|
+
this.operationEpoch += 1;
|
|
5077
|
+
}
|
|
5078
|
+
/**
|
|
5079
|
+
* Single-flight per request identity: concurrent callers share one pending
|
|
5080
|
+
* request, completed values are reused within the operation, and failures
|
|
5081
|
+
* are never cached (so a recovered request can be retried).
|
|
5082
|
+
*/
|
|
5083
|
+
async sessionRequest(key, run) {
|
|
5084
|
+
const cached = this.sessionCache.get(key);
|
|
5085
|
+
if (cached !== void 0) return detachValue(cached);
|
|
5086
|
+
const pending = this.inFlight.get(key);
|
|
5087
|
+
if (pending) return detachValue(await pending);
|
|
5088
|
+
const epoch = this.operationEpoch;
|
|
5089
|
+
const promise = this.runSessionRequest(key, epoch, run);
|
|
5090
|
+
this.inFlight.set(key, promise);
|
|
5091
|
+
return promise;
|
|
5092
|
+
}
|
|
5093
|
+
async runSessionRequest(key, epoch, run) {
|
|
5094
|
+
try {
|
|
5095
|
+
const value = await run();
|
|
5096
|
+
if (epoch === this.operationEpoch && value !== null && value !== void 0) this.sessionCache.set(key, detachValue(value));
|
|
5097
|
+
return value;
|
|
5098
|
+
} finally {
|
|
5099
|
+
if (epoch === this.operationEpoch) this.inFlight.delete(key);
|
|
5100
|
+
}
|
|
5101
|
+
}
|
|
5102
|
+
/**
|
|
5103
|
+
* Performs one HTTP request, rejecting with a typed failure for any non-2xx
|
|
5104
|
+
* response or transport error.
|
|
5105
|
+
*
|
|
5106
|
+
* - `authenticated` reflects the Authorization header actually attached to
|
|
5107
|
+
* this request, never a constructor assumption.
|
|
5108
|
+
* - A status/header-decidable throttle latches synchronously; only an
|
|
5109
|
+
* ambiguous 403 awaits its bounded body, and that latch is guarded by the
|
|
5110
|
+
* epoch captured before the request started.
|
|
5111
|
+
* - Transport messages are fixed text; the raw error (which may contain
|
|
5112
|
+
* credentials in a URL) stays internal to `cause`.
|
|
5113
|
+
*/
|
|
5114
|
+
async request(url, init) {
|
|
5115
|
+
if (this.rateLimitError) throw this.rateLimitError;
|
|
5116
|
+
const epoch = this.operationEpoch;
|
|
5117
|
+
const authenticated = GithubService.hasAttachedAuthorization(init);
|
|
5118
|
+
let res;
|
|
5119
|
+
try {
|
|
5120
|
+
res = init === void 0 ? await (0, cross_fetch.default)(url) : await (0, cross_fetch.default)(url, init);
|
|
5121
|
+
} catch (error) {
|
|
5122
|
+
throw new GithubRequestError({
|
|
5123
|
+
kind: "transport",
|
|
5124
|
+
message: "GitHub request failed before a response was received",
|
|
5125
|
+
endpoint: url,
|
|
5126
|
+
authenticated,
|
|
5127
|
+
cause: error
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
5130
|
+
if (!res.ok) {
|
|
5131
|
+
const verdict = classifyGithubStatus(res, url, authenticated);
|
|
5132
|
+
if (verdict !== "ambiguous") {
|
|
5133
|
+
if (verdict.kind === "rate-limited" && epoch === this.operationEpoch && !this.rateLimitError) this.rateLimitError = verdict;
|
|
5134
|
+
abandonResponseBody(res);
|
|
5135
|
+
throw verdict;
|
|
5136
|
+
}
|
|
5137
|
+
const failure = await classifyAmbiguous403(res, url, authenticated);
|
|
5138
|
+
if (failure.kind === "rate-limited" && epoch === this.operationEpoch && !this.rateLimitError) this.rateLimitError = failure;
|
|
5139
|
+
throw failure;
|
|
5140
|
+
}
|
|
5141
|
+
return res;
|
|
5142
|
+
}
|
|
5143
|
+
/** True only when this request's headers actually carry an Authorization value. */
|
|
5144
|
+
static hasAttachedAuthorization(init) {
|
|
5145
|
+
const headers = init?.headers;
|
|
5146
|
+
if (!headers) return false;
|
|
5147
|
+
if (Array.isArray(headers)) return headers.some((entry) => entry[0]?.toLowerCase() === "authorization" && Boolean(entry[1]));
|
|
5148
|
+
if (typeof Headers !== "undefined" && headers instanceof Headers) return Boolean(headers.get("authorization"));
|
|
5149
|
+
return Object.entries(headers).some(([name, value]) => name.toLowerCase() === "authorization" && typeof value === "string" && value.length > 0);
|
|
5150
|
+
}
|
|
5151
|
+
/** Rate-limit and access-denial failures are actionable; never flatten them to null. */
|
|
5152
|
+
static isActionable(error) {
|
|
5153
|
+
return GithubRequestError.isRateLimit(error) || GithubRequestError.isAccessDenial(error);
|
|
5154
|
+
}
|
|
5155
|
+
static isAbsence(error) {
|
|
5156
|
+
return error instanceof GithubRequestError && error.kind === "not-found";
|
|
5157
|
+
}
|
|
4042
5158
|
/**
|
|
4043
5159
|
* Fetches the recursive Git tree for a repository.
|
|
4044
5160
|
* @param owner Repository owner
|
|
4045
5161
|
* @param repo Repository name
|
|
4046
5162
|
* @param ref Git reference (branch, tag, or commit SHA)
|
|
4047
|
-
* @
|
|
5163
|
+
* @param options.commit Resolved commit; keys the shared tree by immutable identity
|
|
5164
|
+
* @returns The tree data, or null when the object or reference does not exist
|
|
4048
5165
|
*/
|
|
4049
|
-
async getRepoTree(owner, repo, ref) {
|
|
4050
|
-
const
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
5166
|
+
async getRepoTree(owner, repo, ref, options) {
|
|
5167
|
+
const identity = options?.commit ?? ref;
|
|
5168
|
+
const key = options?.commit ? `tree-commit:${owner}/${repo}@${options.commit}` : `tree-ref:${owner}/${repo}@${ref}`;
|
|
5169
|
+
const url = `${this.baseUrl}/repos/${owner}/${repo}/git/trees/${identity}?recursive=1`;
|
|
5170
|
+
return this.sessionRequest(key, async () => {
|
|
5171
|
+
try {
|
|
5172
|
+
return await (await this.request(url, { headers: this.headers })).json();
|
|
5173
|
+
} catch (error) {
|
|
5174
|
+
if (GithubService.isActionable(error)) throw error;
|
|
5175
|
+
if (GithubService.isAbsence(error)) return null;
|
|
5176
|
+
console.error(picocolors.default.red(`Failed to fetch repo tree: ${error}`));
|
|
5177
|
+
return null;
|
|
4056
5178
|
}
|
|
4057
|
-
|
|
4058
|
-
} catch (error) {
|
|
4059
|
-
console.error(picocolors.default.red(`Failed to fetch repo tree: ${error}`));
|
|
4060
|
-
return null;
|
|
4061
|
-
}
|
|
5179
|
+
});
|
|
4062
5180
|
}
|
|
4063
5181
|
/**
|
|
4064
5182
|
* Fetches raw file bytes from GitHub and validates their Git blob identity.
|
|
4065
5183
|
* Text callers should use getRawFile; package callers use this byte-preserving path.
|
|
4066
5184
|
*/
|
|
4067
5185
|
async getRawFileBytes(owner, repo, ref, path, options) {
|
|
4068
|
-
const
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
if (
|
|
5186
|
+
const identity = options?.commit ?? ref;
|
|
5187
|
+
const maxBytes = options?.maxBytes ?? 1048576;
|
|
5188
|
+
const key = `raw:${owner}/${repo}@${identity}:${path}#${options?.expectedSha ?? ""}#${maxBytes}`;
|
|
5189
|
+
const url = `${this.rawBaseUrl}/${owner}/${repo}/${identity}/${path}`;
|
|
5190
|
+
return this.sessionRequest(key, async () => {
|
|
5191
|
+
try {
|
|
5192
|
+
const res = await this.request(url);
|
|
5193
|
+
const content = Buffer.from(await res.arrayBuffer());
|
|
5194
|
+
if (content.byteLength > maxBytes) throw new IntegrityError(`${path}: ${content.byteLength} bytes exceeds the ${maxBytes}-byte limit`);
|
|
5195
|
+
if (options?.expectedSha) {
|
|
5196
|
+
const actualSha = gitBlobSha1(content);
|
|
5197
|
+
if (actualSha !== options.expectedSha) throw new IntegrityError(`${path}: blob sha mismatch (expected ${options.expectedSha}, got ${actualSha}) — download may be corrupted or tampered with`);
|
|
5198
|
+
}
|
|
5199
|
+
return content;
|
|
5200
|
+
} catch (error) {
|
|
5201
|
+
if (error instanceof IntegrityError) throw error;
|
|
5202
|
+
if (GithubService.isActionable(error)) throw error;
|
|
5203
|
+
if (GithubService.isAbsence(error)) return null;
|
|
5204
|
+
console.error(picocolors.default.red(`Failed to fetch file ${path}: ${error}`));
|
|
5205
|
+
return null;
|
|
4078
5206
|
}
|
|
4079
|
-
|
|
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
|
-
}
|
|
5207
|
+
});
|
|
4085
5208
|
}
|
|
4086
5209
|
/**
|
|
4087
5210
|
* Fetches raw file content as UTF-8 for established text-only callers.
|
|
@@ -4095,14 +5218,17 @@ var GithubService = class {
|
|
|
4095
5218
|
* @param repo Repository name
|
|
4096
5219
|
*/
|
|
4097
5220
|
async getLatestReleaseTag(owner, repo) {
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
5221
|
+
const key = `latest-release:${owner}/${repo}`;
|
|
5222
|
+
const url = `${this.baseUrl}/repos/${owner}/${repo}/releases/latest`;
|
|
5223
|
+
return this.sessionRequest(key, async () => {
|
|
5224
|
+
try {
|
|
5225
|
+
return (await (await this.request(url, { headers: this.headers })).json()).tag_name;
|
|
5226
|
+
} catch (error) {
|
|
5227
|
+
if (GithubService.isActionable(error)) throw error;
|
|
5228
|
+
if (process.env.DEBUG) console.warn(`[GithubService] Failed to fetch latest release: ${error}`);
|
|
5229
|
+
return null;
|
|
5230
|
+
}
|
|
5231
|
+
});
|
|
4106
5232
|
}
|
|
4107
5233
|
/**
|
|
4108
5234
|
* Fetches basic repository information, such as the default branch.
|
|
@@ -4110,34 +5236,59 @@ var GithubService = class {
|
|
|
4110
5236
|
* @param repo Repository name
|
|
4111
5237
|
*/
|
|
4112
5238
|
async getRepoInfo(owner, repo) {
|
|
5239
|
+
const key = `repo-info:${owner}/${repo}`;
|
|
4113
5240
|
const url = `${this.baseUrl}/repos/${owner}/${repo}`;
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
5241
|
+
return this.sessionRequest(key, async () => {
|
|
5242
|
+
try {
|
|
5243
|
+
return await (await this.request(url, { headers: this.headers })).json();
|
|
5244
|
+
} catch (error) {
|
|
5245
|
+
if (GithubService.isActionable(error)) throw error;
|
|
5246
|
+
console.error(picocolors.default.red(`Failed to fetch repo info: ${error}`));
|
|
5247
|
+
return null;
|
|
5248
|
+
}
|
|
5249
|
+
});
|
|
4122
5250
|
}
|
|
4123
5251
|
/**
|
|
4124
5252
|
* Downloads multiple text files concurrently with a limit. Existing text
|
|
4125
5253
|
* consumers retain UTF-8 strings; package sync uses the byte variant below.
|
|
5254
|
+
*
|
|
5255
|
+
* Rejects with the first known `IntegrityError` or access-denied
|
|
5256
|
+
* `GithubRequestError` in input-task order after every started worker
|
|
5257
|
+
* settles, unless a throttle latch owns the terminal `rateLimited` result.
|
|
4126
5258
|
*/
|
|
4127
5259
|
async downloadFilesConcurrent(tasks, concurrency = 10) {
|
|
4128
|
-
return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, {
|
|
5260
|
+
return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFile(task.owner, task.repo, task.ref, task.path, {
|
|
5261
|
+
expectedSha: task.sha,
|
|
5262
|
+
commit: task.commit
|
|
5263
|
+
}));
|
|
4129
5264
|
}
|
|
4130
5265
|
/**
|
|
4131
5266
|
* Downloads multiple files without decoding their response bytes.
|
|
5267
|
+
*
|
|
5268
|
+
* Same terminal-failure contract as `downloadFilesConcurrent`, so package
|
|
5269
|
+
* callers cannot treat verification or authorization failure as absence.
|
|
4132
5270
|
*/
|
|
4133
5271
|
async downloadFilesConcurrentBytes(tasks, concurrency = 10) {
|
|
4134
|
-
return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, {
|
|
5272
|
+
return this.downloadFilesConcurrentWith(tasks, concurrency, (task) => this.getRawFileBytes(task.owner, task.repo, task.ref, task.path, {
|
|
5273
|
+
expectedSha: task.sha,
|
|
5274
|
+
commit: task.commit
|
|
5275
|
+
}));
|
|
4135
5276
|
}
|
|
5277
|
+
/**
|
|
5278
|
+
* Runs the batch, settles every started worker, then decides the terminal
|
|
5279
|
+
* outcome:
|
|
5280
|
+
* - a throttle latch keeps priority (`rateLimited` result, as before);
|
|
5281
|
+
* - otherwise the first `IntegrityError` or access-denied failure in
|
|
5282
|
+
* input-task order is rethrown, so callers never mistake verification or
|
|
5283
|
+
* authorization failure for missing data;
|
|
5284
|
+
* - a genuine missing file (404) stays a `failed` result.
|
|
5285
|
+
*/
|
|
4136
5286
|
async downloadFilesConcurrentWith(tasks, concurrency, download) {
|
|
4137
5287
|
const outcomes = Array(tasks.length);
|
|
4138
5288
|
let nextIndex = 0;
|
|
4139
5289
|
const worker = async () => {
|
|
4140
5290
|
while (nextIndex < tasks.length) {
|
|
5291
|
+
if (this.rateLimitError) return;
|
|
4141
5292
|
const index = nextIndex++;
|
|
4142
5293
|
const task = tasks[index];
|
|
4143
5294
|
if (!task) continue;
|
|
@@ -4156,7 +5307,8 @@ var GithubService = class {
|
|
|
4156
5307
|
outcomes[index] = {
|
|
4157
5308
|
kind: "failed",
|
|
4158
5309
|
path: task.path,
|
|
4159
|
-
reason: error instanceof Error ? error.message : String(error)
|
|
5310
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
5311
|
+
error
|
|
4160
5312
|
};
|
|
4161
5313
|
}
|
|
4162
5314
|
}
|
|
@@ -4164,16 +5316,32 @@ var GithubService = class {
|
|
|
4164
5316
|
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
|
|
4165
5317
|
const ok = [];
|
|
4166
5318
|
const failed = [];
|
|
4167
|
-
for (
|
|
4168
|
-
|
|
4169
|
-
|
|
5319
|
+
for (let index = 0; index < tasks.length; index += 1) {
|
|
5320
|
+
const outcome = outcomes[index];
|
|
5321
|
+
const task = tasks[index];
|
|
5322
|
+
if (outcome?.kind === "ok") ok.push({
|
|
4170
5323
|
path: outcome.path,
|
|
4171
5324
|
content: outcome.content
|
|
4172
5325
|
});
|
|
4173
|
-
else failed.push({
|
|
5326
|
+
else if (outcome?.kind === "failed") failed.push({
|
|
4174
5327
|
path: outcome.path,
|
|
4175
5328
|
reason: outcome.reason
|
|
4176
5329
|
});
|
|
5330
|
+
else if (task && this.rateLimitError) failed.push({
|
|
5331
|
+
path: task.path,
|
|
5332
|
+
reason: `not attempted: ${this.rateLimitError.message}`
|
|
5333
|
+
});
|
|
5334
|
+
}
|
|
5335
|
+
if (this.rateLimitError) return {
|
|
5336
|
+
ok,
|
|
5337
|
+
failed,
|
|
5338
|
+
rateLimited: this.rateLimitError
|
|
5339
|
+
};
|
|
5340
|
+
for (let index = 0; index < outcomes.length; index += 1) {
|
|
5341
|
+
const outcome = outcomes[index];
|
|
5342
|
+
if (outcome?.kind !== "failed" || outcome.error === void 0) continue;
|
|
5343
|
+
const error = outcome.error;
|
|
5344
|
+
if (error instanceof IntegrityError || GithubRequestError.isAccessDenial(error)) throw error;
|
|
4177
5345
|
}
|
|
4178
5346
|
return {
|
|
4179
5347
|
ok,
|
|
@@ -4202,18 +5370,20 @@ var GithubService = class {
|
|
|
4202
5370
|
* Returns null on non-200, invalid response, or network failure.
|
|
4203
5371
|
*/
|
|
4204
5372
|
async resolveCommit(owner, repo, ref) {
|
|
5373
|
+
const key = `commit:${owner}/${repo}@${ref}`;
|
|
4205
5374
|
const url = `${this.baseUrl}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`;
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
5375
|
+
return this.sessionRequest(key, async () => {
|
|
5376
|
+
try {
|
|
5377
|
+
const text = (await (await this.request(url, { headers: {
|
|
5378
|
+
...this.headers,
|
|
5379
|
+
Accept: "application/vnd.github.sha"
|
|
5380
|
+
} })).text()).trim();
|
|
5381
|
+
return /^[0-9a-f]{40}$/i.test(text) ? text : null;
|
|
5382
|
+
} catch (error) {
|
|
5383
|
+
if (GithubService.isActionable(error)) throw error;
|
|
5384
|
+
return null;
|
|
5385
|
+
}
|
|
5386
|
+
});
|
|
4217
5387
|
}
|
|
4218
5388
|
/**
|
|
4219
5389
|
* Fetches and validates a release MANIFEST.json for a release tag.
|
|
@@ -4225,34 +5395,35 @@ var GithubService = class {
|
|
|
4225
5395
|
* failed verification must never look like "nothing to verify".
|
|
4226
5396
|
*/
|
|
4227
5397
|
async getReleaseManifest(owner, repo, tag) {
|
|
5398
|
+
const key = `release-manifest:${owner}/${repo}@${tag}`;
|
|
4228
5399
|
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/MANIFEST.json`;
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
5400
|
+
return this.sessionRequest(key, async () => {
|
|
5401
|
+
let res;
|
|
5402
|
+
try {
|
|
5403
|
+
res = await this.request(url);
|
|
5404
|
+
} catch (error) {
|
|
5405
|
+
if (GithubService.isAbsence(error)) return null;
|
|
5406
|
+
throw error;
|
|
5407
|
+
}
|
|
5408
|
+
let data;
|
|
5409
|
+
try {
|
|
5410
|
+
data = await res.json();
|
|
5411
|
+
} catch (error) {
|
|
5412
|
+
throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5413
|
+
}
|
|
5414
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: expected an object`);
|
|
5415
|
+
const record = data;
|
|
5416
|
+
if (record.schema_version !== 1) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: unsupported schema_version`);
|
|
5417
|
+
if (record.tag !== tag) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: tag does not match`);
|
|
5418
|
+
if (typeof record.commit !== "string" || !/^[0-9a-f]{40}$/i.test(record.commit)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid commit`);
|
|
5419
|
+
if (typeof record.files !== "object" || record.files === null || Array.isArray(record.files)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: missing files map`);
|
|
5420
|
+
const files = record.files;
|
|
5421
|
+
for (const [entry, value] of Object.entries(files)) {
|
|
5422
|
+
if (typeof entry !== "string" || typeof value !== "string") throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: non-string entry ${entry}`);
|
|
5423
|
+
if (!/^[0-9a-f]{64}$/i.test(value)) throw new IntegrityError(`Invalid MANIFEST.json for ${tag}: invalid digest for ${entry}`);
|
|
5424
|
+
}
|
|
5425
|
+
return record;
|
|
5426
|
+
});
|
|
4256
5427
|
}
|
|
4257
5428
|
};
|
|
4258
5429
|
//#endregion
|
|
@@ -5020,38 +6191,6 @@ async function removeEmptyParents(rootDir, dir) {
|
|
|
5020
6191
|
}
|
|
5021
6192
|
}
|
|
5022
6193
|
//#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
6194
|
//#region src/services/install/OwnershipWriter.ts
|
|
5056
6195
|
var PassthroughWriter = class {
|
|
5057
6196
|
rootDir;
|
|
@@ -5085,7 +6224,7 @@ var OwnershipWriter = class {
|
|
|
5085
6224
|
await assertNoSymlinksUnderRoot(this.opts.rootDir, absPath);
|
|
5086
6225
|
const rel = toPosixRel(this.opts.rootDir, absPath);
|
|
5087
6226
|
this.touched.add(rel);
|
|
5088
|
-
const incoming = sha256(content);
|
|
6227
|
+
const incoming = sha256$1(content);
|
|
5089
6228
|
const entry = {
|
|
5090
6229
|
...meta,
|
|
5091
6230
|
sha256: incoming
|
|
@@ -5097,7 +6236,7 @@ var OwnershipWriter = class {
|
|
|
5097
6236
|
this.next[rel] = entry;
|
|
5098
6237
|
return;
|
|
5099
6238
|
}
|
|
5100
|
-
const current = sha256(await fs_extra.default.readFile(absPath));
|
|
6239
|
+
const current = sha256$1(await fs_extra.default.readFile(absPath));
|
|
5101
6240
|
if (current === incoming) {
|
|
5102
6241
|
this.plan.unchanged.push(rel);
|
|
5103
6242
|
this.next[rel] = entry;
|
|
@@ -5140,7 +6279,7 @@ var OwnershipWriter = class {
|
|
|
5140
6279
|
const abs = path.default.join(this.opts.rootDir, rel);
|
|
5141
6280
|
await assertNoSymlinksUnderRoot(this.opts.rootDir, abs);
|
|
5142
6281
|
if (!await fs_extra.default.pathExists(abs)) continue;
|
|
5143
|
-
if (sha256(await fs_extra.default.readFile(abs)) !== prev.sha256) {
|
|
6282
|
+
if (sha256$1(await fs_extra.default.readFile(abs)) !== prev.sha256) {
|
|
5144
6283
|
this.plan.keptOrphans.push(rel);
|
|
5145
6284
|
continue;
|
|
5146
6285
|
}
|
|
@@ -5170,6 +6309,8 @@ var OwnershipWriter = class {
|
|
|
5170
6309
|
};
|
|
5171
6310
|
//#endregion
|
|
5172
6311
|
//#region src/services/SkillSyncService.ts
|
|
6312
|
+
var GIT_BLOB_SHA1_RE = /^[0-9a-f]{40}$/;
|
|
6313
|
+
var ATTRIBUTION_FAMILY_RE = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
|
|
5173
6314
|
/**
|
|
5174
6315
|
* A selected registry category or package could not be assembled completely.
|
|
5175
6316
|
* Callers must not write installations or lockfiles from this partial result.
|
|
@@ -5189,6 +6330,7 @@ var SkillSyncService = class {
|
|
|
5189
6330
|
githubService;
|
|
5190
6331
|
failedPackagesByCategory = /* @__PURE__ */ new Map();
|
|
5191
6332
|
failedCategories = /* @__PURE__ */ new Set();
|
|
6333
|
+
failureDetails = /* @__PURE__ */ new Map();
|
|
5192
6334
|
constructor(githubService) {
|
|
5193
6335
|
this.githubService = githubService;
|
|
5194
6336
|
}
|
|
@@ -5198,6 +6340,7 @@ var SkillSyncService = class {
|
|
|
5198
6340
|
async assembleSkills(categories, config, verifiers = {}, trees = {}, origins = {}, requireSkillDefinition = false) {
|
|
5199
6341
|
this.failedPackagesByCategory.clear();
|
|
5200
6342
|
this.failedCategories.clear();
|
|
6343
|
+
this.failureDetails.clear();
|
|
5201
6344
|
const collected = [];
|
|
5202
6345
|
const collectedOrigins = /* @__PURE__ */ new Map();
|
|
5203
6346
|
const conflicts = [];
|
|
@@ -5211,21 +6354,31 @@ var SkillSyncService = class {
|
|
|
5211
6354
|
const catConfig = config.skills[category];
|
|
5212
6355
|
const ref = catConfig.ref || "main";
|
|
5213
6356
|
console.log(picocolors.default.gray(` - Discovering ${category} (${ref})...`));
|
|
5214
|
-
const
|
|
6357
|
+
const resolvedSource = origins[category];
|
|
6358
|
+
const commit = resolvedSource?.commit && GIT_BLOB_SHA1_RE.test(resolvedSource.commit) ? resolvedSource.commit : void 0;
|
|
6359
|
+
const verifier = verifiers[category];
|
|
6360
|
+
if (verifier && commit) {
|
|
6361
|
+
const rejection = verifier.checkSourceCommit(commit);
|
|
6362
|
+
if (rejection) throw new IntegrityError(`${category}@${ref}: ${rejection}`);
|
|
6363
|
+
}
|
|
6364
|
+
const treeData = trees[category] ?? (commit ? await this.githubService.getRepoTree(owner, repo, ref, { commit }) : await this.githubService.getRepoTree(owner, repo, ref));
|
|
5215
6365
|
if (!treeData) {
|
|
5216
6366
|
this.failedCategories.add(category);
|
|
5217
6367
|
console.log(picocolors.default.red(` ❌ Failed to fetch ${category}@${ref}.`));
|
|
5218
6368
|
continue;
|
|
5219
6369
|
}
|
|
5220
|
-
const resolvedSource = origins[category];
|
|
5221
6370
|
const origin = resolvedSource ? {
|
|
5222
6371
|
category,
|
|
5223
6372
|
ref: resolvedSource.ref,
|
|
5224
6373
|
commit: resolvedSource.commit
|
|
5225
6374
|
} : void 0;
|
|
6375
|
+
const source = commit && resolvedSource ? {
|
|
6376
|
+
requestedRef: resolvedSource.ref,
|
|
6377
|
+
resolvedCommit: commit
|
|
6378
|
+
} : void 0;
|
|
5226
6379
|
const foldersToSync = resolveSkillFolders(category, catConfig, treeData.tree, requireSkillDefinition);
|
|
5227
6380
|
for (const absOrRelSkill of foldersToSync) {
|
|
5228
|
-
const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree, verifiers);
|
|
6381
|
+
const skill = await this.fetchSkill(owner, repo, ref, category, absOrRelSkill, treeData.tree, verifiers, source);
|
|
5229
6382
|
if (skill) {
|
|
5230
6383
|
const collectedSkill = origin ? {
|
|
5231
6384
|
...skill,
|
|
@@ -5250,7 +6403,11 @@ var SkillSyncService = class {
|
|
|
5250
6403
|
const failures = [
|
|
5251
6404
|
...this.failedCategories,
|
|
5252
6405
|
...conflicts,
|
|
5253
|
-
...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) =>
|
|
6406
|
+
...Array.from(this.failedPackagesByCategory).flatMap(([category, skills]) => Array.from(skills, (skill) => {
|
|
6407
|
+
const key = `${category}/${skill}`;
|
|
6408
|
+
const detail = this.failureDetails.get(key);
|
|
6409
|
+
return detail ? `${key} (${detail})` : key;
|
|
6410
|
+
}))
|
|
5254
6411
|
];
|
|
5255
6412
|
if (failures.length > 0) throw new SkillAssemblyError(failures);
|
|
5256
6413
|
return collected;
|
|
@@ -5260,6 +6417,7 @@ var SkillSyncService = class {
|
|
|
5260
6417
|
*/
|
|
5261
6418
|
async writeSkills(skills, config, agents, writer = new PassthroughWriter(process.cwd()), rootDir = process.cwd()) {
|
|
5262
6419
|
const overrides = config.custom_overrides || [];
|
|
6420
|
+
const written = /* @__PURE__ */ new Map();
|
|
5263
6421
|
for (const agentId of agents) {
|
|
5264
6422
|
const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
|
|
5265
6423
|
if (!basePath) continue;
|
|
@@ -5269,9 +6427,43 @@ var SkillSyncService = class {
|
|
|
5269
6427
|
const agentDef = SUPPORTED_AGENTS.find((agent) => agent.id === agentId);
|
|
5270
6428
|
if (!agentDef?.path) continue;
|
|
5271
6429
|
const basePath = agentDef.path;
|
|
5272
|
-
for (const skill of skills)
|
|
6430
|
+
for (const skill of skills) {
|
|
6431
|
+
const bindings = await this.writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, false, rootDir);
|
|
6432
|
+
for (const { name, binding } of bindings) {
|
|
6433
|
+
const key = `${skill.category}/${skill.skill}\0${name}`;
|
|
6434
|
+
const existing = written.get(key) ?? [];
|
|
6435
|
+
if (!existing.some((current) => current.agent === binding.agent && current.path === binding.path)) existing.push(binding);
|
|
6436
|
+
written.set(key, existing);
|
|
6437
|
+
}
|
|
6438
|
+
}
|
|
5273
6439
|
console.log(picocolors.default.gray(` - Updated ${basePath}/ (${agentDef.name})`));
|
|
5274
6440
|
}
|
|
6441
|
+
const registry = config.registry ?? "";
|
|
6442
|
+
const packages = {};
|
|
6443
|
+
for (const skill of skills) {
|
|
6444
|
+
const origin = skill.origin;
|
|
6445
|
+
if (!origin?.commit || !GIT_BLOB_SHA1_RE.test(origin.commit)) continue;
|
|
6446
|
+
const files = [];
|
|
6447
|
+
for (const file of skill.files) {
|
|
6448
|
+
if (!file.attribution) continue;
|
|
6449
|
+
const destinations = written.get(`${skill.category}/${skill.skill}\0${file.name}`);
|
|
6450
|
+
if (!destinations || destinations.length === 0) continue;
|
|
6451
|
+
files.push({
|
|
6452
|
+
...file.attribution,
|
|
6453
|
+
name: file.name,
|
|
6454
|
+
destinations
|
|
6455
|
+
});
|
|
6456
|
+
}
|
|
6457
|
+
if (files.length === 0) continue;
|
|
6458
|
+
packages[`${skill.category}/${skill.skill}`] = {
|
|
6459
|
+
registry,
|
|
6460
|
+
declaringCategory: origin.category,
|
|
6461
|
+
requestedRef: origin.ref,
|
|
6462
|
+
resolvedCommit: origin.commit,
|
|
6463
|
+
files
|
|
6464
|
+
};
|
|
6465
|
+
}
|
|
6466
|
+
return packages;
|
|
5275
6467
|
}
|
|
5276
6468
|
async writeSkillForAgent(agentId, skill, overrides, basePath, writer, config, preflightOnly = false, rootDir = basePath) {
|
|
5277
6469
|
const isKiro = agentId === Agent.Kiro;
|
|
@@ -5297,50 +6489,136 @@ var SkillSyncService = class {
|
|
|
5297
6489
|
const content = isKiro && fileItem.name === "SKILL.md" ? this.transformSkillForKiro(fileItem.content, skill.category) : fileItem.bytes ?? fileItem.content;
|
|
5298
6490
|
pending.push({
|
|
5299
6491
|
target: targetFilePath,
|
|
5300
|
-
content
|
|
6492
|
+
content,
|
|
6493
|
+
name: fileItem.name,
|
|
6494
|
+
attributed: fileItem.attribution !== void 0
|
|
5301
6495
|
});
|
|
5302
6496
|
}
|
|
5303
|
-
if (pending === null) return;
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
6497
|
+
if (pending === null) return [];
|
|
6498
|
+
const bindings = [];
|
|
6499
|
+
for (const { target, content, name, attributed } of pending) {
|
|
6500
|
+
await writer.write(target, content, {
|
|
6501
|
+
owner: "skill",
|
|
6502
|
+
source,
|
|
6503
|
+
agent: agentId
|
|
6504
|
+
});
|
|
6505
|
+
if (!attributed) continue;
|
|
6506
|
+
const installedDigest = await this.readInstalledDigest(target);
|
|
6507
|
+
const writtenDigest = sha256$1(content);
|
|
6508
|
+
if (installedDigest !== writtenDigest) continue;
|
|
6509
|
+
bindings.push({
|
|
6510
|
+
name,
|
|
6511
|
+
binding: {
|
|
6512
|
+
agent: agentId,
|
|
6513
|
+
path: toPosixRel(rootDir, target),
|
|
6514
|
+
sha256: writtenDigest
|
|
6515
|
+
}
|
|
6516
|
+
});
|
|
6517
|
+
}
|
|
6518
|
+
return bindings;
|
|
5309
6519
|
}
|
|
5310
|
-
|
|
6520
|
+
/** Digest of the file as it exists on disk, or null when unreadable. */
|
|
6521
|
+
async readInstalledDigest(absPath) {
|
|
6522
|
+
try {
|
|
6523
|
+
return sha256$1(await fs_extra.default.readFile(absPath));
|
|
6524
|
+
} catch {
|
|
6525
|
+
return null;
|
|
6526
|
+
}
|
|
6527
|
+
}
|
|
6528
|
+
async fetchSkill(owner, repo, ref, category, absOrRelSkill, tree, verifiers = {}, source) {
|
|
5311
6529
|
const [sourceCat, skillName] = absOrRelSkill.includes("/") ? absOrRelSkill.split("/") : [category, absOrRelSkill];
|
|
5312
6530
|
if (!sourceCat || !skillName) return null;
|
|
5313
6531
|
const prefix = `skills/${sourceCat}/${skillName}/`;
|
|
5314
6532
|
const packageFiles = tree.filter((file) => file.type === "blob" && file.path.startsWith(prefix) && this.isPackageResource(file.path.slice(prefix.length)));
|
|
5315
|
-
const
|
|
5316
|
-
const
|
|
6533
|
+
const localFamilies = new Set(packageFiles.map((file) => this.attributionFamily(file.path.slice(prefix.length))).filter((family) => family !== null));
|
|
6534
|
+
const rootAttributionFiles = tree.filter((file) => {
|
|
6535
|
+
if (file.type !== "blob" || !this.isRootAttributionFile(file.path)) return false;
|
|
6536
|
+
const family = this.attributionFamily(file.path);
|
|
6537
|
+
if (!family || localFamilies.has(family)) return false;
|
|
6538
|
+
return !packageFiles.some((packageFile) => packageFile.path.slice(prefix.length).toLowerCase() === file.path.toLowerCase());
|
|
6539
|
+
});
|
|
6540
|
+
const selectedFiles = [...packageFiles, ...rootAttributionFiles].sort((left, right) => left.path.localeCompare(right.path));
|
|
6541
|
+
const downloadTasks = selectedFiles.map((file) => ({
|
|
5317
6542
|
owner,
|
|
5318
6543
|
repo,
|
|
5319
6544
|
ref,
|
|
5320
6545
|
path: file.path,
|
|
5321
|
-
sha: file.sha
|
|
6546
|
+
sha: file.sha,
|
|
6547
|
+
...source ? { commit: source.resolvedCommit } : {}
|
|
5322
6548
|
}));
|
|
5323
6549
|
const { ok: files, failed } = await this.githubService.downloadFilesConcurrentBytes(downloadTasks);
|
|
5324
6550
|
const verifier = verifiers[category];
|
|
6551
|
+
let integrityFailure;
|
|
5325
6552
|
if (verifier) for (const f of files) {
|
|
5326
|
-
if (this.isRootAttributionFile(f.path))
|
|
6553
|
+
if (this.isRootAttributionFile(f.path)) {
|
|
6554
|
+
const rejection = verifier.checkRootAttribution(f.path, f.content);
|
|
6555
|
+
if (rejection) {
|
|
6556
|
+
failed.push({
|
|
6557
|
+
path: f.path,
|
|
6558
|
+
reason: rejection
|
|
6559
|
+
});
|
|
6560
|
+
integrityFailure ??= {
|
|
6561
|
+
path: f.path,
|
|
6562
|
+
reason: rejection
|
|
6563
|
+
};
|
|
6564
|
+
}
|
|
6565
|
+
continue;
|
|
6566
|
+
}
|
|
5327
6567
|
const rejection = verifier.check(f.path, f.content);
|
|
5328
|
-
if (rejection)
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
6568
|
+
if (rejection) {
|
|
6569
|
+
failed.push({
|
|
6570
|
+
path: f.path,
|
|
6571
|
+
reason: rejection
|
|
6572
|
+
});
|
|
6573
|
+
integrityFailure ??= {
|
|
6574
|
+
path: f.path,
|
|
6575
|
+
reason: rejection
|
|
6576
|
+
};
|
|
6577
|
+
}
|
|
5332
6578
|
}
|
|
5333
6579
|
for (const failure of failed) console.log(picocolors.default.red(` ❌ ${sourceCat}/${skillName}: ${failure.path} — ${failure.reason}`));
|
|
5334
|
-
if (
|
|
6580
|
+
if (integrityFailure) throw new IntegrityError(`Release manifest verification failed for ${sourceCat}/${skillName}: ${integrityFailure.path} — ${integrityFailure.reason}`);
|
|
6581
|
+
const hasSkillDefinition = packageFiles.some((file) => file.path === `${prefix}SKILL.md`);
|
|
6582
|
+
if (!hasSkillDefinition || failed.length > 0 || files.length !== downloadTasks.length) {
|
|
6583
|
+
this.markPackageFailed(sourceCat, skillName, failed[0]?.reason ?? (hasSkillDefinition ? "downloaded file count mismatch" : "missing SKILL.md"));
|
|
6584
|
+
return null;
|
|
6585
|
+
}
|
|
6586
|
+
const blobShaByPath = new Map(selectedFiles.map((file) => [file.path, file.sha]));
|
|
6587
|
+
const records = /* @__PURE__ */ new Map();
|
|
6588
|
+
if (source) for (const file of files) {
|
|
6589
|
+
const isPackageFile = file.path.startsWith(prefix);
|
|
6590
|
+
const name = isPackageFile ? file.path.slice(prefix.length) : file.path;
|
|
6591
|
+
if (this.attributionFamily(name) === null) continue;
|
|
6592
|
+
const blobSha1 = blobShaByPath.get(file.path);
|
|
6593
|
+
if (!blobSha1 || !GIT_BLOB_SHA1_RE.test(blobSha1)) {
|
|
6594
|
+
this.markPackageFailed(sourceCat, skillName, `missing Git blob identity for ${file.path}`);
|
|
6595
|
+
return null;
|
|
6596
|
+
}
|
|
6597
|
+
const basis = verifier?.hasManifestEntry(file.path) ? "release-manifest" : "commit-tree-blob";
|
|
6598
|
+
records.set(name, {
|
|
6599
|
+
sourcePath: file.path,
|
|
6600
|
+
sourceKind: isPackageFile ? "package" : "root",
|
|
6601
|
+
requestedRef: source.requestedRef,
|
|
6602
|
+
resolvedCommit: source.resolvedCommit,
|
|
6603
|
+
blobSha1,
|
|
6604
|
+
sha256: sha256$1(file.content),
|
|
6605
|
+
basis
|
|
6606
|
+
});
|
|
6607
|
+
}
|
|
5335
6608
|
console.log(picocolors.default.gray(` + Fetched ${sourceCat}/${skillName} (${files.length} files)`));
|
|
5336
6609
|
return {
|
|
5337
6610
|
category: sourceCat,
|
|
5338
6611
|
skill: skillName,
|
|
5339
|
-
files: files.map((file) =>
|
|
5340
|
-
name
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
6612
|
+
files: files.map((file) => {
|
|
6613
|
+
const name = file.path.startsWith(prefix) ? file.path.slice(prefix.length) : file.path;
|
|
6614
|
+
const attribution = records.get(name);
|
|
6615
|
+
return {
|
|
6616
|
+
name,
|
|
6617
|
+
content: file.content.toString("utf8"),
|
|
6618
|
+
bytes: file.content,
|
|
6619
|
+
...attribution ? { attribution } : {}
|
|
6620
|
+
};
|
|
6621
|
+
})
|
|
5344
6622
|
};
|
|
5345
6623
|
}
|
|
5346
6624
|
/**
|
|
@@ -5386,16 +6664,24 @@ var SkillSyncService = class {
|
|
|
5386
6664
|
isPackageResource(relativePath) {
|
|
5387
6665
|
return relativePath === "SKILL.md" || /^(references|scripts|assets)\//.test(relativePath) || this.isRootAttributionFile(relativePath);
|
|
5388
6666
|
}
|
|
6667
|
+
attributionFamily(filePath) {
|
|
6668
|
+
if (filePath.includes("/")) return null;
|
|
6669
|
+
const match = ATTRIBUTION_FAMILY_RE.exec(filePath);
|
|
6670
|
+
if (!match) return null;
|
|
6671
|
+
return match[1].toLowerCase() === "license" ? "license" : "notice";
|
|
6672
|
+
}
|
|
5389
6673
|
isRootAttributionFile(filePath) {
|
|
5390
|
-
return
|
|
6674
|
+
return this.attributionFamily(filePath) !== null;
|
|
5391
6675
|
}
|
|
5392
|
-
markPackageFailed(category, skill) {
|
|
6676
|
+
markPackageFailed(category, skill, detail) {
|
|
5393
6677
|
let failedSkills = this.failedPackagesByCategory.get(category);
|
|
5394
6678
|
if (!failedSkills) {
|
|
5395
6679
|
failedSkills = /* @__PURE__ */ new Set();
|
|
5396
6680
|
this.failedPackagesByCategory.set(category, failedSkills);
|
|
5397
6681
|
}
|
|
5398
6682
|
failedSkills.add(skill);
|
|
6683
|
+
const key = `${category}/${skill}`;
|
|
6684
|
+
if (detail && !this.failureDetails.has(key)) this.failureDetails.set(key, detail);
|
|
5399
6685
|
}
|
|
5400
6686
|
isRecord(value) {
|
|
5401
6687
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -6260,15 +7546,17 @@ var WorkflowSyncService = class {
|
|
|
6260
7546
|
* need the collected files; new callers must use `acquireWorkflows` so an
|
|
6261
7547
|
* incomplete fetch is distinguishable from a deliberate empty selection.
|
|
6262
7548
|
*/
|
|
6263
|
-
async assembleWorkflows(config, ref = "main", verifier) {
|
|
6264
|
-
return (await this.acquireWorkflows(config, ref, verifier)).workflows;
|
|
7549
|
+
async assembleWorkflows(config, ref = "main", verifier, commit) {
|
|
7550
|
+
return (await this.acquireWorkflows(config, ref, verifier, commit)).workflows;
|
|
6265
7551
|
}
|
|
6266
7552
|
/**
|
|
6267
7553
|
* Acquires the selected canonical workflows and reports whether the desired
|
|
6268
7554
|
* set is complete. Partial discovery/download/integrity failures are
|
|
6269
|
-
* `incomplete` and never authorize absence-based pruning.
|
|
7555
|
+
* `incomplete` and never authorize absence-based pruning. When `commit` is
|
|
7556
|
+
* supplied, discovery and downloads use that immutable identity instead of
|
|
7557
|
+
* the mutable ref string.
|
|
6270
7558
|
*/
|
|
6271
|
-
async acquireWorkflows(config, ref = "main", verifier) {
|
|
7559
|
+
async acquireWorkflows(config, ref = "main", verifier, commit) {
|
|
6272
7560
|
if (config.workflows === false || config.workflows === void 0) return {
|
|
6273
7561
|
status: "disabled",
|
|
6274
7562
|
workflows: [],
|
|
@@ -6289,8 +7577,12 @@ var WorkflowSyncService = class {
|
|
|
6289
7577
|
}]
|
|
6290
7578
|
};
|
|
6291
7579
|
const { owner, repo } = githubMatch;
|
|
7580
|
+
if (verifier && commit) {
|
|
7581
|
+
const rejection = verifier.checkSourceCommit(commit);
|
|
7582
|
+
if (rejection) throw new IntegrityError(`workflows@${ref}: ${rejection}`);
|
|
7583
|
+
}
|
|
6292
7584
|
console.log(picocolors.default.gray(` - Discovering workflows (${ref})...`));
|
|
6293
|
-
const treeData = await this.githubService.getRepoTree(owner, repo, ref);
|
|
7585
|
+
const treeData = await this.githubService.getRepoTree(owner, repo, ref, commit ? { commit } : void 0);
|
|
6294
7586
|
if (!treeData) {
|
|
6295
7587
|
console.log(picocolors.default.red(` ❌ Failed to fetch workflows@${ref}.`));
|
|
6296
7588
|
return {
|
|
@@ -6317,21 +7609,21 @@ var WorkflowSyncService = class {
|
|
|
6317
7609
|
failures: []
|
|
6318
7610
|
};
|
|
6319
7611
|
}
|
|
6320
|
-
const
|
|
7612
|
+
const downloadResult = await this.githubService.downloadFilesConcurrent(workflowFiles.map((f) => ({
|
|
6321
7613
|
owner,
|
|
6322
7614
|
repo,
|
|
6323
7615
|
ref,
|
|
6324
7616
|
path: f.path,
|
|
6325
|
-
sha: f.sha
|
|
7617
|
+
sha: f.sha,
|
|
7618
|
+
...commit ? { commit } : {}
|
|
6326
7619
|
})));
|
|
7620
|
+
if (downloadResult.rateLimited) throw downloadResult.rateLimited;
|
|
7621
|
+
const { ok: files, failed } = downloadResult;
|
|
6327
7622
|
const okFiles = [];
|
|
6328
7623
|
for (const f of files) {
|
|
6329
7624
|
const rejection = verifier?.check(f.path, f.content);
|
|
6330
|
-
if (rejection)
|
|
6331
|
-
|
|
6332
|
-
reason: rejection
|
|
6333
|
-
});
|
|
6334
|
-
else okFiles.push(f);
|
|
7625
|
+
if (rejection) throw new IntegrityError(`${f.path}: ${rejection}`);
|
|
7626
|
+
okFiles.push(f);
|
|
6335
7627
|
}
|
|
6336
7628
|
for (const failure of failed) console.log(picocolors.default.red(` ❌ ${failure.path} — ${failure.reason}`));
|
|
6337
7629
|
const status = failed.length > 0 ? "incomplete" : "complete";
|
|
@@ -6401,7 +7693,7 @@ var WorkflowSyncService = class {
|
|
|
6401
7693
|
source: `workflow:${workflowName}`,
|
|
6402
7694
|
agent: SHARED_WORKFLOW_AGENT
|
|
6403
7695
|
});
|
|
6404
|
-
const canonicalDigest = sha256(fileItem.content);
|
|
7696
|
+
const canonicalDigest = sha256$1(fileItem.content);
|
|
6405
7697
|
writtenDigests.set(canonicalPath, canonicalDigest);
|
|
6406
7698
|
result.canonical.push({
|
|
6407
7699
|
name: workflowName,
|
|
@@ -6433,7 +7725,7 @@ var WorkflowSyncService = class {
|
|
|
6433
7725
|
continue;
|
|
6434
7726
|
}
|
|
6435
7727
|
if (this.isOverridden(targetFilePath, overrides)) continue;
|
|
6436
|
-
const outputDigest = sha256(transformed.content);
|
|
7728
|
+
const outputDigest = sha256$1(transformed.content);
|
|
6437
7729
|
const existingDigest = writtenDigests.get(targetFilePath);
|
|
6438
7730
|
if (existingDigest === void 0) {
|
|
6439
7731
|
await writer.write(targetFilePath, transformed.content, {
|
|
@@ -6452,7 +7744,7 @@ var WorkflowSyncService = class {
|
|
|
6452
7744
|
agent: agentId,
|
|
6453
7745
|
path: this.normalizePath(targetFilePath),
|
|
6454
7746
|
outputDigest,
|
|
6455
|
-
canonicalDigest: sha256(fileItem.content)
|
|
7747
|
+
canonicalDigest: sha256$1(fileItem.content)
|
|
6456
7748
|
});
|
|
6457
7749
|
}
|
|
6458
7750
|
}
|
|
@@ -6760,33 +8052,66 @@ var SpecialistTransformer = class {
|
|
|
6760
8052
|
};
|
|
6761
8053
|
//#endregion
|
|
6762
8054
|
//#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
8055
|
var SpecialistSyncService = class {
|
|
6768
8056
|
githubService;
|
|
6769
8057
|
constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
|
|
6770
8058
|
this.githubService = githubService;
|
|
6771
8059
|
}
|
|
6772
|
-
|
|
8060
|
+
/**
|
|
8061
|
+
* Acquires every remote specialist, reporting whether the selected set is
|
|
8062
|
+
* complete. Fetches are bound to `commit` when the caller resolved one, so a
|
|
8063
|
+
* tag that moved after resolution cannot supply different bytes.
|
|
8064
|
+
*/
|
|
8065
|
+
async acquireSpecialists(config, ref = "main", verifier, commit) {
|
|
6773
8066
|
const githubMatch = GithubService.parseGitHubUrl(config.registry);
|
|
6774
|
-
if (!githubMatch) return
|
|
8067
|
+
if (!githubMatch) return {
|
|
8068
|
+
status: "incomplete",
|
|
8069
|
+
specialists: [],
|
|
8070
|
+
failures: [{
|
|
8071
|
+
path: "registry",
|
|
8072
|
+
reason: `Unsupported registry URL '${config.registry}'.`
|
|
8073
|
+
}]
|
|
8074
|
+
};
|
|
6775
8075
|
const { owner, repo } = githubMatch;
|
|
6776
|
-
const
|
|
6777
|
-
if (
|
|
8076
|
+
const treeOptions = commit ? { commit } : void 0;
|
|
8077
|
+
if (verifier && commit) {
|
|
8078
|
+
const rejection = verifier.checkSourceCommit(commit);
|
|
8079
|
+
if (rejection) throw new IntegrityError(`specialists@${ref}: ${rejection}`);
|
|
8080
|
+
}
|
|
8081
|
+
const treeData = await this.githubService.getRepoTree(owner, repo, ref, treeOptions);
|
|
8082
|
+
if (!treeData) return {
|
|
8083
|
+
status: "incomplete",
|
|
8084
|
+
specialists: [],
|
|
8085
|
+
failures: [{
|
|
8086
|
+
path: "specialists",
|
|
8087
|
+
reason: `Failed to fetch specialists@${ref}.`
|
|
8088
|
+
}]
|
|
8089
|
+
};
|
|
6778
8090
|
const skillFiles = treeData.tree.filter((item) => item.type === "blob" && item.path.startsWith("skills/specialists/") && item.path.endsWith("/SKILL.md"));
|
|
6779
8091
|
const specialists = [];
|
|
8092
|
+
const failures = [];
|
|
6780
8093
|
for (const file of skillFiles) {
|
|
6781
8094
|
const specialistName = file.path.split("/")[2];
|
|
6782
|
-
if (!specialistName)
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
8095
|
+
if (!specialistName) {
|
|
8096
|
+
failures.push({
|
|
8097
|
+
path: file.path,
|
|
8098
|
+
reason: "specialist directory name is missing"
|
|
8099
|
+
});
|
|
8100
|
+
continue;
|
|
8101
|
+
}
|
|
8102
|
+
const content = await this.githubService.getRawFile(owner, repo, ref, file.path, {
|
|
8103
|
+
expectedSha: file.sha,
|
|
8104
|
+
...commit ? { commit } : {}
|
|
8105
|
+
});
|
|
8106
|
+
if (!content) {
|
|
8107
|
+
failures.push({
|
|
8108
|
+
path: file.path,
|
|
8109
|
+
reason: "download failed"
|
|
8110
|
+
});
|
|
6788
8111
|
continue;
|
|
6789
8112
|
}
|
|
8113
|
+
const rejection = verifier?.check(file.path, content);
|
|
8114
|
+
if (rejection) throw new IntegrityError(`${file.path}: ${rejection}`);
|
|
6790
8115
|
specialists.push({
|
|
6791
8116
|
category: "specialists",
|
|
6792
8117
|
skill: specialistName,
|
|
@@ -6796,9 +8121,13 @@ var SpecialistSyncService = class {
|
|
|
6796
8121
|
}]
|
|
6797
8122
|
});
|
|
6798
8123
|
}
|
|
6799
|
-
return
|
|
8124
|
+
return {
|
|
8125
|
+
status: failures.length > 0 ? "incomplete" : "complete",
|
|
8126
|
+
specialists,
|
|
8127
|
+
failures
|
|
8128
|
+
};
|
|
6800
8129
|
}
|
|
6801
|
-
async
|
|
8130
|
+
async writeSpecialists(rootDir, agents, specialists, writer = new PassthroughWriter(rootDir)) {
|
|
6802
8131
|
if (specialists.length === 0) return 0;
|
|
6803
8132
|
for (const agentId of agents) {
|
|
6804
8133
|
const agentDef = SUPPORTED_AGENTS.find((a) => a.id === agentId);
|
|
@@ -6825,20 +8154,18 @@ var SpecialistSyncService = class {
|
|
|
6825
8154
|
return specialists.length;
|
|
6826
8155
|
}
|
|
6827
8156
|
/**
|
|
6828
|
-
*
|
|
6829
|
-
*
|
|
6830
|
-
*
|
|
6831
|
-
* @param sourceDir Optional custom source directory for specialists
|
|
8157
|
+
* Reads every specialist from a project-local source directory. This is a
|
|
8158
|
+
* local acquisition with no network I/O; the caller publishes the result
|
|
8159
|
+
* through `writeSpecialists` after the shared preflight gates.
|
|
6832
8160
|
*/
|
|
6833
|
-
async
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
return fs_extra.default.statSync(path.default.join(specialistsDir, f)).isDirectory();
|
|
8161
|
+
async readLocalSpecialists(sourceDir) {
|
|
8162
|
+
if (!await fs_extra.default.pathExists(sourceDir)) return [];
|
|
8163
|
+
const specialistFolders = (await fs_extra.default.readdir(sourceDir)).filter((f) => {
|
|
8164
|
+
return fs_extra.default.statSync(path.default.join(sourceDir, f)).isDirectory();
|
|
6838
8165
|
});
|
|
6839
8166
|
const collected = [];
|
|
6840
8167
|
for (const folder of specialistFolders) {
|
|
6841
|
-
const skillPath = path.default.join(
|
|
8168
|
+
const skillPath = path.default.join(sourceDir, folder, "SKILL.md");
|
|
6842
8169
|
if (!await fs_extra.default.pathExists(skillPath)) continue;
|
|
6843
8170
|
const content = await fs_extra.default.readFile(skillPath, "utf8");
|
|
6844
8171
|
collected.push({
|
|
@@ -6850,7 +8177,7 @@ var SpecialistSyncService = class {
|
|
|
6850
8177
|
}]
|
|
6851
8178
|
});
|
|
6852
8179
|
}
|
|
6853
|
-
return
|
|
8180
|
+
return collected;
|
|
6854
8181
|
}
|
|
6855
8182
|
};
|
|
6856
8183
|
//#endregion
|
|
@@ -7123,22 +8450,120 @@ var BackupService = class {
|
|
|
7123
8450
|
};
|
|
7124
8451
|
//#endregion
|
|
7125
8452
|
//#region src/services/install/ManifestVerifier.ts
|
|
8453
|
+
function normalizeRepoPath(repoPath) {
|
|
8454
|
+
return repoPath.replace(/^(\.\/|\/)+/, "");
|
|
8455
|
+
}
|
|
8456
|
+
function sha256(content) {
|
|
8457
|
+
return (0, node_crypto.createHash)("sha256").update(content, Buffer.isBuffer(content) ? void 0 : "utf8").digest("hex");
|
|
8458
|
+
}
|
|
8459
|
+
function isListed(manifest, repoPath) {
|
|
8460
|
+
return Object.prototype.hasOwnProperty.call(manifest.files, repoPath);
|
|
8461
|
+
}
|
|
7126
8462
|
var ManifestVerifier = class {
|
|
7127
8463
|
manifest;
|
|
7128
8464
|
constructor(manifest) {
|
|
7129
8465
|
this.manifest = manifest;
|
|
7130
8466
|
}
|
|
8467
|
+
/** Resolved commit this manifest claims, or null when no manifest exists. */
|
|
8468
|
+
get commit() {
|
|
8469
|
+
return this.manifest?.commit ?? null;
|
|
8470
|
+
}
|
|
8471
|
+
/**
|
|
8472
|
+
* A manifest used for selected-source verification must describe the exact
|
|
8473
|
+
* resolved commit. A syntactically valid but different commit is an
|
|
8474
|
+
* integrity failure, never a silently skipped verification.
|
|
8475
|
+
*/
|
|
8476
|
+
checkSourceCommit(expected) {
|
|
8477
|
+
if (!this.manifest) return null;
|
|
8478
|
+
if (this.manifest.commit === expected) return null;
|
|
8479
|
+
return `release MANIFEST.json commit '${this.manifest.commit}' does not match the resolved source commit '${expected}'`;
|
|
8480
|
+
}
|
|
8481
|
+
/**
|
|
8482
|
+
* Whether the schema-1 files map binds this exact repo path. Root LICENSE
|
|
8483
|
+
* and NOTICE entries are ordinary files-map entries, not a separate
|
|
8484
|
+
* namespace.
|
|
8485
|
+
*/
|
|
8486
|
+
hasManifestEntry(repoPath) {
|
|
8487
|
+
if (!this.manifest) return false;
|
|
8488
|
+
return isListed(this.manifest, normalizeRepoPath(repoPath));
|
|
8489
|
+
}
|
|
7131
8490
|
/** Returns null when OK (or no manifest), else a rejection reason. */
|
|
7132
8491
|
check(repoPath, content) {
|
|
7133
8492
|
if (!this.manifest) return null;
|
|
7134
|
-
const normalized = repoPath
|
|
7135
|
-
if (!
|
|
8493
|
+
const normalized = normalizeRepoPath(repoPath);
|
|
8494
|
+
if (!isListed(this.manifest, normalized)) return "not listed in release MANIFEST.json";
|
|
7136
8495
|
const expectedSha = this.manifest.files[normalized];
|
|
7137
|
-
if (
|
|
8496
|
+
if (sha256(content) !== expectedSha) return "sha256 does not match release MANIFEST.json";
|
|
8497
|
+
return null;
|
|
8498
|
+
}
|
|
8499
|
+
/**
|
|
8500
|
+
* Root LICENSE/NOTICE live outside every category release scope, so legacy
|
|
8501
|
+
* manifests do not list them. A listed root entry is always enforced; an
|
|
8502
|
+
* unlisted one leaves the caller's verified commit-tree/blob basis intact
|
|
8503
|
+
* instead of inventing a manifest verification that never happened.
|
|
8504
|
+
*/
|
|
8505
|
+
checkRootAttribution(repoPath, content) {
|
|
8506
|
+
if (!this.manifest) return null;
|
|
8507
|
+
const normalized = normalizeRepoPath(repoPath);
|
|
8508
|
+
if (!isListed(this.manifest, normalized)) return null;
|
|
8509
|
+
if (sha256(content) !== this.manifest.files[normalized]) return "sha256 does not match release MANIFEST.json root attribution";
|
|
7138
8510
|
return null;
|
|
7139
8511
|
}
|
|
7140
8512
|
};
|
|
7141
8513
|
//#endregion
|
|
8514
|
+
//#region src/services/install/UpdatePolicy.ts
|
|
8515
|
+
/**
|
|
8516
|
+
* Automatic version-update policy for release-pinned categories, workflows,
|
|
8517
|
+
* and specialists.
|
|
8518
|
+
*
|
|
8519
|
+
* A newer advertised release may only replace a configured pin automatically
|
|
8520
|
+
* when both refs are canonical stable semantic-version refs for the same tag
|
|
8521
|
+
* prefix and the candidate is strictly newer. Branches, commits, custom refs,
|
|
8522
|
+
* prereleases, downgrades, and equal versions stay explicit: accepting them
|
|
8523
|
+
* would silently move a user to incomparable source.
|
|
8524
|
+
*/
|
|
8525
|
+
var STABLE_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
8526
|
+
/**
|
|
8527
|
+
* @param currentRef currently configured ref, if the project already pinned one
|
|
8528
|
+
* @param candidateRef ref advertised by the registry for the same category
|
|
8529
|
+
* @param tagPrefix tag prefix published for that category (may be empty)
|
|
8530
|
+
* @returns true only for a strictly newer comparable stable release ref
|
|
8531
|
+
*/
|
|
8532
|
+
function isAutomaticUpgrade(currentRef, candidateRef, tagPrefix) {
|
|
8533
|
+
if (typeof currentRef !== "string" || typeof candidateRef !== "string") return false;
|
|
8534
|
+
const current = parseStableVersion(currentRef, tagPrefix);
|
|
8535
|
+
const candidate = parseStableVersion(candidateRef, tagPrefix);
|
|
8536
|
+
if (!current || !candidate) return false;
|
|
8537
|
+
for (let index = 0; index < 3; index += 1) {
|
|
8538
|
+
const order = compareNumericStrings(current[index], candidate[index]);
|
|
8539
|
+
if (order !== 0) return order < 0;
|
|
8540
|
+
}
|
|
8541
|
+
return false;
|
|
8542
|
+
}
|
|
8543
|
+
/**
|
|
8544
|
+
* Strips the category tag prefix and requires a canonical `MAJOR.MINOR.PATCH`
|
|
8545
|
+
* core. Numeric components are returned as digit strings so comparison stays
|
|
8546
|
+
* exact beyond `Number.MAX_SAFE_INTEGER`.
|
|
8547
|
+
*/
|
|
8548
|
+
function parseStableVersion(ref, tagPrefix) {
|
|
8549
|
+
if (!ref.startsWith(tagPrefix)) return null;
|
|
8550
|
+
const version = ref.slice(tagPrefix.length);
|
|
8551
|
+
const match = STABLE_VERSION.exec(version);
|
|
8552
|
+
if (!match || match[0] !== version) return null;
|
|
8553
|
+
const components = [
|
|
8554
|
+
match[1],
|
|
8555
|
+
match[2],
|
|
8556
|
+
match[3]
|
|
8557
|
+
];
|
|
8558
|
+
for (const component of components) if (component.length > 1 && component.startsWith("0")) return null;
|
|
8559
|
+
return components;
|
|
8560
|
+
}
|
|
8561
|
+
function compareNumericStrings(left, right) {
|
|
8562
|
+
if (left.length !== right.length) return left.length < right.length ? -1 : 1;
|
|
8563
|
+
if (left === right) return 0;
|
|
8564
|
+
return left < right ? -1 : 1;
|
|
8565
|
+
}
|
|
8566
|
+
//#endregion
|
|
7142
8567
|
//#region src/services/install/RefResolver.ts
|
|
7143
8568
|
var RefResolver = class {
|
|
7144
8569
|
github;
|
|
@@ -7225,21 +8650,21 @@ var RefResolver = class {
|
|
|
7225
8650
|
//#endregion
|
|
7226
8651
|
//#region src/services/SyncService.ts
|
|
7227
8652
|
var defaultExec = (cmd, args) => {
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
8653
|
+
return new Promise((resolve, reject) => {
|
|
8654
|
+
(0, node_child_process.execFile)(cmd, args, (error, _stdout, stderr) => {
|
|
8655
|
+
if (error) {
|
|
8656
|
+
if (error.code === "ENOENT") return reject(error);
|
|
8657
|
+
return resolve({
|
|
8658
|
+
code: typeof error.code === "number" ? error.code : 1,
|
|
8659
|
+
stderr: stderr || error.message
|
|
8660
|
+
});
|
|
8661
|
+
}
|
|
8662
|
+
resolve({
|
|
8663
|
+
code: 0,
|
|
8664
|
+
stderr: stderr || ""
|
|
7235
8665
|
});
|
|
7236
|
-
}
|
|
7237
|
-
resolve({
|
|
7238
|
-
code: 0,
|
|
7239
|
-
stderr: stderr || ""
|
|
7240
8666
|
});
|
|
7241
8667
|
});
|
|
7242
|
-
return promise;
|
|
7243
8668
|
};
|
|
7244
8669
|
/**
|
|
7245
8670
|
* Rejects an incomplete or malformed repository tree. A truncated or shaped
|
|
@@ -7278,6 +8703,8 @@ function assertIncludeTargetExists(category, expression, targetCategory, targetS
|
|
|
7278
8703
|
const skillPath = `${categoryPath}/${targetSkill}/SKILL.md`;
|
|
7279
8704
|
if (!tree.tree.some((item) => item.type === "blob" && item.path === skillPath)) throw new SkillAssemblyError([`${category} include ${expression} (requested skill missing)`]);
|
|
7280
8705
|
}
|
|
8706
|
+
/** Bounded attribution-family filename used for installed variant discovery. */
|
|
8707
|
+
var ATTRIBUTION_FILE_NAME_RE = /^(LICENSE|NOTICE)(?:\.(?:md|txt))?$/i;
|
|
7281
8708
|
/**
|
|
7282
8709
|
* Service responsible for coordinating the synchronization of agent skills and workflows.
|
|
7283
8710
|
* It acts as a facade, delegating specialized synchronization tasks to SkillSyncService
|
|
@@ -7289,7 +8716,7 @@ var SyncService = class {
|
|
|
7289
8716
|
githubService;
|
|
7290
8717
|
skillSyncService;
|
|
7291
8718
|
workflowSyncService;
|
|
7292
|
-
specialistSyncService
|
|
8719
|
+
specialistSyncService;
|
|
7293
8720
|
gitService = new GitService();
|
|
7294
8721
|
lockfileService = new LockfileService();
|
|
7295
8722
|
writer = new PassthroughWriter(process.cwd());
|
|
@@ -7306,10 +8733,13 @@ var SyncService = class {
|
|
|
7306
8733
|
};
|
|
7307
8734
|
warnedMovedTags = /* @__PURE__ */ new Set();
|
|
7308
8735
|
installScope = "all";
|
|
8736
|
+
/** Attribution records for the packages actually published by this run. */
|
|
8737
|
+
skillAttributionPackages = null;
|
|
7309
8738
|
constructor(githubService = new GithubService(process.env.GITHUB_TOKEN)) {
|
|
7310
8739
|
this.githubService = githubService;
|
|
7311
8740
|
this.skillSyncService = new SkillSyncService(githubService);
|
|
7312
8741
|
this.workflowSyncService = new WorkflowSyncService(githubService);
|
|
8742
|
+
this.specialistSyncService = new SpecialistSyncService(githubService);
|
|
7313
8743
|
}
|
|
7314
8744
|
async reconcileConfig(config, projectDeps) {
|
|
7315
8745
|
const reenabled = this.configService.reconcileDependencies(config, projectDeps);
|
|
@@ -7327,7 +8757,14 @@ var SyncService = class {
|
|
|
7327
8757
|
async assembleSkills(categories, config, trees, origins, requireSkillDefinition = false) {
|
|
7328
8758
|
await this.warnIfSyncingFromSameRepo(config);
|
|
7329
8759
|
const skillCategories = categories.filter((category) => category !== "specialists");
|
|
7330
|
-
|
|
8760
|
+
const sourceOrigins = origins ?? Object.fromEntries(skillCategories.filter((category) => this.resolvedRefs?.skills[category]).map((category) => {
|
|
8761
|
+
const source = this.resolvedRefs.skills[category];
|
|
8762
|
+
return [category, {
|
|
8763
|
+
ref: source.ref,
|
|
8764
|
+
commit: source.commit
|
|
8765
|
+
}];
|
|
8766
|
+
}));
|
|
8767
|
+
return this.skillSyncService.assembleSkills(skillCategories, config, this.manifestVerifiers.skills, trees, sourceOrigins, requireSkillDefinition);
|
|
7331
8768
|
}
|
|
7332
8769
|
/**
|
|
7333
8770
|
* Prefetches only the skill sources for a scoped apply. Resolves every
|
|
@@ -7437,6 +8874,7 @@ var SyncService = class {
|
|
|
7437
8874
|
const { lock, migratedFromV1 } = await this.lockfileService.load(process.cwd(), agents);
|
|
7438
8875
|
this.dryRun = opts.dryRun;
|
|
7439
8876
|
this.previousLock = lock;
|
|
8877
|
+
this.skillAttributionPackages = null;
|
|
7440
8878
|
this.pendingWorkflowWrites = {
|
|
7441
8879
|
canonical: [],
|
|
7442
8880
|
bindings: []
|
|
@@ -7499,11 +8937,61 @@ var SyncService = class {
|
|
|
7499
8937
|
entries
|
|
7500
8938
|
};
|
|
7501
8939
|
if (catalog) lock.workflowCatalog = catalog;
|
|
8940
|
+
const attribution = await this.buildAttributionCatalog(configured, entries);
|
|
8941
|
+
if (attribution) lock.attributionCatalog = attribution;
|
|
7502
8942
|
await this.lockfileService.write(process.cwd(), lock);
|
|
7503
8943
|
}
|
|
7504
8944
|
return plan;
|
|
7505
8945
|
}
|
|
7506
8946
|
/**
|
|
8947
|
+
* Merges this run's published attribution over the previous catalog at file
|
|
8948
|
+
* granularity. A previous file survives only when the declaring category is
|
|
8949
|
+
* still configured, this run did not publish replacement bytes for that file,
|
|
8950
|
+
* and every recorded destination still survives ownership finalization with
|
|
8951
|
+
* the exact recorded bytes. Preserved overrides/untouched files therefore
|
|
8952
|
+
* keep their provenance instead of reading as removals; pruned destinations,
|
|
8953
|
+
* removed agents, unconfigured categories and edited copies still drop.
|
|
8954
|
+
*/
|
|
8955
|
+
async buildAttributionCatalog(configured, entries) {
|
|
8956
|
+
const previous = this.previousLock?.attributionCatalog;
|
|
8957
|
+
const written = this.skillAttributionPackages;
|
|
8958
|
+
if (!written && !previous) return void 0;
|
|
8959
|
+
const mergedPackages = {};
|
|
8960
|
+
for (const [packageId, record] of Object.entries(previous?.packages ?? {})) {
|
|
8961
|
+
if (!configured.has(record.declaringCategory)) continue;
|
|
8962
|
+
const replacement = written?.[packageId];
|
|
8963
|
+
const retainedFiles = [];
|
|
8964
|
+
for (const file of record.files) {
|
|
8965
|
+
if (replacement?.files.some((candidate) => candidate.name === file.name)) continue;
|
|
8966
|
+
if (await this.attributionFileDestinationsSurvive(file, entries)) retainedFiles.push(file);
|
|
8967
|
+
}
|
|
8968
|
+
if (retainedFiles.length === 0) continue;
|
|
8969
|
+
mergedPackages[packageId] = {
|
|
8970
|
+
...replacement ?? record,
|
|
8971
|
+
files: [...replacement?.files ?? [], ...retainedFiles]
|
|
8972
|
+
};
|
|
8973
|
+
}
|
|
8974
|
+
const merged = buildAttributionCatalog({
|
|
8975
|
+
...mergedPackages,
|
|
8976
|
+
...written ?? {}
|
|
8977
|
+
});
|
|
8978
|
+
if (previous) return merged;
|
|
8979
|
+
return Object.keys(merged.packages).length > 0 ? merged : void 0;
|
|
8980
|
+
}
|
|
8981
|
+
/**
|
|
8982
|
+
* A retained file must describe only bytes that are still installed: every
|
|
8983
|
+
* destination has to survive ownership finalization for the recorded agent
|
|
8984
|
+
* and still hash to its recorded digest.
|
|
8985
|
+
*/
|
|
8986
|
+
async attributionFileDestinationsSurvive(file, entries) {
|
|
8987
|
+
for (const binding of file.destinations) {
|
|
8988
|
+
const entry = entries[binding.path];
|
|
8989
|
+
if (!entry || entry.agent !== binding.agent) return false;
|
|
8990
|
+
if (await readInstalledAttributionDigest(process.cwd(), binding.path) !== binding.sha256) return false;
|
|
8991
|
+
}
|
|
8992
|
+
return true;
|
|
8993
|
+
}
|
|
8994
|
+
/**
|
|
7507
8995
|
* Builds the catalog from files that survived ownership reconciliation.
|
|
7508
8996
|
* Canonical bodies that were kept edited/overridden and native exports whose
|
|
7509
8997
|
* on-disk bytes differ from the deterministic output are omitted rather than
|
|
@@ -7538,7 +9026,7 @@ var SyncService = class {
|
|
|
7538
9026
|
} catch {
|
|
7539
9027
|
continue;
|
|
7540
9028
|
}
|
|
7541
|
-
const digest = sha256(canonicalBytes);
|
|
9029
|
+
const digest = sha256$1(canonicalBytes);
|
|
7542
9030
|
if (digest !== planned.digest) continue;
|
|
7543
9031
|
const nativeBindings = [];
|
|
7544
9032
|
const nativeConflicts = [];
|
|
@@ -7549,7 +9037,7 @@ var SyncService = class {
|
|
|
7549
9037
|
try {
|
|
7550
9038
|
outputBytes = await fs_extra.default.readFile(outputPath);
|
|
7551
9039
|
} catch {}
|
|
7552
|
-
if (outputBytes === null || sha256(outputBytes) !== binding.outputDigest) {
|
|
9040
|
+
if (outputBytes === null || sha256$1(outputBytes) !== binding.outputDigest) {
|
|
7553
9041
|
nativeConflicts.push({
|
|
7554
9042
|
agent: binding.agent,
|
|
7555
9043
|
path: binding.path
|
|
@@ -7699,28 +9187,244 @@ var SyncService = class {
|
|
|
7699
9187
|
async writeSkills(skills, config) {
|
|
7700
9188
|
if (!this.dryRun && this.installScope !== "skills") await this.notifyLegacyAgentFolder();
|
|
7701
9189
|
const agents = await this.resolveTargetAgents(config);
|
|
7702
|
-
await this.skillSyncService.writeSkills(skills, config, agents, this.writer);
|
|
9190
|
+
this.skillAttributionPackages = await this.skillSyncService.writeSkills(skills, config, agents, this.writer);
|
|
7703
9191
|
}
|
|
7704
9192
|
async assembleWorkflows(config) {
|
|
7705
9193
|
const ref = this.resolvedRefs?.workflows.ref || config.workflows_ref || "main";
|
|
7706
|
-
return this.workflowSyncService.assembleWorkflows(config, ref, this.manifestVerifiers.workflows);
|
|
9194
|
+
return this.workflowSyncService.assembleWorkflows(config, ref, this.manifestVerifiers.workflows, this.resolvedRefs?.workflows.commit ?? void 0);
|
|
7707
9195
|
}
|
|
7708
9196
|
async acquireWorkflows(config) {
|
|
7709
9197
|
const ref = this.resolvedRefs?.workflows.ref || config.workflows_ref || "main";
|
|
7710
|
-
return this.workflowSyncService.acquireWorkflows(config, ref, this.manifestVerifiers.workflows);
|
|
9198
|
+
return this.workflowSyncService.acquireWorkflows(config, ref, this.manifestVerifiers.workflows, this.resolvedRefs?.workflows.commit ?? void 0);
|
|
7711
9199
|
}
|
|
7712
9200
|
async writeWorkflows(workflows, config) {
|
|
7713
9201
|
const agents = await this.resolveTargetAgents(config);
|
|
7714
9202
|
this.pendingWorkflowWrites = await this.workflowSyncService.writeWorkflows(workflows, config, agents, this.writer);
|
|
7715
9203
|
}
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
9204
|
+
/**
|
|
9205
|
+
* Acquires every desired specialist before any managed publication. A local
|
|
9206
|
+
* `skills/specialists` directory is the project's own source and is complete
|
|
9207
|
+
* by construction; otherwise the pinned remote source is acquired and its
|
|
9208
|
+
* completeness is reported explicitly.
|
|
9209
|
+
*/
|
|
9210
|
+
async acquireSpecialists(config) {
|
|
7719
9211
|
const localRegistrySource = path.default.join(process.cwd(), "skills/specialists");
|
|
7720
|
-
if (await fs_extra.default.pathExists(localRegistrySource)) return
|
|
9212
|
+
if (await fs_extra.default.pathExists(localRegistrySource)) return {
|
|
9213
|
+
status: "complete",
|
|
9214
|
+
specialists: await this.specialistSyncService.readLocalSpecialists(localRegistrySource),
|
|
9215
|
+
failures: []
|
|
9216
|
+
};
|
|
7721
9217
|
const ref = this.resolvedRefs?.specialists.ref || config.specialists_ref || "main";
|
|
7722
|
-
|
|
7723
|
-
|
|
9218
|
+
return this.specialistSyncService.acquireSpecialists(config, ref, this.manifestVerifiers.specialists, this.resolvedRefs?.specialists.commit ?? void 0);
|
|
9219
|
+
}
|
|
9220
|
+
/**
|
|
9221
|
+
* Publishes the specialists acquired during preflight. Returns whether any
|
|
9222
|
+
* specialist is desired, which is the only evidence that authorizes pruning
|
|
9223
|
+
* previously installed specialist exports.
|
|
9224
|
+
*/
|
|
9225
|
+
async writeSpecialists(acquisition, config) {
|
|
9226
|
+
const agents = await this.resolveTargetAgents(config);
|
|
9227
|
+
if (agents.length === 0) return true;
|
|
9228
|
+
await this.specialistSyncService.writeSpecialists(process.cwd(), agents, acquisition.specialists, this.writer);
|
|
9229
|
+
return acquisition.specialists.length > 0;
|
|
9230
|
+
}
|
|
9231
|
+
/** The typed throttle failure, if this operation latched one. */
|
|
9232
|
+
get rateLimitFailure() {
|
|
9233
|
+
return this.githubService.rateLimitFailure;
|
|
9234
|
+
}
|
|
9235
|
+
/**
|
|
9236
|
+
* Builds the attribution plan for the acquired (unpublished) skills and
|
|
9237
|
+
* compares it with the qualified state of the current installation. Nothing
|
|
9238
|
+
* is written: the command layer owns consent and publication.
|
|
9239
|
+
*
|
|
9240
|
+
* Destination bindings are derived from the same agent layout the ownership
|
|
9241
|
+
* writer will use, so the collector reads the bytes a previous run would
|
|
9242
|
+
* have installed at exactly those paths. The candidate digest recorded here
|
|
9243
|
+
* is the source digest; publication re-derives the installed digest from the
|
|
9244
|
+
* actual written bytes.
|
|
9245
|
+
*/
|
|
9246
|
+
async planAttribution(skills, config) {
|
|
9247
|
+
const agents = await this.resolveTargetAgents(config);
|
|
9248
|
+
const overrides = config.custom_overrides ?? [];
|
|
9249
|
+
const registry = config.registry ?? "";
|
|
9250
|
+
const previousCatalog = this.previousLock?.attributionCatalog;
|
|
9251
|
+
const planned = {};
|
|
9252
|
+
/** `<packageId>\0<fileName>` -> effective destination paths. */
|
|
9253
|
+
const publishedPaths = /* @__PURE__ */ new Set();
|
|
9254
|
+
const agentDir = (agentId, skill) => {
|
|
9255
|
+
const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
|
|
9256
|
+
if (!basePath) return null;
|
|
9257
|
+
return agentId === Agent.Kiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
|
|
9258
|
+
};
|
|
9259
|
+
for (const skill of skills) {
|
|
9260
|
+
const origin = skill.origin;
|
|
9261
|
+
const packageId = `${skill.category}/${skill.skill}`;
|
|
9262
|
+
const files = [];
|
|
9263
|
+
for (const file of skill.files) {
|
|
9264
|
+
if (!file.attribution) continue;
|
|
9265
|
+
const destinations = [];
|
|
9266
|
+
for (const agentId of agents) {
|
|
9267
|
+
const dir = agentDir(agentId, skill);
|
|
9268
|
+
if (!dir) continue;
|
|
9269
|
+
const relative = toPosixRel(process.cwd(), path.default.join(dir, file.name));
|
|
9270
|
+
if (isOverriddenRel(relative, overrides)) continue;
|
|
9271
|
+
publishedPaths.add(`${packageId}\0${file.name}\0${relative}`);
|
|
9272
|
+
destinations.push({
|
|
9273
|
+
agent: agentId,
|
|
9274
|
+
path: relative,
|
|
9275
|
+
sha256: file.attribution.sha256
|
|
9276
|
+
});
|
|
9277
|
+
}
|
|
9278
|
+
if (destinations.length === 0) continue;
|
|
9279
|
+
files.push({
|
|
9280
|
+
...file.attribution,
|
|
9281
|
+
name: file.name,
|
|
9282
|
+
destinations
|
|
9283
|
+
});
|
|
9284
|
+
}
|
|
9285
|
+
const previous = previousCatalog?.packages[packageId];
|
|
9286
|
+
const preserved = this.preservedAttributionFiles(packageId, previous, publishedPaths, overrides, config.prune !== false);
|
|
9287
|
+
if (files.length === 0) {
|
|
9288
|
+
if (previous && preserved.length === previous.files.length) planned[packageId] = {
|
|
9289
|
+
...previous,
|
|
9290
|
+
files: preserved
|
|
9291
|
+
};
|
|
9292
|
+
continue;
|
|
9293
|
+
}
|
|
9294
|
+
if (!origin?.commit || !/^[0-9a-f]{40}$/.test(origin.commit)) continue;
|
|
9295
|
+
for (const file of preserved) if (!files.some((candidate) => candidate.name === file.name)) files.push(file);
|
|
9296
|
+
planned[packageId] = {
|
|
9297
|
+
registry,
|
|
9298
|
+
declaringCategory: origin.category,
|
|
9299
|
+
requestedRef: origin.ref,
|
|
9300
|
+
resolvedCommit: origin.commit,
|
|
9301
|
+
files
|
|
9302
|
+
};
|
|
9303
|
+
}
|
|
9304
|
+
for (const [packageId, record] of Object.entries(previousCatalog?.packages ?? {})) {
|
|
9305
|
+
if (planned[packageId]) continue;
|
|
9306
|
+
const preserved = this.preservedAttributionFiles(packageId, record, publishedPaths, overrides, config.prune !== false);
|
|
9307
|
+
if (preserved.length !== record.files.length) continue;
|
|
9308
|
+
planned[packageId] = {
|
|
9309
|
+
...record,
|
|
9310
|
+
files: preserved
|
|
9311
|
+
};
|
|
9312
|
+
}
|
|
9313
|
+
const installedOnly = await this.discoverInstalledAttributionVariants(skills, planned, previousCatalog?.packages ?? {}, agents, overrides, publishedPaths);
|
|
9314
|
+
const installed = await collectInstalledAttributionDigests(process.cwd(), this.mergeAttributionDestinations(planned, previousCatalog?.packages ?? {}));
|
|
9315
|
+
const hasInstalledBytes = installedOnly.some((variant) => variant.sha256 !== null) || Object.values(installed).some((perFile) => Object.values(perFile).some((bindings) => bindings.some((binding) => binding.sha256 !== null)));
|
|
9316
|
+
return {
|
|
9317
|
+
planned,
|
|
9318
|
+
installed,
|
|
9319
|
+
installedOnly,
|
|
9320
|
+
comparison: compareAttribution({
|
|
9321
|
+
planned,
|
|
9322
|
+
previousCatalog,
|
|
9323
|
+
hasExistingInstallation: Object.keys(this.previousLock?.entries ?? {}).length > 0 || hasInstalledBytes,
|
|
9324
|
+
installed
|
|
9325
|
+
})
|
|
9326
|
+
};
|
|
9327
|
+
}
|
|
9328
|
+
/**
|
|
9329
|
+
* Previous records whose bytes this run will not touch: every recorded
|
|
9330
|
+
* destination is either a configured override or (when pruning is disabled)
|
|
9331
|
+
* a destination the ownership finalizer keeps. The record is returned
|
|
9332
|
+
* verbatim so the comparison sees preserved bytes, not a removal.
|
|
9333
|
+
*/
|
|
9334
|
+
preservedAttributionFiles(packageId, previous, publishedPaths, overrides, prune) {
|
|
9335
|
+
if (!previous) return [];
|
|
9336
|
+
const preserved = [];
|
|
9337
|
+
for (const file of previous.files) if (file.destinations.every((destination) => {
|
|
9338
|
+
if (publishedPaths.has(`${packageId}\0${file.name}\0${destination.path}`)) return false;
|
|
9339
|
+
return isOverriddenRel(destination.path, overrides) || !prune;
|
|
9340
|
+
})) preserved.push({ ...file });
|
|
9341
|
+
return preserved;
|
|
9342
|
+
}
|
|
9343
|
+
/**
|
|
9344
|
+
* Bounded discovery of attribution-family files that exist in installed
|
|
9345
|
+
* package directories but appear in neither the candidate nor the catalog.
|
|
9346
|
+
* Only effective package directories are read — candidate packages plus
|
|
9347
|
+
* directories referenced by previous destinations — only top-level
|
|
9348
|
+
* LICENSE/NOTICE variants are considered, and the actual installed digest is
|
|
9349
|
+
* reported with no invented origin.
|
|
9350
|
+
*/
|
|
9351
|
+
async discoverInstalledAttributionVariants(skills, planned, previous, agents, overrides, publishedPaths) {
|
|
9352
|
+
const knownPaths = /* @__PURE__ */ new Set();
|
|
9353
|
+
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);
|
|
9354
|
+
const scan = /* @__PURE__ */ new Map();
|
|
9355
|
+
for (const skill of skills) {
|
|
9356
|
+
const packageId = `${skill.category}/${skill.skill}`;
|
|
9357
|
+
for (const agentId of agents) {
|
|
9358
|
+
const basePath = SUPPORTED_AGENTS.find((agent) => agent.id === agentId)?.path;
|
|
9359
|
+
if (!basePath) continue;
|
|
9360
|
+
const dir = agentId === Agent.Kiro ? path.default.join(basePath, `${skill.category}-${skill.skill}`) : path.default.join(basePath, skill.category, skill.skill);
|
|
9361
|
+
scan.set(dir, {
|
|
9362
|
+
packageId,
|
|
9363
|
+
agent: agentId,
|
|
9364
|
+
dir
|
|
9365
|
+
});
|
|
9366
|
+
}
|
|
9367
|
+
}
|
|
9368
|
+
for (const [packageId, record] of Object.entries(previous)) for (const file of record.files) for (const destination of file.destinations) {
|
|
9369
|
+
const dir = path.default.dirname(path.default.join(process.cwd(), destination.path));
|
|
9370
|
+
scan.set(dir, {
|
|
9371
|
+
packageId,
|
|
9372
|
+
agent: destination.agent,
|
|
9373
|
+
dir
|
|
9374
|
+
});
|
|
9375
|
+
}
|
|
9376
|
+
const variants = /* @__PURE__ */ new Map();
|
|
9377
|
+
for (const { packageId, agent, dir } of scan.values()) {
|
|
9378
|
+
let entries;
|
|
9379
|
+
try {
|
|
9380
|
+
entries = await fs_extra.default.readdir(dir);
|
|
9381
|
+
} catch {
|
|
9382
|
+
continue;
|
|
9383
|
+
}
|
|
9384
|
+
for (const entry of entries) {
|
|
9385
|
+
if (!ATTRIBUTION_FILE_NAME_RE.test(entry)) continue;
|
|
9386
|
+
const relative = toPosixRel(process.cwd(), path.default.join(dir, entry));
|
|
9387
|
+
if (knownPaths.has(relative) || publishedPaths.has(`${packageId}\0${entry}\0${relative}`) || isOverriddenRel(relative, overrides)) continue;
|
|
9388
|
+
variants.set(relative, {
|
|
9389
|
+
packageId,
|
|
9390
|
+
name: entry,
|
|
9391
|
+
agent,
|
|
9392
|
+
path: relative,
|
|
9393
|
+
sha256: await readInstalledAttributionDigest(process.cwd(), relative)
|
|
9394
|
+
});
|
|
9395
|
+
}
|
|
9396
|
+
}
|
|
9397
|
+
return [...variants.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
9398
|
+
}
|
|
9399
|
+
/**
|
|
9400
|
+
* Unions the planned and previously recorded destination sets per package
|
|
9401
|
+
* and filename so the installed-state read covers both sides of the
|
|
9402
|
+
* comparison. Extra bindings never qualify a record; they only let the
|
|
9403
|
+
* comparison see the real bytes at every path it may judge.
|
|
9404
|
+
*/
|
|
9405
|
+
mergeAttributionDestinations(planned, previous) {
|
|
9406
|
+
const merged = { ...planned };
|
|
9407
|
+
for (const [packageId, record] of Object.entries(previous)) {
|
|
9408
|
+
const base = merged[packageId];
|
|
9409
|
+
if (!base) {
|
|
9410
|
+
merged[packageId] = record;
|
|
9411
|
+
continue;
|
|
9412
|
+
}
|
|
9413
|
+
const files = base.files.map((file) => ({ ...file }));
|
|
9414
|
+
for (const priorFile of record.files) {
|
|
9415
|
+
const existing = files.find((file) => file.name === priorFile.name);
|
|
9416
|
+
if (!existing) {
|
|
9417
|
+
files.push({ ...priorFile });
|
|
9418
|
+
continue;
|
|
9419
|
+
}
|
|
9420
|
+
for (const destination of priorFile.destinations) if (!existing.destinations.some((current) => current.agent === destination.agent && current.path === destination.path)) existing.destinations.push(destination);
|
|
9421
|
+
}
|
|
9422
|
+
merged[packageId] = {
|
|
9423
|
+
...base,
|
|
9424
|
+
files
|
|
9425
|
+
};
|
|
9426
|
+
}
|
|
9427
|
+
return merged;
|
|
7724
9428
|
}
|
|
7725
9429
|
async applyIndices(config, targetAgents, localMetadata, desiredSkillIds, options) {
|
|
7726
9430
|
const agents = targetAgents || await this.resolveTargetAgents(config);
|
|
@@ -7795,22 +9499,25 @@ var SyncService = class {
|
|
|
7795
9499
|
for (const [cat, catConfig] of Object.entries(config.skills)) {
|
|
7796
9500
|
const remoteMetaCat = remoteMeta.categories?.[cat];
|
|
7797
9501
|
if (!remoteMetaCat?.version) continue;
|
|
7798
|
-
const
|
|
7799
|
-
|
|
9502
|
+
const prefix = remoteMetaCat.tag_prefix || "";
|
|
9503
|
+
const latestRef = `${prefix}${remoteMetaCat.version}`;
|
|
9504
|
+
if (catConfig.ref === void 0 || isAutomaticUpgrade(catConfig.ref, latestRef, prefix)) candidates.push({
|
|
7800
9505
|
key: cat,
|
|
7801
9506
|
ref: latestRef
|
|
7802
9507
|
});
|
|
7803
9508
|
}
|
|
7804
9509
|
if (remoteMeta.releases?.workflows?.version) {
|
|
7805
|
-
const
|
|
7806
|
-
|
|
9510
|
+
const prefix = remoteMeta.releases.workflows.tag_prefix || "";
|
|
9511
|
+
const latestWorkflowsRef = `${prefix}${remoteMeta.releases.workflows.version}`;
|
|
9512
|
+
if (config.workflows_ref && isAutomaticUpgrade(config.workflows_ref, latestWorkflowsRef, prefix)) candidates.push({
|
|
7807
9513
|
key: "workflows",
|
|
7808
9514
|
ref: latestWorkflowsRef
|
|
7809
9515
|
});
|
|
7810
9516
|
}
|
|
7811
9517
|
if (remoteMeta.categories?.specialists?.version) {
|
|
7812
|
-
const
|
|
7813
|
-
|
|
9518
|
+
const prefix = remoteMeta.categories.specialists.tag_prefix || "";
|
|
9519
|
+
const latestSpecialistsRef = `${prefix}${remoteMeta.categories.specialists.version}`;
|
|
9520
|
+
if (config.specialists_ref && isAutomaticUpgrade(config.specialists_ref, latestSpecialistsRef, prefix)) candidates.push({
|
|
7814
9521
|
key: "specialists",
|
|
7815
9522
|
ref: latestSpecialistsRef
|
|
7816
9523
|
});
|
|
@@ -8982,9 +10689,25 @@ var RetrospectiveCommand = class {
|
|
|
8982
10689
|
};
|
|
8983
10690
|
//#endregion
|
|
8984
10691
|
//#region src/commands/sync.ts
|
|
10692
|
+
/** A classified command failure; `cancelled` marks a declined required consent. */
|
|
10693
|
+
var SyncFailureError = class extends Error {
|
|
10694
|
+
failure;
|
|
10695
|
+
cancelled;
|
|
10696
|
+
constructor(failure, cancelled = false) {
|
|
10697
|
+
super(failure.message);
|
|
10698
|
+
this.failure = failure;
|
|
10699
|
+
this.cancelled = cancelled;
|
|
10700
|
+
this.name = "SyncFailureError";
|
|
10701
|
+
}
|
|
10702
|
+
};
|
|
8985
10703
|
/**
|
|
8986
10704
|
* Command for synchronizing skills and workflows from the remote registry to the local workspace.
|
|
8987
|
-
*
|
|
10705
|
+
*
|
|
10706
|
+
* Publication order is preflight-first: configuration and pins are reconciled
|
|
10707
|
+
* in memory, every selected source is resolved and acquired, and the
|
|
10708
|
+
* attribution plan is computed before any managed file, lock entry, or saved
|
|
10709
|
+
* ref is written. A preflight failure therefore preserves the installed bytes,
|
|
10710
|
+
* the lock, and the saved refs.
|
|
8988
10711
|
*/
|
|
8989
10712
|
var SyncCommand = class {
|
|
8990
10713
|
configService;
|
|
@@ -9000,109 +10723,385 @@ var SyncCommand = class {
|
|
|
9000
10723
|
this.hookService = hookService || new HookService();
|
|
9001
10724
|
}
|
|
9002
10725
|
/**
|
|
9003
|
-
* Executes the synchronization flow.
|
|
9004
|
-
*
|
|
10726
|
+
* Executes the synchronization flow and returns a discriminated outcome.
|
|
10727
|
+
* The CLI boundary owns `process.exitCode`; services never exit the process.
|
|
9005
10728
|
*/
|
|
9006
10729
|
async run(options = {}) {
|
|
10730
|
+
const restoreOutput = options.json ? this.redirectProgressToStderr() : () => {};
|
|
9007
10731
|
try {
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
9015
|
-
|
|
9016
|
-
|
|
9017
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
9020
|
-
|
|
9021
|
-
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
|
|
9025
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
9044
|
-
|
|
9045
|
-
|
|
9046
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
9055
|
-
|
|
9056
|
-
} else console.log(picocolors.default.cyan("ℹ️ Skipping version updates, staying on pinned refs."));
|
|
10732
|
+
return await this.execute(options);
|
|
10733
|
+
} catch (error) {
|
|
10734
|
+
const failure = this.classify(error);
|
|
10735
|
+
const cancelled = error instanceof SyncFailureError && error.cancelled;
|
|
10736
|
+
this.reportFailure(failure, options, cancelled);
|
|
10737
|
+
return cancelled ? {
|
|
10738
|
+
status: "cancelled",
|
|
10739
|
+
failure
|
|
10740
|
+
} : {
|
|
10741
|
+
status: "failure",
|
|
10742
|
+
failure
|
|
10743
|
+
};
|
|
10744
|
+
} finally {
|
|
10745
|
+
restoreOutput();
|
|
10746
|
+
}
|
|
10747
|
+
}
|
|
10748
|
+
async execute(options) {
|
|
10749
|
+
const snapshot = await this.configService.readSnapshot();
|
|
10750
|
+
if (!snapshot) throw new SyncFailureError({
|
|
10751
|
+
code: "config-invalid",
|
|
10752
|
+
message: "❌ Error: .skillsrc not found. Run `init` first."
|
|
10753
|
+
});
|
|
10754
|
+
const config = structuredClone(snapshot.config);
|
|
10755
|
+
const projectDeps = await this.detectionService.getProjectDeps();
|
|
10756
|
+
const skillsChanged = await this.syncService.reconcileConfig(config, projectDeps);
|
|
10757
|
+
const workflowsChanged = await this.syncService.reconcileWorkflows(config);
|
|
10758
|
+
let configChanged = skillsChanged || workflowsChanged;
|
|
10759
|
+
const resolvedRefs = await this.syncService.resolvePins(config);
|
|
10760
|
+
if (Object.entries(resolvedRefs.newPins).length > 0) {
|
|
10761
|
+
const pinnedList = [resolvedRefs.newPins.workflows_ref, resolvedRefs.newPins.specialists_ref].filter(Boolean).join(", ");
|
|
10762
|
+
if (!options.dryRun) {
|
|
10763
|
+
if (resolvedRefs.newPins.workflows_ref) config.workflows_ref = resolvedRefs.newPins.workflows_ref;
|
|
10764
|
+
if (resolvedRefs.newPins.specialists_ref) config.specialists_ref = resolvedRefs.newPins.specialists_ref;
|
|
10765
|
+
configChanged = true;
|
|
10766
|
+
console.log(picocolors.default.green(`📌 Pinned ${pinnedList} in .skillsrc`));
|
|
10767
|
+
} else console.log(picocolors.default.gray(` (dry-run) would pin ${pinnedList} in .skillsrc`));
|
|
10768
|
+
}
|
|
10769
|
+
for (const warning of resolvedRefs.warnings) console.log(picocolors.default.yellow(warning));
|
|
10770
|
+
const updates = await this.syncService.checkForUpdates(config);
|
|
10771
|
+
let refsChanged = false;
|
|
10772
|
+
if (updates && Object.keys(updates).length > 0) {
|
|
10773
|
+
if (options.dryRun) {
|
|
10774
|
+
if (!options.json) for (const [cat, ref] of Object.entries(updates)) console.log(picocolors.default.gray(` (dry-run) would offer: ${cat} -> ${ref}`));
|
|
10775
|
+
} else {
|
|
10776
|
+
console.log(picocolors.default.yellow("\n🚀 New skill versions detected:"));
|
|
10777
|
+
for (const [cat, ref] of Object.entries(updates)) {
|
|
10778
|
+
const currentRef = cat === "workflows" ? config.workflows_ref : cat === "specialists" ? config.specialists_ref : config.skills[cat]?.ref;
|
|
10779
|
+
console.log(picocolors.default.gray(` - ${cat}: ${currentRef} -> ${ref}`));
|
|
9057
10780
|
}
|
|
10781
|
+
let update = options.yes;
|
|
10782
|
+
if (update === void 0) {
|
|
10783
|
+
if (options.json || !process.stdin.isTTY) {
|
|
10784
|
+
console.log(picocolors.default.cyan("ℹ️ Non-interactive environment detected. Skipping version updates. Use --yes to auto-confirm."));
|
|
10785
|
+
update = false;
|
|
10786
|
+
} else update = (await inquirer.default.prompt([{
|
|
10787
|
+
type: "confirm",
|
|
10788
|
+
name: "update",
|
|
10789
|
+
message: "Do you want to update .skillsrc with these versions?",
|
|
10790
|
+
default: true
|
|
10791
|
+
}])).update;
|
|
10792
|
+
}
|
|
10793
|
+
if (update) {
|
|
10794
|
+
for (const [cat, ref] of Object.entries(updates)) if (cat === "workflows") {
|
|
10795
|
+
config.workflows_ref = ref;
|
|
10796
|
+
refsChanged = true;
|
|
10797
|
+
} else if (cat === "specialists") {
|
|
10798
|
+
config.specialists_ref = ref;
|
|
10799
|
+
refsChanged = true;
|
|
10800
|
+
} else if (config.skills[cat]) {
|
|
10801
|
+
config.skills[cat].ref = ref;
|
|
10802
|
+
refsChanged = true;
|
|
10803
|
+
}
|
|
10804
|
+
configChanged = true;
|
|
10805
|
+
console.log(picocolors.default.green("✅ .skillsrc updated."));
|
|
10806
|
+
} else console.log(picocolors.default.cyan("ℹ️ Skipping version updates, staying on pinned refs."));
|
|
9058
10807
|
}
|
|
9059
|
-
|
|
9060
|
-
|
|
9061
|
-
|
|
9062
|
-
|
|
9063
|
-
|
|
9064
|
-
|
|
9065
|
-
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9075
|
-
|
|
9076
|
-
|
|
9077
|
-
|
|
9078
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
10808
|
+
}
|
|
10809
|
+
if (refsChanged) await this.syncService.resolvePins(config);
|
|
10810
|
+
if (!options.json) console.log(picocolors.default.cyan(`🚀 Syncing skills from ${config.registry}...`));
|
|
10811
|
+
await this.syncService.beginInstall(config, {
|
|
10812
|
+
dryRun: Boolean(options.dryRun),
|
|
10813
|
+
force: options.force ?? []
|
|
10814
|
+
});
|
|
10815
|
+
const skillCategories = Object.keys(config.skills).filter((category) => category !== "specialists");
|
|
10816
|
+
const skills = await this.syncService.assembleSkills(skillCategories, config);
|
|
10817
|
+
const workflowAcquisition = await this.syncService.acquireWorkflows(config);
|
|
10818
|
+
if (workflowAcquisition.status === "incomplete") throw new SyncFailureError({
|
|
10819
|
+
code: "sync-failed",
|
|
10820
|
+
message: "Workflow acquisition incomplete; existing files, lock and saved refs were preserved.",
|
|
10821
|
+
details: { workflowFailures: workflowAcquisition.failures }
|
|
10822
|
+
});
|
|
10823
|
+
const workflows = workflowAcquisition.workflows;
|
|
10824
|
+
const specialistAcquisition = await this.syncService.acquireSpecialists(config);
|
|
10825
|
+
if (specialistAcquisition.status === "incomplete") throw new SyncFailureError({
|
|
10826
|
+
code: "sync-failed",
|
|
10827
|
+
message: "Specialist acquisition incomplete; existing files, lock and saved refs were preserved.",
|
|
10828
|
+
details: { specialistFailures: specialistAcquisition.failures }
|
|
10829
|
+
});
|
|
10830
|
+
const latched = this.syncService.rateLimitFailure;
|
|
10831
|
+
if (latched) throw latched;
|
|
10832
|
+
let attribution = null;
|
|
10833
|
+
let attributionRequiresConsent = false;
|
|
10834
|
+
if (!options.dryRun) {
|
|
10835
|
+
const plan = await this.syncService.planAttribution(skills, config);
|
|
10836
|
+
attribution = plan.comparison;
|
|
10837
|
+
attributionRequiresConsent = plan.comparison.requiresConsent || plan.installedOnly.length > 0;
|
|
10838
|
+
this.printAttributionPreview(plan);
|
|
10839
|
+
await this.ensureAttributionConsent(plan, options);
|
|
10840
|
+
}
|
|
10841
|
+
let publishedSnapshot = snapshot;
|
|
10842
|
+
if (!options.dryRun) publishedSnapshot = configChanged ? await this.configService.saveReviewedConfig(snapshot, config) : await this.assertSnapshotUnchanged(snapshot);
|
|
10843
|
+
await this.syncService.writeSkills(skills, config);
|
|
10844
|
+
await this.syncService.writeWorkflows(workflows, config);
|
|
10845
|
+
const specialistsOk = await this.syncService.writeSpecialists(specialistAcquisition, config);
|
|
10846
|
+
const plan = await this.syncService.completeInstall(config, {
|
|
10847
|
+
skills,
|
|
10848
|
+
workflows,
|
|
10849
|
+
specialistsOk,
|
|
10850
|
+
refreshIndices: true,
|
|
10851
|
+
completedSkillCategories: skillCategories,
|
|
10852
|
+
workflowAcquisition
|
|
10853
|
+
});
|
|
10854
|
+
if (options.dryRun) {
|
|
10855
|
+
if (options.json) this.writeJson({
|
|
10856
|
+
schema_version: 1,
|
|
10857
|
+
kind: "sync.plan",
|
|
10858
|
+
data: plan
|
|
9082
10859
|
});
|
|
9083
|
-
|
|
9084
|
-
|
|
9085
|
-
|
|
9086
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
9089
|
-
|
|
9090
|
-
|
|
9091
|
-
|
|
9092
|
-
|
|
9093
|
-
|
|
10860
|
+
else {
|
|
10861
|
+
console.log(picocolors.default.cyan("🔍 Dry run — no files written"));
|
|
10862
|
+
this.printPlanCounts(plan);
|
|
10863
|
+
if (options.verbose) this.printVerbosePlan(plan);
|
|
10864
|
+
console.log(picocolors.default.gray(" (dry-run) skipped: MCP registration, hooks, capability notes"));
|
|
10865
|
+
}
|
|
10866
|
+
return {
|
|
10867
|
+
status: "dry-run",
|
|
10868
|
+
plan
|
|
10869
|
+
};
|
|
10870
|
+
}
|
|
10871
|
+
this.printSummary(plan);
|
|
10872
|
+
await this.syncService.discloseCapabilities(config);
|
|
10873
|
+
console.log(picocolors.default.green("✅ All skills synced successfully!"));
|
|
10874
|
+
await this.runMcpPhase(config, options, publishedSnapshot);
|
|
10875
|
+
await this.runHookPhase(config, options);
|
|
10876
|
+
if (options.json) this.writeJson({
|
|
10877
|
+
schema_version: 1,
|
|
10878
|
+
kind: "sync.result",
|
|
10879
|
+
data: {
|
|
10880
|
+
status: "success",
|
|
10881
|
+
added: plan.added.length,
|
|
10882
|
+
updated: plan.updated.length,
|
|
10883
|
+
unchanged: plan.unchanged.length,
|
|
10884
|
+
kept: plan.kept.length,
|
|
10885
|
+
unknown: plan.unknown.length,
|
|
10886
|
+
pruned: plan.pruned.length,
|
|
10887
|
+
keptOrphans: plan.keptOrphans.length,
|
|
10888
|
+
attribution: attribution ? {
|
|
10889
|
+
required: attributionRequiresConsent,
|
|
10890
|
+
changes: attribution.packages.filter((change) => change.requiresConsent).length
|
|
10891
|
+
} : null
|
|
10892
|
+
}
|
|
10893
|
+
});
|
|
10894
|
+
return {
|
|
10895
|
+
status: "success",
|
|
10896
|
+
plan
|
|
10897
|
+
};
|
|
10898
|
+
}
|
|
10899
|
+
/**
|
|
10900
|
+
* Confirms the configuration on disk is still the snapshot read at preflight
|
|
10901
|
+
* without writing anything. Runs immediately before the first managed write.
|
|
10902
|
+
*/
|
|
10903
|
+
async assertSnapshotUnchanged(snapshot) {
|
|
10904
|
+
const current = await this.configService.readSnapshot();
|
|
10905
|
+
if (!current || current.sourcePath !== snapshot.sourcePath || current.fingerprint !== snapshot.fingerprint) throw new SyncFailureError({
|
|
10906
|
+
code: "config-invalid",
|
|
10907
|
+
message: "❌ .skillsrc changed after preflight; nothing was published. Re-run `ags sync`."
|
|
10908
|
+
});
|
|
10909
|
+
return snapshot;
|
|
10910
|
+
}
|
|
10911
|
+
/**
|
|
10912
|
+
* In JSON mode stdout carries exactly one envelope. Every progress line,
|
|
10913
|
+
* including service-level logging, is redirected to stderr for the run.
|
|
10914
|
+
*/
|
|
10915
|
+
redirectProgressToStderr() {
|
|
10916
|
+
const originalLog = console.log;
|
|
10917
|
+
const originalInfo = console.info;
|
|
10918
|
+
console.log = (...args) => console.error(...args);
|
|
10919
|
+
console.info = (...args) => console.error(...args);
|
|
10920
|
+
return () => {
|
|
10921
|
+
console.log = originalLog;
|
|
10922
|
+
console.info = originalInfo;
|
|
10923
|
+
};
|
|
10924
|
+
}
|
|
10925
|
+
writeJson(value) {
|
|
10926
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
10927
|
+
}
|
|
10928
|
+
/** Maps a thrown value to a stable, caller-visible failure contract. */
|
|
10929
|
+
classify(error) {
|
|
10930
|
+
if (error instanceof SyncFailureError) return error.failure;
|
|
10931
|
+
const latched = this.syncService.rateLimitFailure;
|
|
10932
|
+
if (latched) return this.githubError(latched);
|
|
10933
|
+
if (error instanceof GithubRequestError) return this.githubError(error);
|
|
10934
|
+
if (error instanceof ConfigValidationError) return {
|
|
10935
|
+
code: "config-invalid",
|
|
10936
|
+
message: `❌ Invalid .skillsrc: ${error.message}`,
|
|
10937
|
+
details: {
|
|
10938
|
+
configPath: error.configPath,
|
|
10939
|
+
logicalPath: error.logicalPath
|
|
10940
|
+
}
|
|
10941
|
+
};
|
|
10942
|
+
if (error instanceof IntegrityError) return {
|
|
10943
|
+
code: "integrity",
|
|
10944
|
+
message: error.message
|
|
10945
|
+
};
|
|
10946
|
+
if (error instanceof Error) return {
|
|
10947
|
+
code: "sync-failed",
|
|
10948
|
+
message: error.message
|
|
10949
|
+
};
|
|
10950
|
+
return {
|
|
10951
|
+
code: "sync-failed",
|
|
10952
|
+
message: String(error)
|
|
10953
|
+
};
|
|
10954
|
+
}
|
|
10955
|
+
githubError(error) {
|
|
10956
|
+
const auth = error.authenticated ? "a GitHub token was sent" : "no GitHub token was sent";
|
|
10957
|
+
const code = error.kind === "rate-limited" || error.kind === "access-denied" ? error.kind : error.kind === "integrity" ? "integrity" : "sync-failed";
|
|
10958
|
+
let hint;
|
|
10959
|
+
if (error.kind === "rate-limited") {
|
|
10960
|
+
const timing = error.resetAt ? ` It resets at ${error.resetAt}.` : error.retryAfterSeconds !== void 0 ? ` Retry after ${error.retryAfterSeconds}s.` : "";
|
|
10961
|
+
hint = `GitHub ${error.rateLimitKind ?? "primary"} rate limit reached; no further requests were scheduled.${timing} ${auth}; set GITHUB_TOKEN for a higher limit.`;
|
|
10962
|
+
} else if (error.kind === "access-denied") hint = `GitHub denied access to ${error.endpoint}. ${auth}; verify repository access or set GITHUB_TOKEN.`;
|
|
10963
|
+
else if (error.kind === "integrity") hint = "A downloaded source failed integrity verification; nothing was published.";
|
|
10964
|
+
else hint = `GitHub request failed at ${error.endpoint}. ${auth}; retry when the network or the reference is available.`;
|
|
10965
|
+
return {
|
|
10966
|
+
code,
|
|
10967
|
+
message: error.message,
|
|
10968
|
+
hint,
|
|
10969
|
+
github: error.toJSON()
|
|
10970
|
+
};
|
|
10971
|
+
}
|
|
10972
|
+
reportFailure(failure, options, cancelled) {
|
|
10973
|
+
if (options.json) {
|
|
10974
|
+
this.writeJson({
|
|
10975
|
+
schema_version: 1,
|
|
10976
|
+
kind: "sync.error",
|
|
10977
|
+
data: {
|
|
10978
|
+
code: failure.code,
|
|
10979
|
+
message: failure.message,
|
|
10980
|
+
...failure.hint ? { hint: failure.hint } : {},
|
|
10981
|
+
...failure.github ? { github: failure.github } : {},
|
|
10982
|
+
...failure.details ? { details: failure.details } : {},
|
|
10983
|
+
...cancelled ? { cancelled: true } : {}
|
|
9094
10984
|
}
|
|
9095
|
-
|
|
10985
|
+
});
|
|
10986
|
+
return;
|
|
10987
|
+
}
|
|
10988
|
+
console.error(picocolors.default.red("❌ Sync failed:"), failure.message);
|
|
10989
|
+
if (failure.hint) console.error(picocolors.default.yellow(` ${failure.hint}`));
|
|
10990
|
+
const acquisitionFailures = failure.details?.workflowFailures ?? failure.details?.specialistFailures;
|
|
10991
|
+
if (Array.isArray(acquisitionFailures)) for (const item of acquisitionFailures) console.error(picocolors.default.red(` ❌ ${String(item.path)} — ${String(item.reason)}`));
|
|
10992
|
+
}
|
|
10993
|
+
/**
|
|
10994
|
+
* Discloses a first installation and previews every transition with both
|
|
10995
|
+
* origins, the installed digest, explicitly unknown old origins, and
|
|
10996
|
+
* installed-only attribution files that no candidate/catalog record covers.
|
|
10997
|
+
*/
|
|
10998
|
+
printAttributionPreview(plan) {
|
|
10999
|
+
const { comparison, installed, installedOnly } = plan;
|
|
11000
|
+
if (comparison.firstInstall && installedOnly.length === 0) {
|
|
11001
|
+
for (const record of comparison.disclosures) {
|
|
11002
|
+
const files = record.files.map((file) => `${file.name} (${file.basis}, ${file.resolvedCommit.slice(0, 12)})`).join(", ");
|
|
11003
|
+
console.error(picocolors.default.gray(` ℹ️ First install — attribution disclosed from ${record.registry}@${record.requestedRef}: ${files}`));
|
|
9096
11004
|
}
|
|
9097
|
-
|
|
9098
|
-
await this.syncService.discloseCapabilities(config);
|
|
9099
|
-
if (workflowAcquisition.status !== "incomplete") console.log(picocolors.default.green("✅ All skills synced successfully!"));
|
|
9100
|
-
await this.runMcpPhase(config, options);
|
|
9101
|
-
await this.runHookPhase(config, options);
|
|
9102
|
-
} catch (error) {
|
|
9103
|
-
if (error instanceof Error) console.error(picocolors.default.red("❌ Sync failed:"), error.message);
|
|
9104
|
-
else console.error(picocolors.default.red("❌ Sync failed:"), String(error));
|
|
11005
|
+
return;
|
|
9105
11006
|
}
|
|
11007
|
+
const relevant = comparison.packages.filter((change) => change.status !== "unchanged" || change.requiresConsent || change.files.some((file) => file.previous === null));
|
|
11008
|
+
if (relevant.length === 0 && installedOnly.length === 0) return;
|
|
11009
|
+
console.error(picocolors.default.yellow("\n⚠️ Attribution changes require consent:"));
|
|
11010
|
+
for (const change of relevant) {
|
|
11011
|
+
console.error(picocolors.default.yellow(` ${change.status} ${change.packageId} (${change.reason})`));
|
|
11012
|
+
for (const file of change.files.slice(0, 12)) {
|
|
11013
|
+
const installedNow = installed[change.packageId]?.[file.name] ?? [];
|
|
11014
|
+
const installedDigest = installedNow.find((binding) => binding.sha256 !== null)?.sha256 ?? null;
|
|
11015
|
+
console.error(picocolors.default.gray(` ${file.status} ${file.name}`));
|
|
11016
|
+
console.error(picocolors.default.gray(` old: ${this.describeAttributionOrigin(file.previous, file.previousSha256 ?? installedDigest, "old")}`));
|
|
11017
|
+
console.error(picocolors.default.gray(` new: ${this.describeAttributionOrigin(file.planned, file.plannedSha256, "new")}`));
|
|
11018
|
+
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("; ")}`));
|
|
11019
|
+
}
|
|
11020
|
+
if (change.files.length > 12) console.error(picocolors.default.gray(` … +${change.files.length - 12} more files`));
|
|
11021
|
+
}
|
|
11022
|
+
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"}`));
|
|
11023
|
+
if (installedOnly.length > 12) console.error(picocolors.default.gray(` … +${installedOnly.length - 12} more installed-only files`));
|
|
11024
|
+
}
|
|
11025
|
+
describeAttributionOrigin(record, digest, kind) {
|
|
11026
|
+
if (record) return `${record.sourcePath}@${record.requestedRef}/${record.resolvedCommit.slice(0, 12)} basis=${record.basis} sha256=${record.sha256.slice(0, 12)}`;
|
|
11027
|
+
if (digest) return `unknown origin (qualified record unavailable) sha256=${digest.slice(0, 12)}`;
|
|
11028
|
+
return kind === "new" ? "not shipped by the selected source" : "none";
|
|
11029
|
+
}
|
|
11030
|
+
/**
|
|
11031
|
+
* Requires separate attribution consent for any computed transition or any
|
|
11032
|
+
* installed attribution file that cannot be qualified. Ordinary `--yes` is
|
|
11033
|
+
* not consent; a non-interactive run without the flag fails preflight, and an
|
|
11034
|
+
* interactive decline cancels the run, both before any publication.
|
|
11035
|
+
*/
|
|
11036
|
+
async ensureAttributionConsent(plan, options) {
|
|
11037
|
+
const { comparison, installed, installedOnly } = plan;
|
|
11038
|
+
if (!(comparison.requiresConsent || installedOnly.length > 0)) return;
|
|
11039
|
+
const details = {
|
|
11040
|
+
legacyUnqualified: comparison.legacyUnqualified,
|
|
11041
|
+
installedOnly: installedOnly.map((variant) => ({
|
|
11042
|
+
packageId: variant.packageId,
|
|
11043
|
+
name: variant.name,
|
|
11044
|
+
agent: variant.agent,
|
|
11045
|
+
path: variant.path,
|
|
11046
|
+
sha256: variant.sha256
|
|
11047
|
+
})),
|
|
11048
|
+
packages: comparison.packages.filter((change) => change.requiresConsent || installedOnly.some((variant) => variant.packageId === change.packageId)).map((change) => ({
|
|
11049
|
+
packageId: change.packageId,
|
|
11050
|
+
status: change.status,
|
|
11051
|
+
reason: change.reason,
|
|
11052
|
+
files: change.files.map((file) => ({
|
|
11053
|
+
name: file.name,
|
|
11054
|
+
status: file.status,
|
|
11055
|
+
previousSha256: file.previousSha256,
|
|
11056
|
+
plannedSha256: file.plannedSha256,
|
|
11057
|
+
previous: file.previous ? {
|
|
11058
|
+
sourcePath: file.previous.sourcePath,
|
|
11059
|
+
sourceKind: file.previous.sourceKind,
|
|
11060
|
+
requestedRef: file.previous.requestedRef,
|
|
11061
|
+
resolvedCommit: file.previous.resolvedCommit,
|
|
11062
|
+
blobSha1: file.previous.blobSha1,
|
|
11063
|
+
basis: file.previous.basis,
|
|
11064
|
+
sha256: file.previous.sha256
|
|
11065
|
+
} : null,
|
|
11066
|
+
planned: file.planned ? {
|
|
11067
|
+
sourcePath: file.planned.sourcePath,
|
|
11068
|
+
sourceKind: file.planned.sourceKind,
|
|
11069
|
+
requestedRef: file.planned.requestedRef,
|
|
11070
|
+
resolvedCommit: file.planned.resolvedCommit,
|
|
11071
|
+
blobSha1: file.planned.blobSha1,
|
|
11072
|
+
basis: file.planned.basis,
|
|
11073
|
+
sha256: file.planned.sha256
|
|
11074
|
+
} : null,
|
|
11075
|
+
installed: (installed[change.packageId]?.[file.name] ?? []).map((binding) => ({
|
|
11076
|
+
agent: binding.agent,
|
|
11077
|
+
path: binding.path,
|
|
11078
|
+
sha256: binding.sha256
|
|
11079
|
+
}))
|
|
11080
|
+
}))
|
|
11081
|
+
}))
|
|
11082
|
+
};
|
|
11083
|
+
if (options.acceptAttributionChanges) return;
|
|
11084
|
+
const message = "Attribution changes require explicit consent; re-run with --accept-attribution-changes (ordinary --yes does not authorize attribution changes).";
|
|
11085
|
+
if (options.json || !process.stdin.isTTY) throw new SyncFailureError({
|
|
11086
|
+
code: "attribution-consent-required",
|
|
11087
|
+
message,
|
|
11088
|
+
hint: "Review the preview above, then re-run with --accept-attribution-changes.",
|
|
11089
|
+
details
|
|
11090
|
+
});
|
|
11091
|
+
await this.promptAttributionConsent(details);
|
|
11092
|
+
}
|
|
11093
|
+
async promptAttributionConsent(details) {
|
|
11094
|
+
if ((await inquirer.default.prompt([{
|
|
11095
|
+
type: "confirm",
|
|
11096
|
+
name: "accept",
|
|
11097
|
+
message: "Apply these attribution changes to the installed files?",
|
|
11098
|
+
default: false
|
|
11099
|
+
}])).accept) return;
|
|
11100
|
+
throw new SyncFailureError({
|
|
11101
|
+
code: "attribution-consent-required",
|
|
11102
|
+
message: "Attribution changes were declined; nothing was published.",
|
|
11103
|
+
details
|
|
11104
|
+
}, true);
|
|
9106
11105
|
}
|
|
9107
11106
|
/**
|
|
9108
11107
|
* Phase 7 — MCP integration. Three paths:
|
|
@@ -9113,7 +11112,7 @@ var SyncCommand = class {
|
|
|
9113
11112
|
* In non-interactive mode (no TTY) and never-prompted: skip with a hint, do
|
|
9114
11113
|
* not assume consent. Users can opt in later via `ags mcp enable`.
|
|
9115
11114
|
*/
|
|
9116
|
-
async runMcpPhase(config, options) {
|
|
11115
|
+
async runMcpPhase(config, options, snapshot) {
|
|
9117
11116
|
const mcp = config.mcp ? { ...config.mcp } : defaultMcpConfig();
|
|
9118
11117
|
const agents = config.agents ?? [];
|
|
9119
11118
|
const snippetOnlyOverride = options.snippets === true && (!mcp.enabled || mcp.scope === "disabled");
|
|
@@ -9123,17 +11122,19 @@ var SyncCommand = class {
|
|
|
9123
11122
|
mcp.snippets = true;
|
|
9124
11123
|
} else if (options.snippets !== void 0) mcp.snippets = options.snippets;
|
|
9125
11124
|
if (!snippetOnlyOverride && (!mcp.prompted || !mcp.enabled && mcp.prompted)) {
|
|
9126
|
-
if (!process.stdin.isTTY && !options.yes) {
|
|
11125
|
+
if ((options.json || !process.stdin.isTTY) && !options.yes) {
|
|
9127
11126
|
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."));
|
|
9128
11127
|
return;
|
|
9129
11128
|
}
|
|
9130
11129
|
const decided = await this.promptMcpConsent(options, mcp.prompted && !mcp.enabled);
|
|
9131
|
-
|
|
11130
|
+
const reviewed = structuredClone(config);
|
|
11131
|
+
reviewed.mcp = {
|
|
9132
11132
|
enabled: decided.enabled,
|
|
9133
11133
|
scope: decided.enabled ? decided.scope : "disabled",
|
|
9134
11134
|
prompted: true
|
|
9135
11135
|
};
|
|
9136
|
-
|
|
11136
|
+
config.mcp = reviewed.mcp;
|
|
11137
|
+
await this.configService.saveReviewedConfig(snapshot, reviewed);
|
|
9137
11138
|
}
|
|
9138
11139
|
const finalMcp = config.mcp ? { ...config.mcp } : defaultMcpConfig();
|
|
9139
11140
|
if (snippetOnlyOverride) {
|
|
@@ -9143,6 +11144,10 @@ var SyncCommand = class {
|
|
|
9143
11144
|
} else if (options.snippets !== void 0) finalMcp.snippets = options.snippets;
|
|
9144
11145
|
if (!finalMcp.enabled || finalMcp.scope === "disabled") return;
|
|
9145
11146
|
const userScopePrompt = options.yes ? async () => true : async (agent, file) => {
|
|
11147
|
+
if (options.json || !process.stdin.isTTY) {
|
|
11148
|
+
console.log(picocolors.default.gray(` ℹ️ Skipping user-scope MCP config for ${agent} (${file}); re-run interactively or with --yes to authorize.`));
|
|
11149
|
+
return false;
|
|
11150
|
+
}
|
|
9146
11151
|
const { ok } = await inquirer.default.prompt([{
|
|
9147
11152
|
type: "confirm",
|
|
9148
11153
|
name: "ok",
|
|
@@ -9223,7 +11228,7 @@ var SyncCommand = class {
|
|
|
9223
11228
|
return;
|
|
9224
11229
|
}
|
|
9225
11230
|
const promptPermission = options.yes ? async () => true : async (agent) => {
|
|
9226
|
-
if (!process.stdin.isTTY) {
|
|
11231
|
+
if (options.json || !process.stdin.isTTY) {
|
|
9227
11232
|
console.log(picocolors.default.gray(`\nℹ️ Skipping MCP permission grant for ${agent} (non-interactive; use --yes to auto-confirm).`));
|
|
9228
11233
|
return false;
|
|
9229
11234
|
}
|
|
@@ -10241,7 +12246,7 @@ var UninstallService = class {
|
|
|
10241
12246
|
missing.push(rel);
|
|
10242
12247
|
continue;
|
|
10243
12248
|
}
|
|
10244
|
-
if (sha256(await fs_extra.default.readFile(absPath)) === entry.sha256) remove.push(rel);
|
|
12249
|
+
if (sha256$1(await fs_extra.default.readFile(absPath)) === entry.sha256) remove.push(rel);
|
|
10245
12250
|
else keepEdited.push(rel);
|
|
10246
12251
|
}
|
|
10247
12252
|
const removedAgents = hasAgents ? [...selection.agents] : [];
|
|
@@ -10256,7 +12261,7 @@ var UninstallService = class {
|
|
|
10256
12261
|
}
|
|
10257
12262
|
const entry = lock.entries[rel];
|
|
10258
12263
|
const content = await fs_extra.default.readFile(absPath);
|
|
10259
|
-
if (entry && sha256(content) === entry.sha256) remove.push(rel);
|
|
12264
|
+
if (entry && sha256$1(content) === entry.sha256) remove.push(rel);
|
|
10260
12265
|
else keepEdited.push(rel);
|
|
10261
12266
|
}
|
|
10262
12267
|
return {
|
|
@@ -11366,7 +13371,7 @@ var PolicyCommand = class {
|
|
|
11366
13371
|
};
|
|
11367
13372
|
//#endregion
|
|
11368
13373
|
//#region src/index.ts
|
|
11369
|
-
dotenv.default.config();
|
|
13374
|
+
dotenv.default.config({ quiet: true });
|
|
11370
13375
|
var _agsExecPath = process.argv[1] ?? "";
|
|
11371
13376
|
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");
|
|
11372
13377
|
var program = new commander.Command();
|
|
@@ -11374,8 +13379,9 @@ program.name("agent-skills-standard").description("A CLI to manage and sync AI a
|
|
|
11374
13379
|
program.command("init").description("Initialize a .skillsrc configuration file interactively").action(async () => {
|
|
11375
13380
|
await new InitCommand().run();
|
|
11376
13381
|
});
|
|
11377
|
-
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", "
|
|
11378
|
-
await new SyncCommand().run(options);
|
|
13382
|
+
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) => {
|
|
13383
|
+
const outcome = await new SyncCommand().run(options);
|
|
13384
|
+
process.exitCode = outcome.status === "success" || outcome.status === "dry-run" ? 0 : 1;
|
|
11379
13385
|
});
|
|
11380
13386
|
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) => {
|
|
11381
13387
|
await new VerifyCommand().run(options);
|