@ryanreh99/skills-sync 1.0.0
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/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/assets/contracts/build/bundle.schema.json +76 -0
- package/dist/assets/contracts/inputs/config.schema.json +13 -0
- package/dist/assets/contracts/inputs/mcp-servers.schema.json +56 -0
- package/dist/assets/contracts/inputs/pack-manifest.schema.json +33 -0
- package/dist/assets/contracts/inputs/pack-sources.schema.json +47 -0
- package/dist/assets/contracts/inputs/profile.schema.json +21 -0
- package/dist/assets/contracts/inputs/upstreams.schema.json +45 -0
- package/dist/assets/contracts/runtime/targets.schema.json +120 -0
- package/dist/assets/contracts/state/upstreams-lock.schema.json +38 -0
- package/dist/assets/manifests/targets.linux.json +27 -0
- package/dist/assets/manifests/targets.macos.json +27 -0
- package/dist/assets/manifests/targets.windows.json +27 -0
- package/dist/assets/seed/config.json +3 -0
- package/dist/assets/seed/packs/personal/mcp/servers.json +20 -0
- package/dist/assets/seed/packs/personal/pack.json +7 -0
- package/dist/assets/seed/packs/personal/sources.json +31 -0
- package/dist/assets/seed/profiles/personal.json +4 -0
- package/dist/assets/seed/upstreams.json +23 -0
- package/dist/cli.js +532 -0
- package/dist/index.js +27 -0
- package/dist/lib/adapters/claude.js +49 -0
- package/dist/lib/adapters/codex.js +239 -0
- package/dist/lib/adapters/common.js +114 -0
- package/dist/lib/adapters/copilot.js +53 -0
- package/dist/lib/adapters/cursor.js +53 -0
- package/dist/lib/adapters/gemini.js +52 -0
- package/dist/lib/agents.js +888 -0
- package/dist/lib/bindings.js +510 -0
- package/dist/lib/build.js +190 -0
- package/dist/lib/bundle.js +165 -0
- package/dist/lib/config.js +324 -0
- package/dist/lib/core.js +447 -0
- package/dist/lib/detect.js +56 -0
- package/dist/lib/doctor.js +504 -0
- package/dist/lib/init.js +292 -0
- package/dist/lib/inventory.js +235 -0
- package/dist/lib/manage.js +463 -0
- package/dist/lib/mcp-config.js +264 -0
- package/dist/lib/profile-transfer.js +221 -0
- package/dist/lib/upstreams.js +782 -0
- package/docs/agent-storage-map.md +153 -0
- package/docs/architecture.md +117 -0
- package/docs/changelog.md +12 -0
- package/docs/commands.md +94 -0
- package/docs/contracts.md +112 -0
- package/docs/homebrew.md +46 -0
- package/docs/quickstart.md +14 -0
- package/docs/roadmap.md +5 -0
- package/docs/security.md +32 -0
- package/docs/user-guide.md +257 -0
- package/package.json +61 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
CACHE_ROOT,
|
|
5
|
+
CODEX_MCP_BLOCK_END,
|
|
6
|
+
CODEX_MCP_BLOCK_START,
|
|
7
|
+
MCP_MANAGED_PREFIX,
|
|
8
|
+
RUNTIME_INTERNAL_ROOT,
|
|
9
|
+
SCHEMAS,
|
|
10
|
+
assertJsonFileMatchesSchema,
|
|
11
|
+
bindingMatches,
|
|
12
|
+
existsOrLink,
|
|
13
|
+
expandTargetPath,
|
|
14
|
+
fileSha256,
|
|
15
|
+
getTargetManifestPath,
|
|
16
|
+
isInsidePath,
|
|
17
|
+
logInfo,
|
|
18
|
+
logWarn,
|
|
19
|
+
pathsEqual,
|
|
20
|
+
toFileSystemRelativePath
|
|
21
|
+
} from "./core.js";
|
|
22
|
+
import { getStatePath } from "./bindings.js";
|
|
23
|
+
import { collectImportedSkillEntries, collectLocalSkillEntries } from "./bundle.js";
|
|
24
|
+
import { loadEffectiveTargets, loadPackSources, normalizeMcpManifest, resolvePack, resolveProfile } from "./config.js";
|
|
25
|
+
import { collectSourcePlanning, getCommitObjectType, getLockKey, loadLockfile, loadUpstreamsConfig, resolveReferences, validateAllLockPins } from "./upstreams.js";
|
|
26
|
+
import { extractCodexMcpTables, renderCodexMcpTables } from "./adapters/codex.js";
|
|
27
|
+
|
|
28
|
+
function redactPathDetails(message) {
|
|
29
|
+
return String(message ?? "")
|
|
30
|
+
.replace(/[A-Za-z]:\\[^\s'"]+/g, "<path>")
|
|
31
|
+
.replace(/~\/[^\s'"]+/g, "<path>")
|
|
32
|
+
.replace(/\/(?:[^/\s]+\/)+[^/\s]+/g, "<path>")
|
|
33
|
+
.replace(/\b[\w.-]+\.(json|toml|md)\b/g, "<file>");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getExpectedTargetRaw(binding, targets) {
|
|
37
|
+
const key = `${binding.tool}:${binding.kind}`;
|
|
38
|
+
switch (key) {
|
|
39
|
+
case "codex:dir":
|
|
40
|
+
return targets.codex.skillsDir;
|
|
41
|
+
case "claude:dir":
|
|
42
|
+
return targets.claude.skillsDir;
|
|
43
|
+
case "cursor:dir":
|
|
44
|
+
return targets.cursor.skillsDir;
|
|
45
|
+
case "copilot:dir":
|
|
46
|
+
return targets.copilot.skillsDir;
|
|
47
|
+
case "gemini:dir":
|
|
48
|
+
return targets.gemini.skillsDir;
|
|
49
|
+
case "codex:config":
|
|
50
|
+
return targets.codex.mcpConfig;
|
|
51
|
+
case "claude:config":
|
|
52
|
+
return targets.claude.mcpConfig;
|
|
53
|
+
case "cursor:config":
|
|
54
|
+
return targets.cursor.mcpConfig;
|
|
55
|
+
case "copilot:config":
|
|
56
|
+
return targets.copilot.mcpConfig;
|
|
57
|
+
case "gemini:config":
|
|
58
|
+
return targets.gemini.mcpConfig;
|
|
59
|
+
default:
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getManagedServerNames(canonicalMcp) {
|
|
65
|
+
const names = Object.keys(canonicalMcp?.mcpServers ?? {}).sort((left, right) => left.localeCompare(right));
|
|
66
|
+
return names.map((name) => `${MCP_MANAGED_PREFIX}${name}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sortObjectDeep(value) {
|
|
70
|
+
if (Array.isArray(value)) {
|
|
71
|
+
return value.map((item) => sortObjectDeep(item));
|
|
72
|
+
}
|
|
73
|
+
if (!value || typeof value !== "object") {
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
const sorted = {};
|
|
77
|
+
for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
|
|
78
|
+
sorted[key] = sortObjectDeep(value[key]);
|
|
79
|
+
}
|
|
80
|
+
return sorted;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizedMcpServersString(value) {
|
|
84
|
+
return JSON.stringify(sortObjectDeep(value ?? {}));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function validateBundleMcp(bundleMcpPath, errors) {
|
|
88
|
+
try {
|
|
89
|
+
const canonical = await fs.readJson(bundleMcpPath);
|
|
90
|
+
if (!canonical || typeof canonical !== "object" || Array.isArray(canonical)) {
|
|
91
|
+
throw new Error("MCP bundle must be a JSON object.");
|
|
92
|
+
}
|
|
93
|
+
if (!canonical.mcpServers || typeof canonical.mcpServers !== "object" || Array.isArray(canonical.mcpServers)) {
|
|
94
|
+
throw new Error("MCP bundle must contain object field 'mcpServers'.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const projected = { servers: {} };
|
|
98
|
+
for (const [name, server] of Object.entries(canonical.mcpServers)) {
|
|
99
|
+
if (!server || typeof server !== "object" || Array.isArray(server)) {
|
|
100
|
+
throw new Error(`mcpServers['${name}'] must be an object.`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof server.url === "string" && server.url.trim().length > 0) {
|
|
103
|
+
projected.servers[name] = {
|
|
104
|
+
url: server.url.trim()
|
|
105
|
+
};
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (typeof server.command !== "string" || server.command.trim().length === 0) {
|
|
109
|
+
throw new Error(`mcpServers['${name}'] must define either non-empty url or command.`);
|
|
110
|
+
}
|
|
111
|
+
const projectedServer = {
|
|
112
|
+
command: server.command,
|
|
113
|
+
args: Array.isArray(server.args) ? server.args : []
|
|
114
|
+
};
|
|
115
|
+
if (server.env && typeof server.env === "object" && !Array.isArray(server.env)) {
|
|
116
|
+
const env = {};
|
|
117
|
+
for (const key of Object.keys(server.env).sort((left, right) => left.localeCompare(right))) {
|
|
118
|
+
if (key.length === 0) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
env[key] = String(server.env[key]);
|
|
122
|
+
}
|
|
123
|
+
if (Object.keys(env).length > 0) {
|
|
124
|
+
projectedServer.env = env;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
projected.servers[name] = projectedServer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validate projected shape against the input MCP servers schema contract.
|
|
131
|
+
const tempMcpShapePath = path.join(CACHE_ROOT, ".doctor-mcp-shape.json");
|
|
132
|
+
await fs.ensureDir(path.dirname(tempMcpShapePath));
|
|
133
|
+
await fs.writeFile(tempMcpShapePath, `${JSON.stringify(projected)}\n`, "utf8");
|
|
134
|
+
await assertJsonFileMatchesSchema(tempMcpShapePath, SCHEMAS.mcpServers);
|
|
135
|
+
await fs.remove(tempMcpShapePath).catch(() => {});
|
|
136
|
+
return canonical;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
errors.push(`Invalid canonical MCP at ${bundleMcpPath}: ${error.message}`);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function validateRuntimeArtifacts({
|
|
144
|
+
profile,
|
|
145
|
+
packRoot,
|
|
146
|
+
skillImports,
|
|
147
|
+
resolvedReferences,
|
|
148
|
+
normalizedMcp,
|
|
149
|
+
errors
|
|
150
|
+
}) {
|
|
151
|
+
const runtimeInternalRoot = RUNTIME_INTERNAL_ROOT;
|
|
152
|
+
const bundleRoot = path.join(runtimeInternalRoot, "common");
|
|
153
|
+
const bundleMetadataPath = path.join(bundleRoot, "bundle.json");
|
|
154
|
+
const bundleSkillsPath = path.join(bundleRoot, "skills");
|
|
155
|
+
const bundleMcpPath = path.join(bundleRoot, "mcp.json");
|
|
156
|
+
|
|
157
|
+
for (const requiredPath of [bundleMetadataPath, bundleSkillsPath, bundleMcpPath]) {
|
|
158
|
+
if (!(await fs.pathExists(requiredPath))) {
|
|
159
|
+
errors.push(`Missing canonical bundle artifact: ${requiredPath}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (errors.length > 0) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const bundleDoc = await assertJsonFileMatchesSchema(bundleMetadataPath, SCHEMAS.bundle).catch((error) => {
|
|
167
|
+
errors.push(error.message);
|
|
168
|
+
return null;
|
|
169
|
+
});
|
|
170
|
+
if (!bundleDoc) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (bundleDoc.profile !== profile.name) {
|
|
175
|
+
errors.push(`Bundle profile mismatch. Expected '${profile.name}', found '${bundleDoc.profile}'.`);
|
|
176
|
+
}
|
|
177
|
+
if (bundleDoc.sources?.packPath !== packRoot) {
|
|
178
|
+
errors.push(`Bundle packPath mismatch. Expected '${packRoot}', found '${bundleDoc.sources?.packPath}'.`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const canonicalMcp = await fs.readJson(bundleMcpPath).catch((error) => {
|
|
182
|
+
errors.push(`Failed to read canonical MCP: ${error.message}`);
|
|
183
|
+
return null;
|
|
184
|
+
});
|
|
185
|
+
if (!canonicalMcp) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const localEntries = await collectLocalSkillEntries(packRoot);
|
|
190
|
+
const importedEntries = collectImportedSkillEntries(skillImports, resolvedReferences);
|
|
191
|
+
const allEntries = [...localEntries, ...importedEntries];
|
|
192
|
+
for (const entry of allEntries) {
|
|
193
|
+
const bundled = path.join(bundleSkillsPath, toFileSystemRelativePath(entry.destRelative));
|
|
194
|
+
if (!(await fs.pathExists(bundled))) {
|
|
195
|
+
errors.push(`Expected bundled skill missing: ${bundled}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const requiredProjectionPaths = [
|
|
200
|
+
path.join(runtimeInternalRoot, ".codex", "skills"),
|
|
201
|
+
path.join(runtimeInternalRoot, ".codex", "config.toml"),
|
|
202
|
+
path.join(runtimeInternalRoot, ".claude", "skills"),
|
|
203
|
+
path.join(runtimeInternalRoot, ".claude", "mcp.json"),
|
|
204
|
+
path.join(runtimeInternalRoot, ".cursor", "skills"),
|
|
205
|
+
path.join(runtimeInternalRoot, ".cursor", "mcp.json"),
|
|
206
|
+
path.join(runtimeInternalRoot, ".copilot", "skills"),
|
|
207
|
+
path.join(runtimeInternalRoot, ".copilot", "mcp-config.json"),
|
|
208
|
+
path.join(runtimeInternalRoot, ".gemini", "skills"),
|
|
209
|
+
path.join(runtimeInternalRoot, ".gemini", "settings.json")
|
|
210
|
+
];
|
|
211
|
+
for (const projectionPath of requiredProjectionPaths) {
|
|
212
|
+
if (!(await fs.pathExists(projectionPath))) {
|
|
213
|
+
errors.push(`Missing projection artifact: ${projectionPath}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const tool of ["codex", "claude", "cursor", "copilot", "gemini"]) {
|
|
218
|
+
for (const entry of allEntries) {
|
|
219
|
+
const target = path.join(runtimeInternalRoot, `.${tool}`, "skills", toFileSystemRelativePath(entry.destRelative));
|
|
220
|
+
if (!(await fs.pathExists(target))) {
|
|
221
|
+
errors.push(`Expected ${tool} projected skill missing: ${target}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const bundleMcpHash = await fileSha256(bundleMcpPath);
|
|
227
|
+
for (const projectionPath of [path.join(runtimeInternalRoot, ".cursor", "mcp.json")]) {
|
|
228
|
+
if (await fs.pathExists(projectionPath)) {
|
|
229
|
+
const hash = await fileSha256(projectionPath);
|
|
230
|
+
if (hash !== bundleMcpHash) {
|
|
231
|
+
errors.push(`MCP projection does not match canonical bundle: ${projectionPath}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const expectedMcpServersNormalized = normalizedMcpServersString(canonicalMcp?.mcpServers ?? {});
|
|
237
|
+
for (const projectionPath of [
|
|
238
|
+
path.join(runtimeInternalRoot, ".claude", "mcp.json"),
|
|
239
|
+
path.join(runtimeInternalRoot, ".copilot", "mcp-config.json"),
|
|
240
|
+
path.join(runtimeInternalRoot, ".gemini", "settings.json")
|
|
241
|
+
]) {
|
|
242
|
+
if (!(await fs.pathExists(projectionPath))) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const doc = await fs.readJson(projectionPath);
|
|
247
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
248
|
+
errors.push(`Projection is not a JSON object: ${projectionPath}`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const actualMcpServers = doc.mcpServers;
|
|
252
|
+
if (!actualMcpServers || typeof actualMcpServers !== "object" || Array.isArray(actualMcpServers)) {
|
|
253
|
+
errors.push(`Projection is missing mcpServers object: ${projectionPath}`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (normalizedMcpServersString(actualMcpServers) !== expectedMcpServersNormalized) {
|
|
257
|
+
errors.push(`Projection mcpServers does not match canonical bundle: ${projectionPath}`);
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
errors.push(`Failed to parse projection '${projectionPath}': ${error.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const codexConfigPath = path.join(runtimeInternalRoot, ".codex", "config.toml");
|
|
265
|
+
if (await fs.pathExists(codexConfigPath)) {
|
|
266
|
+
const actual = await fs.readFile(codexConfigPath, "utf8");
|
|
267
|
+
const actualMcpTables = extractCodexMcpTables(actual);
|
|
268
|
+
const expectedMcpTables = renderCodexMcpTables(canonicalMcp);
|
|
269
|
+
if (actualMcpTables !== expectedMcpTables) {
|
|
270
|
+
errors.push(`Codex MCP tables in projection do not match canonical bundle: ${codexConfigPath}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const normalizedCanonical = JSON.stringify(canonicalMcp);
|
|
275
|
+
const normalizedExpected = JSON.stringify(normalizedMcp);
|
|
276
|
+
if (normalizedCanonical !== normalizedExpected) {
|
|
277
|
+
errors.push(`Canonical MCP differs from pack MCP normalization at ${bundleMcpPath}.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function validateStateAndBindings({ state, errors, warnings }) {
|
|
282
|
+
if (!state) {
|
|
283
|
+
warnings.push("No active profile state found.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const key of ["profile", "os", "bindings"]) {
|
|
288
|
+
if (!(key in state)) {
|
|
289
|
+
errors.push(`State file is missing '${key}'.`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (state.os !== "windows" && state.os !== "macos" && state.os !== "linux") {
|
|
293
|
+
errors.push(`State os must be 'windows', 'macos', or 'linux'; found '${state.os}'.`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const targets = await loadEffectiveTargets(state.os).catch((error) => {
|
|
298
|
+
errors.push(error.message);
|
|
299
|
+
return null;
|
|
300
|
+
});
|
|
301
|
+
if (!targets) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const runtimeInternalRoot = RUNTIME_INTERNAL_ROOT;
|
|
306
|
+
const bundleMcpPath = path.join(runtimeInternalRoot, "common", "mcp.json");
|
|
307
|
+
const canonicalMcp = (await fs.pathExists(bundleMcpPath)) ? await fs.readJson(bundleMcpPath) : null;
|
|
308
|
+
const expectedManagedNames = getManagedServerNames(canonicalMcp);
|
|
309
|
+
|
|
310
|
+
for (const binding of Array.isArray(state.bindings) ? state.bindings : []) {
|
|
311
|
+
const expectedRaw = getExpectedTargetRaw(binding, targets);
|
|
312
|
+
if (!expectedRaw) {
|
|
313
|
+
errors.push(`Unknown state binding type: ${binding.tool}:${binding.kind}`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const expectedTargetPath = expandTargetPath(expectedRaw, state.os);
|
|
317
|
+
const codexNestedDirBinding =
|
|
318
|
+
binding.kind === "dir" && binding.tool === "codex" && isInsidePath(expectedTargetPath, binding.targetPath);
|
|
319
|
+
if (!pathsEqual(binding.targetPath, expectedTargetPath) && !codexNestedDirBinding) {
|
|
320
|
+
errors.push(
|
|
321
|
+
`Binding target mismatch for ${binding.tool}:${binding.kind}. Expected '${expectedTargetPath}', found '${binding.targetPath}'.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (binding.kind === "config") {
|
|
326
|
+
if (!(await fs.pathExists(binding.targetPath))) {
|
|
327
|
+
errors.push(`Missing managed config target: ${binding.targetPath}`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (binding.tool === "codex") {
|
|
332
|
+
const content = await fs.readFile(binding.targetPath, "utf8");
|
|
333
|
+
if (!content.includes(CODEX_MCP_BLOCK_START) || !content.includes(CODEX_MCP_BLOCK_END)) {
|
|
334
|
+
errors.push(`Codex managed MCP block missing in ${binding.targetPath}`);
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
let doc;
|
|
338
|
+
try {
|
|
339
|
+
doc = await fs.readJson(binding.targetPath);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
errors.push(`Failed to parse JSON config '${binding.targetPath}': ${error.message}`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (!doc.mcpServers || typeof doc.mcpServers !== "object" || Array.isArray(doc.mcpServers)) {
|
|
345
|
+
errors.push(`JSON config missing mcpServers object: ${binding.targetPath}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
for (const managedName of expectedManagedNames) {
|
|
349
|
+
if (!(managedName in doc.mcpServers)) {
|
|
350
|
+
errors.push(`Missing managed MCP entry '${managedName}' in ${binding.targetPath}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!(await fs.pathExists(binding.sourcePath))) {
|
|
358
|
+
errors.push(`Missing binding source: ${binding.sourcePath}`);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (!isInsidePath(runtimeInternalRoot, binding.sourcePath)) {
|
|
362
|
+
errors.push(`Binding source is outside runtime artifact root: ${binding.sourcePath}`);
|
|
363
|
+
}
|
|
364
|
+
if (!(await existsOrLink(binding.targetPath))) {
|
|
365
|
+
errors.push(`Missing binding target: ${binding.targetPath}`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!(await bindingMatches(binding))) {
|
|
369
|
+
errors.push(`Binding mismatch: ${binding.targetPath} no longer matches ${binding.sourcePath}.`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function cmdDoctor(profileOverride) {
|
|
375
|
+
const errors = [];
|
|
376
|
+
const warnings = [];
|
|
377
|
+
|
|
378
|
+
const requiredFiles = [
|
|
379
|
+
getTargetManifestPath("windows"),
|
|
380
|
+
getTargetManifestPath("macos"),
|
|
381
|
+
getTargetManifestPath("linux"),
|
|
382
|
+
SCHEMAS.profile,
|
|
383
|
+
SCHEMAS.packManifest,
|
|
384
|
+
SCHEMAS.mcpServers,
|
|
385
|
+
SCHEMAS.bundle,
|
|
386
|
+
SCHEMAS.targets,
|
|
387
|
+
SCHEMAS.upstreams,
|
|
388
|
+
SCHEMAS.upstreamsLock,
|
|
389
|
+
SCHEMAS.packSources
|
|
390
|
+
];
|
|
391
|
+
for (const requiredFile of requiredFiles) {
|
|
392
|
+
if (!(await fs.pathExists(requiredFile))) {
|
|
393
|
+
errors.push(`Missing required file: ${requiredFile}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (errors.length > 0) {
|
|
397
|
+
// Continue to report all known issues.
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let upstreams = null;
|
|
401
|
+
let lockState = null;
|
|
402
|
+
try {
|
|
403
|
+
await assertJsonFileMatchesSchema(getTargetManifestPath("windows"), SCHEMAS.targets);
|
|
404
|
+
await assertJsonFileMatchesSchema(getTargetManifestPath("macos"), SCHEMAS.targets);
|
|
405
|
+
await assertJsonFileMatchesSchema(getTargetManifestPath("linux"), SCHEMAS.targets);
|
|
406
|
+
upstreams = await loadUpstreamsConfig();
|
|
407
|
+
lockState = await loadLockfile();
|
|
408
|
+
if (lockState.exists) {
|
|
409
|
+
await assertJsonFileMatchesSchema(lockState.path, SCHEMAS.upstreamsLock);
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
errors.push(error.message);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const statePath = await getStatePath();
|
|
416
|
+
const state = (await fs.pathExists(statePath)) ? await fs.readJson(statePath).catch(() => null) : null;
|
|
417
|
+
if ((await fs.pathExists(statePath)) && !state) {
|
|
418
|
+
errors.push(`Failed to parse state file: ${statePath}`);
|
|
419
|
+
}
|
|
420
|
+
await validateStateAndBindings({ state, errors, warnings });
|
|
421
|
+
|
|
422
|
+
const profileName = profileOverride || state?.profile || null;
|
|
423
|
+
if (!profileName) {
|
|
424
|
+
warnings.push("No profile provided and no active state profile found; skipping profile-specific validation.");
|
|
425
|
+
} else if (upstreams && lockState) {
|
|
426
|
+
try {
|
|
427
|
+
const { profilePath, profile } = await resolveProfile(profileName);
|
|
428
|
+
const packRoot = await resolvePack(profile);
|
|
429
|
+
await assertJsonFileMatchesSchema(profilePath, SCHEMAS.profile);
|
|
430
|
+
await assertJsonFileMatchesSchema(path.join(packRoot, "pack.json"), SCHEMAS.packManifest);
|
|
431
|
+
const mcpManifest = await assertJsonFileMatchesSchema(path.join(packRoot, "mcp", "servers.json"), SCHEMAS.mcpServers);
|
|
432
|
+
const normalizedMcp = normalizeMcpManifest(mcpManifest);
|
|
433
|
+
|
|
434
|
+
const { sources } = await loadPackSources(packRoot);
|
|
435
|
+
const { references, skillImports } = collectSourcePlanning(sources, upstreams.byId);
|
|
436
|
+
|
|
437
|
+
await validateAllLockPins(lockState, upstreams.byId, errors);
|
|
438
|
+
const resolvedReferences =
|
|
439
|
+
references.length > 0
|
|
440
|
+
? await resolveReferences({
|
|
441
|
+
references,
|
|
442
|
+
upstreamById: upstreams.byId,
|
|
443
|
+
lockState,
|
|
444
|
+
preferPinned: true,
|
|
445
|
+
requirePinned: true,
|
|
446
|
+
updatePins: false,
|
|
447
|
+
allowLockUpdate: false
|
|
448
|
+
})
|
|
449
|
+
: new Map();
|
|
450
|
+
|
|
451
|
+
for (const importEntry of skillImports) {
|
|
452
|
+
const key = getLockKey(importEntry.upstreamId, importEntry.ref);
|
|
453
|
+
const resolved = resolvedReferences.get(key);
|
|
454
|
+
if (!resolved) {
|
|
455
|
+
errors.push(`Missing resolved upstream reference for ${importEntry.upstreamId}@${importEntry.ref}.`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const objectType = await getCommitObjectType(resolved.repoPath, resolved.commit, importEntry.repoPath);
|
|
459
|
+
if (objectType !== "tree") {
|
|
460
|
+
errors.push(
|
|
461
|
+
`Imported path '${importEntry.repoPath}' from '${importEntry.upstreamId}@${importEntry.ref}' is missing at commit ${resolved.commit}.`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await validateRuntimeArtifacts({
|
|
467
|
+
profile,
|
|
468
|
+
packRoot,
|
|
469
|
+
skillImports,
|
|
470
|
+
resolvedReferences,
|
|
471
|
+
normalizedMcp,
|
|
472
|
+
errors
|
|
473
|
+
});
|
|
474
|
+
} catch (error) {
|
|
475
|
+
errors.push(error.message);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const runtimeBundleMcpPath = path.join(RUNTIME_INTERNAL_ROOT, "common", "mcp.json");
|
|
480
|
+
if (await fs.pathExists(runtimeBundleMcpPath)) {
|
|
481
|
+
await validateBundleMcp(runtimeBundleMcpPath, errors);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const warning of warnings) {
|
|
485
|
+
logWarn(redactPathDetails(warning));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (errors.length > 0) {
|
|
489
|
+
process.stdout.write("[skills-sync] Doctor found issues:\n");
|
|
490
|
+
for (const error of errors) {
|
|
491
|
+
process.stdout.write(` - ${redactPathDetails(error)}\n`);
|
|
492
|
+
}
|
|
493
|
+
process.stdout.write("\n");
|
|
494
|
+
process.stdout.write("Remediation steps:\n");
|
|
495
|
+
process.stdout.write(" 1) Run skills-sync init\n");
|
|
496
|
+
process.stdout.write(" 2) Run skills-sync build --profile <name>\n");
|
|
497
|
+
process.stdout.write(" 3) Run skills-sync apply --profile <name>\n");
|
|
498
|
+
process.stdout.write(" 4) Re-run doctor\n");
|
|
499
|
+
process.exitCode = 1;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
logInfo("Doctor checks passed.");
|
|
504
|
+
}
|