@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,510 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
LOCAL_OVERRIDES_ROOT,
|
|
5
|
+
MANAGED_BY,
|
|
6
|
+
RUNTIME_INTERNAL_ROOT,
|
|
7
|
+
bindingMatches,
|
|
8
|
+
createDirectoryBinding,
|
|
9
|
+
detectOsName,
|
|
10
|
+
existsOrLink,
|
|
11
|
+
expandTargetPath,
|
|
12
|
+
logInfo,
|
|
13
|
+
logWarn,
|
|
14
|
+
pathsEqual,
|
|
15
|
+
resolveLinkTarget,
|
|
16
|
+
writeJsonFile
|
|
17
|
+
} from "./core.js";
|
|
18
|
+
import { loadEffectiveTargets } from "./config.js";
|
|
19
|
+
import { applyManagedMcpConfig, removeManagedMcpConfig } from "./mcp-config.js";
|
|
20
|
+
|
|
21
|
+
function redactPathDetails(message) {
|
|
22
|
+
return String(message ?? "")
|
|
23
|
+
.replace(/[A-Za-z]:\\[^\s'"]+/g, "<path>")
|
|
24
|
+
.replace(/~\/[^\s'"]+/g, "<path>")
|
|
25
|
+
.replace(/\/(?:[^/\s]+\/)+[^/\s]+/g, "<path>")
|
|
26
|
+
.replace(/\b[\w.-]+\.(json|toml|md)\b/g, "<file>");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getStatePath() {
|
|
30
|
+
const stateDir = path.join(LOCAL_OVERRIDES_ROOT, "state");
|
|
31
|
+
await fs.ensureDir(stateDir);
|
|
32
|
+
return path.join(stateDir, "active-profile.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getDirectoryBindingSpecs(effectiveTargets, runtimeInternalRoot) {
|
|
36
|
+
const specs = [
|
|
37
|
+
{
|
|
38
|
+
tool: "codex",
|
|
39
|
+
sourcePath: path.join(runtimeInternalRoot, ".codex", "skills"),
|
|
40
|
+
targetRawPath: effectiveTargets.codex.skillsDir
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
tool: "claude",
|
|
44
|
+
sourcePath: path.join(runtimeInternalRoot, ".claude", "skills"),
|
|
45
|
+
targetRawPath: effectiveTargets.claude.skillsDir
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
tool: "cursor",
|
|
49
|
+
sourcePath: path.join(runtimeInternalRoot, ".cursor", "skills"),
|
|
50
|
+
targetRawPath: effectiveTargets.cursor.skillsDir
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
tool: "copilot",
|
|
54
|
+
sourcePath: path.join(runtimeInternalRoot, ".copilot", "skills"),
|
|
55
|
+
targetRawPath: effectiveTargets.copilot.skillsDir
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
tool: "gemini",
|
|
59
|
+
sourcePath: path.join(runtimeInternalRoot, ".gemini", "skills"),
|
|
60
|
+
targetRawPath: effectiveTargets.gemini.skillsDir
|
|
61
|
+
}
|
|
62
|
+
];
|
|
63
|
+
return specs.filter((spec) => typeof spec.targetRawPath === "string" && spec.targetRawPath.trim().length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getConfigSpecs(effectiveTargets, runtimeInternalRoot) {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
tool: "codex",
|
|
70
|
+
sourcePath: path.join(runtimeInternalRoot, ".codex", "config.toml"),
|
|
71
|
+
targetRawPath: effectiveTargets.codex.mcpConfig
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
tool: "claude",
|
|
75
|
+
sourcePath: path.join(runtimeInternalRoot, ".claude", "mcp.json"),
|
|
76
|
+
targetRawPath: effectiveTargets.claude.mcpConfig
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
tool: "cursor",
|
|
80
|
+
sourcePath: path.join(runtimeInternalRoot, ".cursor", "mcp.json"),
|
|
81
|
+
targetRawPath: effectiveTargets.cursor.mcpConfig
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
tool: "copilot",
|
|
85
|
+
sourcePath: path.join(runtimeInternalRoot, ".copilot", "mcp-config.json"),
|
|
86
|
+
targetRawPath: effectiveTargets.copilot.mcpConfig
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
tool: "gemini",
|
|
90
|
+
sourcePath: path.join(runtimeInternalRoot, ".gemini", "settings.json"),
|
|
91
|
+
targetRawPath: effectiveTargets.gemini.mcpConfig
|
|
92
|
+
}
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatUnmanagedPathError(targetPath, profileName) {
|
|
97
|
+
void targetPath;
|
|
98
|
+
return (
|
|
99
|
+
"Refusing to replace an unmanaged existing target.\n" +
|
|
100
|
+
"Remediation:\n" +
|
|
101
|
+
" - Move or remove the existing target manually.\n" +
|
|
102
|
+
` - Re-run apply for profile '${profileName}'.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function isAdoptableDirectoryBinding(sourcePath, targetPath) {
|
|
107
|
+
const linkTarget = await resolveLinkTarget(targetPath);
|
|
108
|
+
return Boolean(linkTarget) && pathsEqual(linkTarget, sourcePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function bindNestedDirectoryEntries({
|
|
112
|
+
tool,
|
|
113
|
+
sourcePath,
|
|
114
|
+
targetPath,
|
|
115
|
+
osName,
|
|
116
|
+
profileName,
|
|
117
|
+
bindings,
|
|
118
|
+
createdTargets,
|
|
119
|
+
dryRun = false,
|
|
120
|
+
plannedActions = null
|
|
121
|
+
}) {
|
|
122
|
+
if (!dryRun) {
|
|
123
|
+
await fs.ensureDir(targetPath);
|
|
124
|
+
}
|
|
125
|
+
const entries = await fs.readdir(sourcePath, { withFileTypes: true });
|
|
126
|
+
const directories = entries.filter((entry) => entry.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
|
|
127
|
+
|
|
128
|
+
for (const directory of directories) {
|
|
129
|
+
const sourceChildPath = path.join(sourcePath, directory.name);
|
|
130
|
+
const targetChildPath = path.join(targetPath, directory.name);
|
|
131
|
+
|
|
132
|
+
if (await existsOrLink(targetChildPath)) {
|
|
133
|
+
const adopted = dryRun
|
|
134
|
+
? await isAdoptableDirectoryBinding(sourceChildPath, targetChildPath)
|
|
135
|
+
: await adoptExistingDirectoryBinding({
|
|
136
|
+
tool,
|
|
137
|
+
sourcePath: sourceChildPath,
|
|
138
|
+
targetPath: targetChildPath,
|
|
139
|
+
osName,
|
|
140
|
+
bindings
|
|
141
|
+
});
|
|
142
|
+
if (!adopted) {
|
|
143
|
+
throw new Error(formatUnmanagedPathError(targetChildPath, profileName));
|
|
144
|
+
}
|
|
145
|
+
if (plannedActions) {
|
|
146
|
+
plannedActions.push({
|
|
147
|
+
tool,
|
|
148
|
+
kind: "dir",
|
|
149
|
+
targetPath: targetChildPath,
|
|
150
|
+
sourcePath: sourceChildPath,
|
|
151
|
+
action: "adopt-existing"
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!dryRun) {
|
|
158
|
+
const method = await createDirectoryBinding(sourceChildPath, targetChildPath, osName);
|
|
159
|
+
createdTargets.push(targetChildPath);
|
|
160
|
+
bindings.push({
|
|
161
|
+
tool,
|
|
162
|
+
kind: "dir",
|
|
163
|
+
targetPath: targetChildPath,
|
|
164
|
+
sourcePath: sourceChildPath,
|
|
165
|
+
method,
|
|
166
|
+
hash: null,
|
|
167
|
+
managedBy: MANAGED_BY
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (plannedActions) {
|
|
171
|
+
plannedActions.push({
|
|
172
|
+
tool,
|
|
173
|
+
kind: "dir",
|
|
174
|
+
targetPath: targetChildPath,
|
|
175
|
+
sourcePath: sourceChildPath,
|
|
176
|
+
action: "create-binding"
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function adoptExistingDirectoryBinding({ tool, sourcePath, targetPath, osName, bindings }) {
|
|
183
|
+
if (!(await isAdoptableDirectoryBinding(sourcePath, targetPath))) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
bindings.push({
|
|
188
|
+
tool,
|
|
189
|
+
kind: "dir",
|
|
190
|
+
targetPath,
|
|
191
|
+
sourcePath,
|
|
192
|
+
method: osName === "windows" ? "junction" : "symlink",
|
|
193
|
+
hash: null,
|
|
194
|
+
managedBy: MANAGED_BY
|
|
195
|
+
});
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function unlinkInternal(options = {}) {
|
|
200
|
+
const { suppressNoStateMessage = false, dryRun = false } = options;
|
|
201
|
+
const statePath = await getStatePath();
|
|
202
|
+
if (!(await fs.pathExists(statePath))) {
|
|
203
|
+
if (!suppressNoStateMessage) {
|
|
204
|
+
logInfo("No active bindings to unlink.");
|
|
205
|
+
}
|
|
206
|
+
return { removed: 0, skipped: 0, remainingBindings: [] };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const state = await fs.readJson(statePath);
|
|
210
|
+
const bindings = Array.isArray(state.bindings) ? state.bindings : [];
|
|
211
|
+
let removed = 0;
|
|
212
|
+
let skipped = 0;
|
|
213
|
+
const remainingBindings = [];
|
|
214
|
+
|
|
215
|
+
for (const binding of bindings) {
|
|
216
|
+
if (binding.kind === "config") {
|
|
217
|
+
try {
|
|
218
|
+
const result = await removeManagedMcpConfig(binding, { dryRun });
|
|
219
|
+
if (result.removed) {
|
|
220
|
+
removed += 1;
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
skipped += 1;
|
|
224
|
+
logWarn(`Skipping a config binding because managed MCP removal failed: ${redactPathDetails(error.message)}`);
|
|
225
|
+
remainingBindings.push(binding);
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!(await existsOrLink(binding.targetPath))) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!(await bindingMatches(binding))) {
|
|
234
|
+
skipped += 1;
|
|
235
|
+
logWarn("Skipping a binding because it no longer matches managed metadata.");
|
|
236
|
+
remainingBindings.push(binding);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!dryRun) {
|
|
240
|
+
await fs.remove(binding.targetPath);
|
|
241
|
+
}
|
|
242
|
+
removed += 1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (dryRun) {
|
|
246
|
+
return { removed, skipped, remainingBindings };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (remainingBindings.length > 0) {
|
|
250
|
+
await writeJsonFile(statePath, {
|
|
251
|
+
...state,
|
|
252
|
+
updatedAt: new Date().toISOString(),
|
|
253
|
+
bindings: remainingBindings
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
await fs.remove(statePath);
|
|
257
|
+
}
|
|
258
|
+
return { removed, skipped, remainingBindings };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function applyBindings(profileName, options = {}) {
|
|
262
|
+
const { dryRun = false, quiet = false } = options;
|
|
263
|
+
const info = (message) => {
|
|
264
|
+
if (!quiet) {
|
|
265
|
+
logInfo(message);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const osName = detectOsName();
|
|
269
|
+
const runtimeInternalRoot = RUNTIME_INTERNAL_ROOT;
|
|
270
|
+
const bundlePath = path.join(runtimeInternalRoot, "common", "bundle.json");
|
|
271
|
+
const bundleMcpPath = path.join(runtimeInternalRoot, "common", "mcp.json");
|
|
272
|
+
const requestedProfile = typeof profileName === "string" && profileName.trim().length > 0 ? profileName.trim() : null;
|
|
273
|
+
const profileHint = requestedProfile ?? "<name>";
|
|
274
|
+
|
|
275
|
+
if (!(await fs.pathExists(bundlePath))) {
|
|
276
|
+
throw new Error(`Missing runtime bundle metadata.\nRun build first: skills-sync build --profile ${profileHint}`);
|
|
277
|
+
}
|
|
278
|
+
if (!(await fs.pathExists(bundleMcpPath))) {
|
|
279
|
+
throw new Error(`Missing runtime bundle MCP manifest.\nRun build first: skills-sync build --profile ${profileHint}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let bundle;
|
|
283
|
+
try {
|
|
284
|
+
bundle = await fs.readJson(bundlePath);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw new Error(`Failed to read runtime bundle metadata: ${error.message}`);
|
|
287
|
+
}
|
|
288
|
+
const bundleProfile = typeof bundle.profile === "string" && bundle.profile.trim().length > 0 ? bundle.profile.trim() : null;
|
|
289
|
+
const effectiveProfile = requestedProfile ?? bundleProfile;
|
|
290
|
+
if (!effectiveProfile) {
|
|
291
|
+
throw new Error("Could not determine profile for apply. Pass --profile <name> or rebuild runtime artifacts with profile metadata.");
|
|
292
|
+
}
|
|
293
|
+
if (!requestedProfile) {
|
|
294
|
+
info(`No --profile supplied. Using runtime bundle profile '${effectiveProfile}'.`);
|
|
295
|
+
} else {
|
|
296
|
+
info(`Using provided profile '${effectiveProfile}' for apply.`);
|
|
297
|
+
}
|
|
298
|
+
if (requestedProfile && bundleProfile && bundleProfile !== requestedProfile) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Runtime artifacts are stale for requested profile '${requestedProfile}'. Found bundle profile '${bundleProfile}'.\n` +
|
|
301
|
+
`Run build first: skills-sync build --profile ${requestedProfile}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const canonicalMcp = await fs.readJson(bundleMcpPath);
|
|
305
|
+
|
|
306
|
+
const effectiveTargets = await loadEffectiveTargets(osName);
|
|
307
|
+
const statePath = await getStatePath();
|
|
308
|
+
if (await fs.pathExists(statePath)) {
|
|
309
|
+
if (dryRun) {
|
|
310
|
+
info("Dry-run: existing state file detected. Apply would unlink previous managed bindings first.");
|
|
311
|
+
} else {
|
|
312
|
+
const unlinkResult = await unlinkInternal({ suppressNoStateMessage: true });
|
|
313
|
+
if (unlinkResult.remainingBindings.length > 0) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
"Cannot continue apply because some previous bindings could not be safely unlinked.\n" +
|
|
316
|
+
"Run unlink/doctor, resolve reported paths manually, then retry apply."
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const directorySpecs = getDirectoryBindingSpecs(effectiveTargets, runtimeInternalRoot);
|
|
323
|
+
const configSpecs = getConfigSpecs(effectiveTargets, runtimeInternalRoot);
|
|
324
|
+
const bindings = [];
|
|
325
|
+
const createdTargets = [];
|
|
326
|
+
const plannedActions = [];
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
for (const spec of directorySpecs) {
|
|
330
|
+
const sourcePath = path.resolve(spec.sourcePath);
|
|
331
|
+
if (!(await fs.pathExists(sourcePath))) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
"Source directory missing for apply.\n" +
|
|
334
|
+
`Run build first: skills-sync build --profile ${effectiveProfile}`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const targetPath = expandTargetPath(spec.targetRawPath, osName);
|
|
339
|
+
if (!dryRun) {
|
|
340
|
+
await fs.ensureDir(path.dirname(targetPath));
|
|
341
|
+
}
|
|
342
|
+
if (await existsOrLink(targetPath)) {
|
|
343
|
+
const adopted = dryRun
|
|
344
|
+
? await isAdoptableDirectoryBinding(sourcePath, targetPath)
|
|
345
|
+
: await adoptExistingDirectoryBinding({
|
|
346
|
+
tool: spec.tool,
|
|
347
|
+
sourcePath,
|
|
348
|
+
targetPath,
|
|
349
|
+
osName,
|
|
350
|
+
bindings
|
|
351
|
+
});
|
|
352
|
+
if (adopted) {
|
|
353
|
+
plannedActions.push({
|
|
354
|
+
tool: spec.tool,
|
|
355
|
+
kind: "dir",
|
|
356
|
+
sourcePath,
|
|
357
|
+
targetPath,
|
|
358
|
+
action: "adopt-existing"
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Codex skills target may already exist as a user-managed parent directory.
|
|
364
|
+
if (spec.tool === "codex") {
|
|
365
|
+
const targetLstat = await fs.lstat(targetPath);
|
|
366
|
+
if (targetLstat.isSymbolicLink()) {
|
|
367
|
+
throw new Error(formatUnmanagedPathError(targetPath, effectiveProfile));
|
|
368
|
+
}
|
|
369
|
+
const targetStats = await fs.stat(targetPath);
|
|
370
|
+
if (!targetStats.isDirectory()) {
|
|
371
|
+
throw new Error(formatUnmanagedPathError(targetPath, effectiveProfile));
|
|
372
|
+
}
|
|
373
|
+
await bindNestedDirectoryEntries({
|
|
374
|
+
tool: spec.tool,
|
|
375
|
+
sourcePath,
|
|
376
|
+
targetPath,
|
|
377
|
+
osName,
|
|
378
|
+
profileName: effectiveProfile,
|
|
379
|
+
bindings,
|
|
380
|
+
createdTargets,
|
|
381
|
+
dryRun,
|
|
382
|
+
plannedActions
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
throw new Error(formatUnmanagedPathError(targetPath, effectiveProfile));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!dryRun) {
|
|
390
|
+
const method = await createDirectoryBinding(sourcePath, targetPath, osName);
|
|
391
|
+
createdTargets.push(targetPath);
|
|
392
|
+
bindings.push({
|
|
393
|
+
tool: spec.tool,
|
|
394
|
+
kind: "dir",
|
|
395
|
+
targetPath,
|
|
396
|
+
sourcePath,
|
|
397
|
+
method,
|
|
398
|
+
hash: null,
|
|
399
|
+
managedBy: MANAGED_BY
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
plannedActions.push({
|
|
403
|
+
tool: spec.tool,
|
|
404
|
+
kind: "dir",
|
|
405
|
+
sourcePath,
|
|
406
|
+
targetPath,
|
|
407
|
+
action: "create-binding"
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
for (const spec of configSpecs) {
|
|
412
|
+
const sourcePath = path.resolve(spec.sourcePath);
|
|
413
|
+
if (!(await fs.pathExists(sourcePath))) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
"Source config missing for apply.\n" +
|
|
416
|
+
`Run build first: skills-sync build --profile ${effectiveProfile}`
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const targetPath = expandTargetPath(spec.targetRawPath, osName);
|
|
421
|
+
const result = await applyManagedMcpConfig({
|
|
422
|
+
tool: spec.tool,
|
|
423
|
+
targetPath,
|
|
424
|
+
canonicalMcp,
|
|
425
|
+
dryRun
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
plannedActions.push({
|
|
429
|
+
tool: spec.tool,
|
|
430
|
+
kind: "config",
|
|
431
|
+
sourcePath,
|
|
432
|
+
targetPath,
|
|
433
|
+
action: result.wouldWrite ? "update-config" : "no-change"
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
if (!dryRun) {
|
|
437
|
+
bindings.push({
|
|
438
|
+
tool: spec.tool,
|
|
439
|
+
kind: "config",
|
|
440
|
+
targetPath,
|
|
441
|
+
sourcePath,
|
|
442
|
+
method: result.method,
|
|
443
|
+
hash: result.hash,
|
|
444
|
+
managedNames: result.managedNames,
|
|
445
|
+
managedBy: MANAGED_BY
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (!dryRun) {
|
|
451
|
+
for (const createdTarget of createdTargets) {
|
|
452
|
+
if (await existsOrLink(createdTarget)) {
|
|
453
|
+
await fs.remove(createdTarget);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (dryRun) {
|
|
461
|
+
const byTool = new Map();
|
|
462
|
+
for (const action of plannedActions) {
|
|
463
|
+
byTool.set(action.tool, (byTool.get(action.tool) ?? 0) + 1);
|
|
464
|
+
}
|
|
465
|
+
info(`Dry-run apply for profile '${effectiveProfile}' complete. No files were modified.`);
|
|
466
|
+
for (const tool of Array.from(byTool.keys()).sort((left, right) => left.localeCompare(right))) {
|
|
467
|
+
info(` ${tool}: ${byTool.get(tool)} planned action(s)`);
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
dryRun: true,
|
|
471
|
+
profile: effectiveProfile,
|
|
472
|
+
os: osName,
|
|
473
|
+
plannedActions
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const stateDocument = {
|
|
478
|
+
managedBy: MANAGED_BY,
|
|
479
|
+
profile: effectiveProfile,
|
|
480
|
+
os: osName,
|
|
481
|
+
appliedAt: new Date().toISOString(),
|
|
482
|
+
bindings
|
|
483
|
+
};
|
|
484
|
+
await writeJsonFile(statePath, stateDocument);
|
|
485
|
+
|
|
486
|
+
info(`Apply complete for profile '${effectiveProfile}'.`);
|
|
487
|
+
info(`Target OS: ${osName}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function unlinkBindings(options = {}) {
|
|
491
|
+
const { dryRun = false } = options;
|
|
492
|
+
const { removed, skipped, remainingBindings } = await unlinkInternal({ dryRun });
|
|
493
|
+
if (dryRun) {
|
|
494
|
+
logInfo(`Dry-run unlink complete. Would remove ${removed} binding(s), skip ${skipped} binding(s).`);
|
|
495
|
+
return { dryRun: true, removed, skipped, remainingBindings };
|
|
496
|
+
}
|
|
497
|
+
logInfo(`Unlink complete. Removed ${removed} binding(s), skipped ${skipped} binding(s).`);
|
|
498
|
+
if (remainingBindings.length > 0) {
|
|
499
|
+
logWarn("State file still contains unresolved bindings. Run doctor for remediation steps.");
|
|
500
|
+
}
|
|
501
|
+
return { dryRun: false, removed, skipped, remainingBindings };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function cmdApply(profileName, options = {}) {
|
|
505
|
+
return applyBindings(profileName, options);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export async function cmdUnlink(options = {}) {
|
|
509
|
+
return unlinkBindings(options);
|
|
510
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RUNTIME_INTERNAL_ROOT, SCHEMAS, detectOsName, expandTargetPath, logInfo, logWarn } from "./core.js";
|
|
4
|
+
import { loadEffectiveTargets, loadPackSources, normalizeMcpManifest, resolvePack, resolveProfile } from "./config.js";
|
|
5
|
+
import {
|
|
6
|
+
collectSourcePlanning,
|
|
7
|
+
loadLockfile,
|
|
8
|
+
loadUpstreamsConfig,
|
|
9
|
+
resolveReferences,
|
|
10
|
+
saveLockfile
|
|
11
|
+
} from "./upstreams.js";
|
|
12
|
+
import { assertJsonFileMatchesSchema } from "./core.js";
|
|
13
|
+
import { buildBundle } from "./bundle.js";
|
|
14
|
+
import { projectCodexFromBundle } from "./adapters/codex.js";
|
|
15
|
+
import { projectClaudeFromBundle } from "./adapters/claude.js";
|
|
16
|
+
import { projectCursorFromBundle } from "./adapters/cursor.js";
|
|
17
|
+
import { projectCopilotFromBundle } from "./adapters/copilot.js";
|
|
18
|
+
import { projectGeminiFromBundle } from "./adapters/gemini.js";
|
|
19
|
+
|
|
20
|
+
export { collectImportedSkillEntries, collectLocalSkillEntries } from "./bundle.js";
|
|
21
|
+
|
|
22
|
+
function sleep(ms) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
setTimeout(resolve, ms);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function removeDirectoryRobust(targetPath) {
|
|
29
|
+
const maxAttempts = 6;
|
|
30
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
31
|
+
try {
|
|
32
|
+
await fs.rm(targetPath, {
|
|
33
|
+
recursive: true,
|
|
34
|
+
force: true,
|
|
35
|
+
maxRetries: 5,
|
|
36
|
+
retryDelay: 100
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const code = error?.code;
|
|
41
|
+
const retryable = code === "EPERM" || code === "ENOTEMPTY" || code === "EBUSY";
|
|
42
|
+
if (!retryable || attempt === maxAttempts) {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
await sleep(150 * attempt);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function buildProfile(profileName, options = {}) {
|
|
51
|
+
const { quiet = false, lockMode = "write" } = options;
|
|
52
|
+
|
|
53
|
+
const { profile } = await resolveProfile(profileName);
|
|
54
|
+
const packRoot = await resolvePack(profile);
|
|
55
|
+
|
|
56
|
+
const packManifestPath = path.join(packRoot, "pack.json");
|
|
57
|
+
await assertJsonFileMatchesSchema(packManifestPath, SCHEMAS.packManifest);
|
|
58
|
+
|
|
59
|
+
const mcpServersPath = path.join(packRoot, "mcp", "servers.json");
|
|
60
|
+
const mcpServersManifest = await assertJsonFileMatchesSchema(mcpServersPath, SCHEMAS.mcpServers);
|
|
61
|
+
const normalizedMcp = normalizeMcpManifest(mcpServersManifest);
|
|
62
|
+
|
|
63
|
+
const { sources } = await loadPackSources(packRoot);
|
|
64
|
+
const upstreams = await loadUpstreamsConfig();
|
|
65
|
+
const lockState = await loadLockfile();
|
|
66
|
+
|
|
67
|
+
const { references, skillImports } = collectSourcePlanning(sources, upstreams.byId);
|
|
68
|
+
const lockConfigByMode = {
|
|
69
|
+
read: {
|
|
70
|
+
preferPinned: true,
|
|
71
|
+
requirePinned: true,
|
|
72
|
+
updatePins: false,
|
|
73
|
+
allowLockUpdate: false
|
|
74
|
+
},
|
|
75
|
+
write: {
|
|
76
|
+
preferPinned: true,
|
|
77
|
+
requirePinned: false,
|
|
78
|
+
updatePins: false,
|
|
79
|
+
allowLockUpdate: true
|
|
80
|
+
},
|
|
81
|
+
refresh: {
|
|
82
|
+
preferPinned: false,
|
|
83
|
+
requirePinned: false,
|
|
84
|
+
updatePins: true,
|
|
85
|
+
allowLockUpdate: true
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const lockConfig = lockConfigByMode[lockMode];
|
|
89
|
+
if (!lockConfig) {
|
|
90
|
+
throw new Error(`Invalid lock mode '${lockMode}'. Use read, write, or refresh.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const resolvedReferences =
|
|
94
|
+
references.length > 0
|
|
95
|
+
? await resolveReferences({
|
|
96
|
+
references,
|
|
97
|
+
upstreamById: upstreams.byId,
|
|
98
|
+
lockState,
|
|
99
|
+
...lockConfig
|
|
100
|
+
})
|
|
101
|
+
: new Map();
|
|
102
|
+
|
|
103
|
+
if (lockState.changed && lockConfig.allowLockUpdate) {
|
|
104
|
+
await saveLockfile(lockState);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const runtimeInternalRoot = RUNTIME_INTERNAL_ROOT;
|
|
108
|
+
await removeDirectoryRobust(runtimeInternalRoot);
|
|
109
|
+
await fs.ensureDir(runtimeInternalRoot);
|
|
110
|
+
|
|
111
|
+
let localConfigPolicy = {};
|
|
112
|
+
try {
|
|
113
|
+
const osName = detectOsName();
|
|
114
|
+
const targets = await loadEffectiveTargets(osName);
|
|
115
|
+
localConfigPolicy = {
|
|
116
|
+
codex: {
|
|
117
|
+
path: expandTargetPath(targets.codex.mcpConfig, osName),
|
|
118
|
+
canOverride: Boolean(targets.codex?.canOverride)
|
|
119
|
+
},
|
|
120
|
+
claude: {
|
|
121
|
+
path: expandTargetPath(targets.claude.mcpConfig, osName),
|
|
122
|
+
canOverride: Boolean(targets.claude?.canOverride)
|
|
123
|
+
},
|
|
124
|
+
cursor: {
|
|
125
|
+
path: expandTargetPath(targets.cursor.mcpConfig, osName),
|
|
126
|
+
canOverride: Boolean(targets.cursor?.canOverride)
|
|
127
|
+
},
|
|
128
|
+
copilot: {
|
|
129
|
+
path: expandTargetPath(targets.copilot.mcpConfig, osName),
|
|
130
|
+
canOverride: Boolean(targets.copilot?.canOverride)
|
|
131
|
+
},
|
|
132
|
+
gemini: {
|
|
133
|
+
path: expandTargetPath(targets.gemini.mcpConfig, osName),
|
|
134
|
+
canOverride: Boolean(targets.gemini?.canOverride)
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
} catch {
|
|
138
|
+
if (!quiet) {
|
|
139
|
+
logWarn("Could not resolve local target config for merge-friendly projection seeding.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const bundle = await buildBundle({
|
|
144
|
+
profile,
|
|
145
|
+
packRoot,
|
|
146
|
+
skillImports,
|
|
147
|
+
resolvedReferences,
|
|
148
|
+
normalizedMcp,
|
|
149
|
+
runtimeInternalRoot
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const toolProjectors = [
|
|
153
|
+
{ tool: "codex", projector: projectCodexFromBundle },
|
|
154
|
+
{ tool: "claude", projector: projectClaudeFromBundle },
|
|
155
|
+
{ tool: "cursor", projector: projectCursorFromBundle },
|
|
156
|
+
{ tool: "copilot", projector: projectCopilotFromBundle },
|
|
157
|
+
{ tool: "gemini", projector: projectGeminiFromBundle }
|
|
158
|
+
];
|
|
159
|
+
for (const { tool, projector } of toolProjectors) {
|
|
160
|
+
await projector({
|
|
161
|
+
runtimeInternalRoot,
|
|
162
|
+
bundleSkillsPath: bundle.bundleSkillsPath,
|
|
163
|
+
bundleMcpPath: bundle.bundleMcpPath,
|
|
164
|
+
packRoot,
|
|
165
|
+
localConfigPath: localConfigPolicy[tool]?.path ?? null,
|
|
166
|
+
canOverride: localConfigPolicy[tool]?.canOverride ?? false
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!quiet) {
|
|
171
|
+
logInfo(`Build complete for profile '${profileName}'.`);
|
|
172
|
+
logInfo("Runtime artifacts refreshed.");
|
|
173
|
+
logInfo(`Resolved upstream refs: ${references.length}`);
|
|
174
|
+
logInfo(`Lock mode: ${lockMode}`);
|
|
175
|
+
if (lockMode === "read" && references.length > 0) {
|
|
176
|
+
logWarn("Build ran with --lock=read. No upstream pins were written.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
profile,
|
|
182
|
+
packRoot,
|
|
183
|
+
runtimeInternalRoot,
|
|
184
|
+
normalizedMcp,
|
|
185
|
+
sources,
|
|
186
|
+
skillEntries: bundle.skillEntries,
|
|
187
|
+
references,
|
|
188
|
+
resolvedReferences
|
|
189
|
+
};
|
|
190
|
+
}
|