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