@mearl/setup 3.0.0-beta.1 → 3.0.0-beta.10
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 +174 -156
- package/README.zh-CN.md +260 -0
- package/dist/browserExtension.js +24 -8
- package/dist/browserPreset.d.ts +20 -0
- package/dist/browserPreset.js +82 -0
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +43 -12
- package/dist/lifecycle.d.ts +10 -2
- package/dist/lifecycle.js +70 -13
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +51 -13
- package/dist/preset.d.ts +16 -5
- package/dist/preset.js +50 -13
- package/dist/presetStore.d.ts +2 -48
- package/dist/presetStore.js +1 -252
- package/dist/setupPreset.d.ts +9 -0
- package/dist/setupPreset.js +14 -0
- package/dist/setupVersion.js +1 -1
- package/dist/skillSourceReceipts.d.ts +25 -0
- package/dist/skillSourceReceipts.js +94 -0
- package/dist/skillSources.d.ts +7 -22
- package/dist/skillSources.js +43 -96
- package/dist/skills.js +8 -6
- package/package.json +16 -11
- package/dist/presetUpgrade.d.ts +0 -13
- package/dist/presetUpgrade.js +0 -42
package/dist/presetStore.js
CHANGED
|
@@ -1,252 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { constants, closeSync, fstatSync, lstatSync, linkSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join, resolve } from 'node:path';
|
|
5
|
-
import { withRecordLock } from '@mearl/daemon-core';
|
|
6
|
-
import { mergePresetDefaults, prepareNodeBundleSnapshot, prepareNodeCapabilitySnapshot, verifyNodeCapabilityActivation, validateNodeModuleConfig, resolvePresetModuleConfig, MAX_NODE_SELECTION_BYTES } from '@mearl/preset';
|
|
7
|
-
import { BundleError, MAX_BROWSER_STORAGE_BYTES, presetRequiresBrowser, validateBundleManifest, verifyPreset, verifyBrowserPreset, verifyPreparedPreset } from '@mearl/preset/bundles';
|
|
8
|
-
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
-
const hashPattern = /^[a-f0-9]{64}$/;
|
|
10
|
-
const MAX_RECEIPT_BYTES = 16 * 1024 * 1024;
|
|
11
|
-
const MAX_OVERRIDES_BYTES = 262_144;
|
|
12
|
-
const POINTER_BYTES = MAX_NODE_SELECTION_BYTES + 128;
|
|
13
|
-
function readBounded(file, limit) {
|
|
14
|
-
const before = lstatSync(file);
|
|
15
|
-
if (!before.isFile() || before.isSymbolicLink() || before.size > limit)
|
|
16
|
-
throw new Error('Invalid preset resource file or size');
|
|
17
|
-
const descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
18
|
-
try {
|
|
19
|
-
const stat = fstatSync(descriptor);
|
|
20
|
-
if (!stat.isFile() || stat.size > limit)
|
|
21
|
-
throw new Error('Invalid preset resource file or size');
|
|
22
|
-
const bytes = Buffer.alloc(Math.min(stat.size, limit) + 1);
|
|
23
|
-
let length = 0;
|
|
24
|
-
while (length < bytes.byteLength) {
|
|
25
|
-
const count = readSync(descriptor, bytes, length, bytes.byteLength - length, null);
|
|
26
|
-
if (count === 0)
|
|
27
|
-
break;
|
|
28
|
-
length += count;
|
|
29
|
-
}
|
|
30
|
-
if (length > stat.size)
|
|
31
|
-
throw new Error('Preset resource changed while reading');
|
|
32
|
-
return bytes.subarray(0, length);
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
closeSync(descriptor);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
function optionalFile(file, limit) {
|
|
39
|
-
try {
|
|
40
|
-
return readBounded(file, limit);
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
if (error.code === 'ENOENT')
|
|
44
|
-
return undefined;
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function atomicWrite(file, bytes) {
|
|
49
|
-
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
50
|
-
try {
|
|
51
|
-
writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
52
|
-
renameSync(temporary, file);
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
rmSync(temporary, { force: true });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function revision(state) {
|
|
59
|
-
return state.activation?.installationRevision ?? (state.activation ? `node:${state.activation.revision}` : null);
|
|
60
|
-
}
|
|
61
|
-
function overrides(value) {
|
|
62
|
-
const accepted = mergePresetDefaults({}, value);
|
|
63
|
-
if (Buffer.byteLength(JSON.stringify(accepted)) > MAX_OVERRIDES_BYTES)
|
|
64
|
-
throw new BundleError('BUNDLE_QUOTA', 'User overrides exceed 256 KiB');
|
|
65
|
-
return accepted;
|
|
66
|
-
}
|
|
67
|
-
/** One pointer binds the desired preset, user overrides and exact Node selection. */
|
|
68
|
-
export class PresetInstallationStore {
|
|
69
|
-
directory;
|
|
70
|
-
mearlApiVersion;
|
|
71
|
-
pointer;
|
|
72
|
-
constructor(options = {}) {
|
|
73
|
-
this.directory = resolve(options.directory ?? (process.env.MEARL_CAPABILITY_DIR || join(homedir(), '.mearl/capabilities')));
|
|
74
|
-
this.mearlApiVersion = options.mearlApiVersion ?? '1.0.0';
|
|
75
|
-
this.pointer = join(this.directory, 'active-node.json');
|
|
76
|
-
}
|
|
77
|
-
assertCurrent(expected) {
|
|
78
|
-
const bytes = optionalFile(this.pointer, POINTER_BYTES);
|
|
79
|
-
if ((bytes ? digest(bytes) : null) !== expected)
|
|
80
|
-
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed during preparation');
|
|
81
|
-
}
|
|
82
|
-
async current(options = {}) {
|
|
83
|
-
const raw = optionalFile(this.pointer, POINTER_BYTES);
|
|
84
|
-
if (!raw)
|
|
85
|
-
return { rawHash: null };
|
|
86
|
-
const rawHash = digest(raw);
|
|
87
|
-
const activation = await verifyNodeCapabilityActivation(JSON.parse(raw.toString('utf8')));
|
|
88
|
-
if (!activation.installationRevision)
|
|
89
|
-
return { rawHash, activation };
|
|
90
|
-
const captured = withRecordLock(this.pointer, () => {
|
|
91
|
-
this.assertCurrent(rawHash);
|
|
92
|
-
const receiptBytes = readBounded(join(this.directory, 'installations', `${activation.installationRevision}.json`), MAX_RECEIPT_BYTES);
|
|
93
|
-
const receipt = JSON.parse(receiptBytes.toString('utf8'));
|
|
94
|
-
const { revision: receiptRevision, ...data } = receipt;
|
|
95
|
-
if (receiptRevision !== activation.installationRevision || digest(JSON.stringify(data)) !== receiptRevision || data.schemaVersion !== 1 ||
|
|
96
|
-
Object.keys(data).some(key => !['schemaVersion', 'snapshot', 'bundles', 'browserRevision', 'nodeRevision', 'managedModules', 'nodeSyncRequired', 'browserSyncRequired', 'nodeConfig', 'overrides'].includes(key)) || typeof data.nodeSyncRequired !== 'boolean' || (data.browserSyncRequired !== undefined && typeof data.browserSyncRequired !== 'boolean') ||
|
|
97
|
-
data.nodeRevision !== activation.revision || !Array.isArray(data.bundles) || data.bundles.length > 64 ||
|
|
98
|
-
!Array.isArray(data.managedModules) || new Set(data.managedModules).size !== data.managedModules.length || data.managedModules.some(id => typeof id !== 'string' || !Object.hasOwn(activation.modules, id))) {
|
|
99
|
-
throw new BundleError('BUNDLE_INTEGRITY', 'Installed preset receipt does not match its selection');
|
|
100
|
-
}
|
|
101
|
-
const acceptedOverrides = overrides(data.overrides);
|
|
102
|
-
const nodeConfig = validateNodeModuleConfig(data.nodeConfig);
|
|
103
|
-
let total = 0;
|
|
104
|
-
const metadata = data.bundles.map(bundle => {
|
|
105
|
-
if (Object.keys(bundle).some(key => !['source', 'manifest', 'revision'].includes(key)))
|
|
106
|
-
throw new BundleError('BUNDLE_INVALID', 'Invalid installed package metadata');
|
|
107
|
-
const manifest = validateBundleManifest(bundle.manifest, this.mearlApiVersion);
|
|
108
|
-
total += Object.values(manifest.resources).reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
109
|
-
return { ...bundle, manifest };
|
|
110
|
-
});
|
|
111
|
-
if (total > MAX_BROWSER_STORAGE_BYTES)
|
|
112
|
-
throw new BundleError('BUNDLE_QUOTA', 'Installed preset resources exceed 64 MiB');
|
|
113
|
-
const bundles = options.verifyResources === false ? [] : metadata.map(bundle => ({ ...bundle, resources: Object.fromEntries(Object.entries(bundle.manifest.resources).map(([name, entry]) => [name, readBounded(join(this.directory, 'blobs', `${entry.sha256}.bin`), entry.byteLength)])) }));
|
|
114
|
-
for (const entry of options.verifyResources === false ? [] : Object.values(activation.modules)) {
|
|
115
|
-
const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
|
|
116
|
-
if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
|
|
117
|
-
throw new BundleError('BUNDLE_INTEGRITY', 'Installed Node object is missing or corrupt');
|
|
118
|
-
}
|
|
119
|
-
return { receipt: { ...receipt, overrides: acceptedOverrides, nodeConfig }, bundles };
|
|
120
|
-
});
|
|
121
|
-
if (options.verifyResources === false) {
|
|
122
|
-
const graph = await verifyPreset(captured.receipt.snapshot, this.mearlApiVersion);
|
|
123
|
-
await verifyBrowserPreset({ source: graph.snapshot.source, manifests: graph.snapshot.manifests,
|
|
124
|
-
bundleRevisions: Object.fromEntries(Object.entries(graph.snapshot.bundleRevisions).filter(([id]) => graph.preset.packages[id].runtimes?.includes('browser'))), revision: captured.receipt.browserRevision }, this.mearlApiVersion);
|
|
125
|
-
return { rawHash, activation, receipt: captured.receipt, preset: graph.preset };
|
|
126
|
-
}
|
|
127
|
-
const prepared = await verifyPreparedPreset({ snapshot: captured.receipt.snapshot, bundles: captured.bundles }, this.mearlApiVersion);
|
|
128
|
-
if (prepared.browser.snapshot.revision !== captured.receipt.browserRevision)
|
|
129
|
-
throw new BundleError('BUNDLE_INTEGRITY', 'Installed browser projection does not match');
|
|
130
|
-
const selected = await prepareNodeBundleSnapshot(prepared.bundles.filter(bundle => prepared.preset.packages[bundle.manifest.id].runtimes?.includes('node')), 'native-host', this.mearlApiVersion, resolvePresetModuleConfig(mergePresetDefaults(prepared.preset.defaults, captured.receipt.overrides), captured.receipt.nodeConfig));
|
|
131
|
-
if (Object.keys(selected.snapshot.modules).length !== captured.receipt.managedModules.length || captured.receipt.managedModules.some(id => !Object.hasOwn(selected.snapshot.modules, id)) ||
|
|
132
|
-
Object.entries(selected.snapshot.modules).some(([id, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.modules[id])) ||
|
|
133
|
-
Object.entries(selected.snapshot.bindings).some(([hook, entry]) => JSON.stringify(entry) !== JSON.stringify(activation.bindings[hook])) ||
|
|
134
|
-
Object.values(activation.bindings).filter(entry => captured.receipt.managedModules.includes(entry.module)).length !== Object.keys(selected.snapshot.bindings).length) {
|
|
135
|
-
throw new BundleError('BUNDLE_INTEGRITY', 'Installed managed Node selection does not match its packages');
|
|
136
|
-
}
|
|
137
|
-
return { rawHash, activation, prepared, receipt: captured.receipt, preset: prepared.preset };
|
|
138
|
-
}
|
|
139
|
-
async apply(input, options) {
|
|
140
|
-
options.signal?.throwIfAborted();
|
|
141
|
-
const acceptedOverrides = options.overrides === undefined ? undefined : overrides(options.overrides);
|
|
142
|
-
const nodeConfig = options.nodeConfig === undefined ? undefined : validateNodeModuleConfig(options.nodeConfig);
|
|
143
|
-
const [current, prepared] = await Promise.all([this.current({ verifyResources: false }), verifyPreparedPreset(input, this.mearlApiVersion)]);
|
|
144
|
-
if (revision(current) !== options.expectedRevision)
|
|
145
|
-
throw new BundleError('BUNDLE_CONFLICT', 'The installed preset changed');
|
|
146
|
-
const configured = nodeConfig ?? current.receipt?.nodeConfig ?? Object.fromEntries(Object.entries(current.activation?.modules ?? {}).filter(([, entry]) => entry.config !== undefined).map(([id, entry]) => [id, entry.config]));
|
|
147
|
-
const selected = await prepareNodeBundleSnapshot(prepared.bundles.filter(bundle => prepared.preset.packages[bundle.manifest.id].runtimes?.includes('node')), 'native-host', this.mearlApiVersion, resolvePresetModuleConfig(mergePresetDefaults(prepared.preset.defaults, acceptedOverrides ?? current.receipt?.overrides ?? {}), configured));
|
|
148
|
-
const previousManaged = new Set(current.receipt?.managedModules ?? []);
|
|
149
|
-
const unmanaged = Object.fromEntries(Object.entries(current.activation?.modules ?? {}).filter(([id]) => !previousManaged.has(id)));
|
|
150
|
-
for (const entry of Object.values(unmanaged)) {
|
|
151
|
-
const bytes = readBounded(join(this.directory, 'objects', `${entry.sha256}.cjs`), entry.byteLength);
|
|
152
|
-
if (bytes.byteLength !== entry.byteLength || digest(bytes) !== entry.sha256)
|
|
153
|
-
throw new BundleError('BUNDLE_INTEGRITY', 'An independent Node module is corrupt');
|
|
154
|
-
}
|
|
155
|
-
if (Object.keys(selected.snapshot.modules).some(id => Object.hasOwn(unmanaged, id)))
|
|
156
|
-
throw new BundleError('BUNDLE_CONFLICT', 'A preset module conflicts with an independently installed Node module');
|
|
157
|
-
const bindings = Object.fromEntries(Object.entries(current.activation?.bindings ?? {}).filter(([, entry]) => Object.hasOwn(unmanaged, entry.module)));
|
|
158
|
-
if (Object.keys(selected.snapshot.bindings).some(hook => Object.hasOwn(bindings, hook)))
|
|
159
|
-
throw new BundleError('BUNDLE_CONFLICT', 'A preset hook conflicts with an independently installed Node module');
|
|
160
|
-
const snapshot = await prepareNodeCapabilitySnapshot({ schemaVersion: 1, modules: { ...unmanaged, ...selected.snapshot.modules }, bindings: { ...bindings, ...selected.snapshot.bindings } });
|
|
161
|
-
const data = {
|
|
162
|
-
schemaVersion: 1, snapshot: prepared.snapshot,
|
|
163
|
-
bundles: prepared.bundles.map(({ source, revision, manifest }) => ({ source, revision, manifest })),
|
|
164
|
-
browserRevision: prepared.browser.snapshot.revision, nodeRevision: snapshot.revision,
|
|
165
|
-
managedModules: Object.keys(selected.snapshot.modules), overrides: acceptedOverrides ?? current.receipt?.overrides ?? {},
|
|
166
|
-
nodeSyncRequired: Boolean(current.receipt?.nodeSyncRequired || current.receipt?.managedModules.length || Object.values(prepared.preset.packages).some(entry => entry.enabled !== false && entry.runtimes?.includes('node'))),
|
|
167
|
-
browserSyncRequired: Boolean(current.receipt?.browserSyncRequired || presetRequiresBrowser(prepared.preset)),
|
|
168
|
-
nodeConfig: configured,
|
|
169
|
-
};
|
|
170
|
-
const receipt = { ...data, revision: digest(JSON.stringify(data)) };
|
|
171
|
-
const receiptBytes = JSON.stringify(receipt);
|
|
172
|
-
if (Buffer.byteLength(receiptBytes) > MAX_RECEIPT_BYTES)
|
|
173
|
-
throw new BundleError('BUNDLE_QUOTA', 'Preset installation receipt exceeds 16 MiB');
|
|
174
|
-
const activation = { ...snapshot, installationRevision: receipt.revision };
|
|
175
|
-
await options.confirm?.();
|
|
176
|
-
withRecordLock(this.pointer, () => {
|
|
177
|
-
this.assertCurrent(current.rawHash);
|
|
178
|
-
options.signal?.throwIfAborted();
|
|
179
|
-
for (const child of ['blobs', 'objects', 'installations'])
|
|
180
|
-
mkdirSync(join(this.directory, child), { recursive: true, mode: 0o700 });
|
|
181
|
-
for (const bundle of prepared.bundles)
|
|
182
|
-
for (const [name, entry] of Object.entries(bundle.manifest.resources)) {
|
|
183
|
-
atomicWrite(join(this.directory, 'blobs', `${entry.sha256}.bin`), bundle.resources[name]);
|
|
184
|
-
}
|
|
185
|
-
for (const entry of Object.values(selected.snapshot.modules)) {
|
|
186
|
-
const destination = join(this.directory, 'objects', `${entry.sha256}.cjs`);
|
|
187
|
-
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
188
|
-
try {
|
|
189
|
-
linkSync(join(this.directory, 'blobs', `${entry.sha256}.bin`), temporary);
|
|
190
|
-
renameSync(temporary, destination);
|
|
191
|
-
}
|
|
192
|
-
finally {
|
|
193
|
-
rmSync(temporary, { force: true });
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
atomicWrite(join(this.directory, 'installations', `${receipt.revision}.json`), receiptBytes);
|
|
197
|
-
if (current.receipt?.revision !== receipt.revision)
|
|
198
|
-
atomicWrite(join(this.directory, 'rollback.json'), JSON.stringify({ candidate: receipt.revision, previous: current.activation ?? null }));
|
|
199
|
-
atomicWrite(this.pointer, JSON.stringify(activation));
|
|
200
|
-
});
|
|
201
|
-
return receipt;
|
|
202
|
-
}
|
|
203
|
-
async rollback(expectedRevision) {
|
|
204
|
-
const current = await this.current({ verifyResources: false });
|
|
205
|
-
if (current.activation?.installationRevision !== expectedRevision)
|
|
206
|
-
throw new BundleError('BUNDLE_CONFLICT', 'A newer preset installation cannot be rolled back');
|
|
207
|
-
const record = JSON.parse(readBounded(join(this.directory, 'rollback.json'), POINTER_BYTES + 256).toString('utf8'));
|
|
208
|
-
if (record.candidate !== expectedRevision)
|
|
209
|
-
throw new BundleError('BUNDLE_CONFLICT', 'The preset rollback record has changed');
|
|
210
|
-
const previous = record.previous === null ? undefined : await verifyNodeCapabilityActivation(record.previous);
|
|
211
|
-
withRecordLock(this.pointer, () => {
|
|
212
|
-
this.assertCurrent(current.rawHash);
|
|
213
|
-
if (previous)
|
|
214
|
-
atomicWrite(this.pointer, JSON.stringify(previous));
|
|
215
|
-
else
|
|
216
|
-
rmSync(this.pointer, { force: true });
|
|
217
|
-
rmSync(join(this.directory, 'rollback.json'), { force: true });
|
|
218
|
-
this.collectLocked(previous);
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
async collectGarbage() {
|
|
222
|
-
const current = await this.current({ verifyResources: false });
|
|
223
|
-
const raw = optionalFile(join(this.directory, 'rollback.json'), POINTER_BYTES + 256);
|
|
224
|
-
const record = raw && JSON.parse(raw.toString('utf8'));
|
|
225
|
-
const previous = record?.candidate === current.activation?.installationRevision && record.previous !== null ? await verifyNodeCapabilityActivation(record.previous) : undefined;
|
|
226
|
-
withRecordLock(this.pointer, () => {
|
|
227
|
-
this.assertCurrent(current.rawHash);
|
|
228
|
-
for (const child of ['blobs', 'objects', 'installations'])
|
|
229
|
-
mkdirSync(join(this.directory, child), { recursive: true, mode: 0o700 });
|
|
230
|
-
this.collectLocked(current.activation, previous);
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
collectLocked(active, previous) {
|
|
234
|
-
const receipts = new Set([active?.installationRevision, previous?.installationRevision].filter((value) => Boolean(value)));
|
|
235
|
-
const blobs = new Set();
|
|
236
|
-
const modules = new Set([active, previous].flatMap(selection => Object.values(selection?.modules ?? {}).map(entry => entry.sha256)));
|
|
237
|
-
for (const id of receipts) {
|
|
238
|
-
const record = JSON.parse(readBounded(join(this.directory, 'installations', `${id}.json`), MAX_RECEIPT_BYTES).toString('utf8'));
|
|
239
|
-
for (const bundle of record.bundles)
|
|
240
|
-
for (const entry of Object.values(bundle.manifest.resources))
|
|
241
|
-
blobs.add(entry.sha256);
|
|
242
|
-
}
|
|
243
|
-
for (const [directory, extension, retained] of [['blobs', '.bin', blobs], ['objects', '.cjs', modules], ['installations', '.json', receipts]]) {
|
|
244
|
-
for (const name of readdirSync(join(this.directory, directory))) {
|
|
245
|
-
const id = name.slice(0, -extension.length);
|
|
246
|
-
if (name.endsWith(extension) && hashPattern.test(id) && !retained.has(id))
|
|
247
|
-
rmSync(join(this.directory, directory, name), { force: true });
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
export function presetSelectionRevision(state) { return revision(state); }
|
|
1
|
+
export { PresetInstallationStore, presetSelectionRevision } from '@mearl/daemon-core/installation';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { PresetInstaller, type StagedPresetInstallation } from './preset.js';
|
|
2
|
+
/** Preserve the accepted installation and resume its exact resources before Skill operations. */
|
|
3
|
+
export declare function prepareSetupPreset(options: {
|
|
4
|
+
source?: string;
|
|
5
|
+
fallbackSource?: string;
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
}, dependencies?: {
|
|
8
|
+
installer?: PresetInstaller;
|
|
9
|
+
}): Promise<StagedPresetInstallation | undefined>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PresetInstaller } from './preset.js';
|
|
2
|
+
/** Preserve the accepted installation and resume its exact resources before Skill operations. */
|
|
3
|
+
export async function prepareSetupPreset(options, dependencies = {}) {
|
|
4
|
+
const installer = dependencies.installer ?? new PresetInstaller({ directory: (options.env ?? process.env).MEARL_CAPABILITY_DIR });
|
|
5
|
+
if (options.source)
|
|
6
|
+
return installer.stage(options.source);
|
|
7
|
+
const current = await installer.store.current();
|
|
8
|
+
if (current.receipt)
|
|
9
|
+
return { revision: current.receipt.revision, activate: () => installer.resume(current.receipt.revision) };
|
|
10
|
+
if (current.activation)
|
|
11
|
+
return;
|
|
12
|
+
if (options.fallbackSource)
|
|
13
|
+
return installer.stage(options.fallbackSource);
|
|
14
|
+
}
|
package/dist/setupVersion.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
const require = createRequire(import.meta.url);
|
|
3
3
|
export const SETUP_VERSION = require('../package.json').version;
|
|
4
|
-
export const SETUP_COMMAND = `npx @mearl/setup${SETUP_VERSION.includes('-beta.') ? '@'
|
|
4
|
+
export const SETUP_COMMAND = `npx @mearl/setup${SETUP_VERSION.includes('-beta.') ? '@beta' : ''}`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type SkillSourceScope = 'global' | 'project';
|
|
2
|
+
export interface TrackedSkillSource {
|
|
3
|
+
name: string;
|
|
4
|
+
source: string;
|
|
5
|
+
scope: SkillSourceScope;
|
|
6
|
+
ref?: string;
|
|
7
|
+
skillPath?: string;
|
|
8
|
+
sourceType?: string;
|
|
9
|
+
expectedHash?: string;
|
|
10
|
+
hashKind: 'git-tree' | 'folder';
|
|
11
|
+
}
|
|
12
|
+
export interface SourceOptions {
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
homeDirectory?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function readTrackedSkillSources(scope: SkillSourceScope, cwd?: string, options?: SourceOptions): TrackedSkillSource[];
|
|
17
|
+
/** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
|
|
18
|
+
export declare function isPublicSkillSource(source: TrackedSkillSource): boolean;
|
|
19
|
+
export declare function gitSource(source: string, ref?: string): {
|
|
20
|
+
repository: string;
|
|
21
|
+
ref?: string;
|
|
22
|
+
subdirectory?: string;
|
|
23
|
+
};
|
|
24
|
+
/** Compare the manager's existing source receipt; no network access or second lockfile. */
|
|
25
|
+
export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string): boolean;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
function lockFile(scope, cwd, options) {
|
|
6
|
+
const env = options.env ?? process.env;
|
|
7
|
+
return scope === 'project' ? join(cwd, 'skills-lock.json') : env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
|
|
8
|
+
}
|
|
9
|
+
export function readTrackedSkillSources(scope, cwd, options = {}) {
|
|
10
|
+
const file = lockFile(scope, cwd, options);
|
|
11
|
+
let text;
|
|
12
|
+
try {
|
|
13
|
+
if (statSync(file).size > 4 * 1024 * 1024)
|
|
14
|
+
throw new Error('Skill source lock exceeds 4 MiB');
|
|
15
|
+
text = readFileSync(file, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error.code === 'ENOENT')
|
|
19
|
+
return [];
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const lock = JSON.parse(text);
|
|
23
|
+
if (!lock || !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills))
|
|
24
|
+
throw new Error('Invalid Skills source lock');
|
|
25
|
+
return Object.entries(lock.skills).flatMap(([name, entry]) => {
|
|
26
|
+
if (!entry || typeof entry !== 'object' || entry.sourceType === 'node_modules')
|
|
27
|
+
return [];
|
|
28
|
+
if (!name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
|
|
29
|
+
throw new Error('Invalid tracked Skill name');
|
|
30
|
+
const source = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : entry.source;
|
|
31
|
+
if (typeof source !== 'string' || !source.trim())
|
|
32
|
+
return [];
|
|
33
|
+
if (source.length > 4096 || /[\0\r\n]/.test(source))
|
|
34
|
+
throw new Error('Invalid tracked Skill source');
|
|
35
|
+
const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
|
|
36
|
+
return [{ name, source, scope, ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}), ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}), ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}), ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}), hashKind: scope === 'global' && typeof expectedHash === 'string' && /^[a-f0-9]{40}$/.test(expectedHash) ? 'git-tree' : 'folder' }];
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
|
|
40
|
+
export function isPublicSkillSource(source) {
|
|
41
|
+
return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
|
|
42
|
+
}
|
|
43
|
+
export function gitSource(source, ref) {
|
|
44
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(source))
|
|
45
|
+
return { repository: `https://github.com/${source}.git`, ...(ref ? { ref } : {}) };
|
|
46
|
+
if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
|
|
47
|
+
return { repository: source, ...(ref ? { ref } : {}) };
|
|
48
|
+
const url = new URL(source);
|
|
49
|
+
if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
|
|
50
|
+
throw new Error('Skill source is not a supported Git repository');
|
|
51
|
+
if (url.hostname === 'github.com' && !url.port) {
|
|
52
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
53
|
+
if (parts.length >= 2)
|
|
54
|
+
return { repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`, ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3]) ? { ref: ref || decodeURIComponent(parts[3]) } : {}), ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4 ? { subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, '') } : {}) };
|
|
55
|
+
}
|
|
56
|
+
return { repository: url.href, ...(ref ? { ref } : {}) };
|
|
57
|
+
}
|
|
58
|
+
/** Compare the manager's existing source receipt; no network access or second lockfile. */
|
|
59
|
+
export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
|
|
60
|
+
const location = (source, ref, sourceType) => {
|
|
61
|
+
const value = normalize(source, sourceType);
|
|
62
|
+
if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
|
|
63
|
+
throw new Error('Invalid normalized Skill source');
|
|
64
|
+
const hash = value.indexOf('#');
|
|
65
|
+
const fragment = hash < 0 ? undefined : value.slice(hash + 1);
|
|
66
|
+
const plain = hash < 0 ? value : value.slice(0, hash);
|
|
67
|
+
const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
|
|
68
|
+
let repository = parsed.repository;
|
|
69
|
+
if (repository.startsWith('file:'))
|
|
70
|
+
repository = fileURLToPath(repository);
|
|
71
|
+
if (isAbsolute(repository))
|
|
72
|
+
repository = resolve(repository);
|
|
73
|
+
else {
|
|
74
|
+
const ssh = repository.match(/^[\w.-]+@([\w.-]+):(.+)$/);
|
|
75
|
+
const url = new URL(ssh ? `ssh://${ssh[1]}/${ssh[2]}` : repository);
|
|
76
|
+
// Authentication transport is independent of the repository's identity.
|
|
77
|
+
repository = `${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
|
|
78
|
+
}
|
|
79
|
+
return { ...parsed, repository, ref: parsed.ref || undefined };
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
const expected = location(requested);
|
|
83
|
+
const actual = location(tracked.source, tracked.ref, tracked.sourceType);
|
|
84
|
+
if (expected.repository !== actual.repository || expected.ref !== actual.ref)
|
|
85
|
+
return false;
|
|
86
|
+
if (!expected.subdirectory)
|
|
87
|
+
return true;
|
|
88
|
+
const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
|
|
89
|
+
return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
|
|
93
|
+
}
|
|
94
|
+
}
|
package/dist/skillSources.d.ts
CHANGED
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
import { type CommandRunner } from '@mearl/daemon-core';
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
source: string;
|
|
6
|
-
scope: SkillSourceScope;
|
|
7
|
-
ref?: string;
|
|
8
|
-
skillPath?: string;
|
|
9
|
-
sourceType?: string;
|
|
10
|
-
expectedHash?: string;
|
|
11
|
-
hashKind: 'git-tree' | 'folder';
|
|
12
|
-
}
|
|
13
|
-
interface SourceOptions {
|
|
14
|
-
env?: NodeJS.ProcessEnv;
|
|
15
|
-
homeDirectory?: string;
|
|
16
|
-
}
|
|
17
|
-
export declare function readTrackedSkillSources(scope: SkillSourceScope, cwd?: string, options?: SourceOptions): TrackedSkillSource[];
|
|
18
|
-
/** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
|
|
19
|
-
export declare function isPublicSkillSource(source: TrackedSkillSource): boolean;
|
|
20
|
-
/** Compare the manager's existing source receipt; no network access or second lockfile. */
|
|
21
|
-
export declare function matchesTrackedSkillSource(requested: string, tracked: TrackedSkillSource, normalize?: (source: string, sourceType?: string) => string): boolean;
|
|
2
|
+
import { readTrackedSkillSources, type SourceOptions, type SkillSourceScope, type TrackedSkillSource } from './skillSourceReceipts.js';
|
|
3
|
+
export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
|
|
4
|
+
export type { SkillSourceScope, TrackedSkillSource } from './skillSourceReceipts.js';
|
|
22
5
|
/** Matches the public Skills folder hash without writing to the installed Skill. */
|
|
23
6
|
export declare function hashSkillDirectory(directory: string, ignoreRuntimeFiles?: boolean): Promise<string>;
|
|
24
7
|
/** Checks source content in a temporary checkout; never invokes the mutating Skills `check` alias. */
|
|
@@ -31,16 +14,18 @@ export declare function checkTrackedSkillUpdates(options?: SourceOptions & {
|
|
|
31
14
|
scope: SkillSourceScope;
|
|
32
15
|
}>>;
|
|
33
16
|
export interface SkillInstallationStatus {
|
|
34
|
-
state: 'ready' | 'missing' | 'version' | 'registration' | 'conflict';
|
|
17
|
+
state: 'ready' | 'missing' | 'version' | 'registration' | 'migration' | 'conflict';
|
|
35
18
|
reason?: string;
|
|
36
19
|
}
|
|
37
20
|
/** Developer links keep their Git checkout as the source receipt without changing a manager's lock. */
|
|
38
21
|
export declare function readLinkedSkillSource(directory: string, name: string): TrackedSkillSource | undefined;
|
|
22
|
+
/** Keep the user's disabled state when an installer replaces the Skill directory. */
|
|
23
|
+
export declare function preserveSkillDisabled<T>(directory: string, operation: () => Promise<T>): Promise<T>;
|
|
39
24
|
/** CLI and Agent readiness use the same source, manifest and registration evidence. */
|
|
40
25
|
export declare function inspectGlobalSkill(source: string, name: string, version?: string, options?: {
|
|
41
26
|
homeDirectory?: string;
|
|
42
27
|
readSources?: typeof readTrackedSkillSources;
|
|
43
28
|
externalManager?: boolean;
|
|
29
|
+
migrateFrom?: readonly string[];
|
|
44
30
|
normalizeSource?: (source: string, sourceType?: string) => string;
|
|
45
31
|
}): SkillInstallationStatus;
|
|
46
|
-
export {};
|
package/dist/skillSources.js
CHANGED
|
@@ -1,104 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdtemp, readFile, readdir, realpath, rm, stat } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { lstatSync, realpathSync, renameSync } from 'node:fs';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { homedir, tmpdir } from 'node:os';
|
|
6
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
7
|
import { runCommand } from '@mearl/daemon-core';
|
|
9
8
|
import { satisfies, valid } from 'semver';
|
|
10
|
-
import { DISABLED_SKILL_MANIFEST, findPluginDirectoryEntry, findPluginRegistration, listPluginNames, OFFICIAL_PLUGIN_MANAGER, pluginRegistrationNeedsRefresh, readInstalledSkillMetadata } from '@mearl/client/plugin';
|
|
9
|
+
import { DISABLED_SKILL_MANIFEST, getSkillEnabled, findPluginDirectoryEntry, findPluginRegistration, listPluginNames, OFFICIAL_PLUGIN_MANAGER, pluginRegistrationNeedsRefresh, readInstalledSkillMetadata } from '@mearl/client/plugin';
|
|
11
10
|
import { existsSync } from 'node:fs';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return scope === 'project' ? join(cwd, 'skills-lock.json') : env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, 'skills', '.skill-lock.json') : join(options.homeDirectory ?? homedir(), '.agents', '.skill-lock.json');
|
|
15
|
-
}
|
|
16
|
-
export function readTrackedSkillSources(scope, cwd, options = {}) {
|
|
17
|
-
const file = lockFile(scope, cwd, options);
|
|
18
|
-
let text;
|
|
19
|
-
try {
|
|
20
|
-
if (statSync(file).size > 4 * 1024 * 1024)
|
|
21
|
-
throw new Error('Skill source lock exceeds 4 MiB');
|
|
22
|
-
text = readFileSync(file, 'utf8');
|
|
23
|
-
}
|
|
24
|
-
catch (error) {
|
|
25
|
-
if (error.code === 'ENOENT')
|
|
26
|
-
return [];
|
|
27
|
-
throw error;
|
|
28
|
-
}
|
|
29
|
-
const lock = JSON.parse(text);
|
|
30
|
-
if (!lock || !lock.skills || typeof lock.skills !== 'object' || Array.isArray(lock.skills))
|
|
31
|
-
throw new Error('Invalid Skills source lock');
|
|
32
|
-
return Object.entries(lock.skills).flatMap(([name, entry]) => {
|
|
33
|
-
if (!entry || typeof entry !== 'object' || entry.sourceType === 'node_modules')
|
|
34
|
-
return [];
|
|
35
|
-
if (!name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
|
|
36
|
-
throw new Error('Invalid tracked Skill name');
|
|
37
|
-
const source = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : entry.source;
|
|
38
|
-
if (typeof source !== 'string' || !source.trim())
|
|
39
|
-
return [];
|
|
40
|
-
if (source.length > 4096 || /[\0\r\n]/.test(source))
|
|
41
|
-
throw new Error('Invalid tracked Skill source');
|
|
42
|
-
const expectedHash = scope === 'global' ? entry.skillFolderHash : entry.computedHash;
|
|
43
|
-
return [{ name, source, scope, ...(typeof entry.sourceType === 'string' ? { sourceType: entry.sourceType } : {}), ...(typeof entry.ref === 'string' ? { ref: entry.ref } : {}), ...(typeof entry.skillPath === 'string' ? { skillPath: entry.skillPath } : {}), ...(typeof expectedHash === 'string' && expectedHash ? { expectedHash } : {}), hashKind: scope === 'global' && typeof expectedHash === 'string' && /^[a-f0-9]{40}$/.test(expectedHash) ? 'git-tree' : 'folder' }];
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
/** Custom source kinds require their installed backend, even if they use owner/repo syntax. */
|
|
47
|
-
export function isPublicSkillSource(source) {
|
|
48
|
-
return source.sourceType === undefined || ['github', 'gitlab', 'git', 'local', 'well-known'].includes(source.sourceType);
|
|
49
|
-
}
|
|
50
|
-
function gitSource(source, ref) {
|
|
51
|
-
if (/^[\w.-]+\/[\w.-]+$/.test(source))
|
|
52
|
-
return { repository: `https://github.com/${source}.git`, ...(ref ? { ref } : {}) };
|
|
53
|
-
if (/^[\w.-]+@[\w.-]+:[^\s]+$/.test(source))
|
|
54
|
-
return { repository: source, ...(ref ? { ref } : {}) };
|
|
55
|
-
const url = new URL(source);
|
|
56
|
-
if (!['https:', 'http:', 'ssh:', 'git:', 'file:'].includes(url.protocol) || url.password || (url.username && url.protocol !== 'ssh:') || url.search || url.hash)
|
|
57
|
-
throw new Error('Skill source is not a supported Git repository');
|
|
58
|
-
if (url.hostname === 'github.com' && !url.port) {
|
|
59
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
60
|
-
if (parts.length >= 2)
|
|
61
|
-
return { repository: `https://github.com/${parts[0]}/${parts[1].replace(/\.git$/, '')}.git`, ...(ref || (['tree', 'blob'].includes(parts[2]) && parts[3]) ? { ref: ref || decodeURIComponent(parts[3]) } : {}), ...(['tree', 'blob'].includes(parts[2]) && parts.length > 4 ? { subdirectory: decodeURIComponent(parts.slice(4).join('/')).replace(/\/SKILL\.md$/, '') } : {}) };
|
|
62
|
-
}
|
|
63
|
-
return { repository: url.href, ...(ref ? { ref } : {}) };
|
|
64
|
-
}
|
|
65
|
-
/** Compare the manager's existing source receipt; no network access or second lockfile. */
|
|
66
|
-
export function matchesTrackedSkillSource(requested, tracked, normalize = source => source) {
|
|
67
|
-
const location = (source, ref, sourceType) => {
|
|
68
|
-
const value = normalize(source, sourceType);
|
|
69
|
-
if (typeof value !== 'string' || !value || value.length > 8192 || /[\0\r\n]/.test(value))
|
|
70
|
-
throw new Error('Invalid normalized Skill source');
|
|
71
|
-
const hash = value.indexOf('#');
|
|
72
|
-
const fragment = hash < 0 ? undefined : value.slice(hash + 1);
|
|
73
|
-
const plain = hash < 0 ? value : value.slice(0, hash);
|
|
74
|
-
const parsed = isAbsolute(plain) ? { repository: plain, ref: ref ?? fragment } : gitSource(plain, ref ?? fragment);
|
|
75
|
-
let repository = parsed.repository;
|
|
76
|
-
if (repository.startsWith('file:'))
|
|
77
|
-
repository = fileURLToPath(repository);
|
|
78
|
-
if (isAbsolute(repository))
|
|
79
|
-
repository = resolve(repository);
|
|
80
|
-
else {
|
|
81
|
-
const ssh = repository.match(/^[\w.-]+@([\w.-]+):(.+)$/);
|
|
82
|
-
const url = new URL(ssh ? `ssh://${ssh[1]}/${ssh[2]}` : repository);
|
|
83
|
-
// Authentication transport is independent of the repository's identity.
|
|
84
|
-
repository = `${url.hostname.toLowerCase()}${url.port ? ':' + url.port : ''}/${url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}`;
|
|
85
|
-
}
|
|
86
|
-
return { ...parsed, repository, ref: parsed.ref || undefined };
|
|
87
|
-
};
|
|
88
|
-
try {
|
|
89
|
-
const expected = location(requested);
|
|
90
|
-
const actual = location(tracked.source, tracked.ref, tracked.sourceType);
|
|
91
|
-
if (expected.repository !== actual.repository || expected.ref !== actual.ref)
|
|
92
|
-
return false;
|
|
93
|
-
if (!expected.subdirectory)
|
|
94
|
-
return true;
|
|
95
|
-
const path = (tracked.skillPath ?? actual.subdirectory ?? '').replace(/(?:^|\/)SKILL\.md$/, '').replace(/\/+$/, '');
|
|
96
|
-
return path === expected.subdirectory || path.startsWith(expected.subdirectory + '/');
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
return normalize(requested) === normalize(tracked.source, tracked.sourceType) && !tracked.ref;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
11
|
+
import { gitSource, isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
|
|
12
|
+
export { isPublicSkillSource, matchesTrackedSkillSource, readTrackedSkillSources } from './skillSourceReceipts.js';
|
|
102
13
|
function skillName(text) {
|
|
103
14
|
return text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1].match(/^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m)?.[1]?.trim();
|
|
104
15
|
}
|
|
@@ -237,6 +148,35 @@ export function readLinkedSkillSource(directory, name) {
|
|
|
237
148
|
return undefined;
|
|
238
149
|
}
|
|
239
150
|
}
|
|
151
|
+
/** Keep the user's disabled state when an installer replaces the Skill directory. */
|
|
152
|
+
export async function preserveSkillDisabled(directory, operation) {
|
|
153
|
+
const disabled = getSkillEnabled(directory) === false;
|
|
154
|
+
try {
|
|
155
|
+
return await operation();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
if (disabled && existsSync(join(directory, 'SKILL.md')))
|
|
159
|
+
renameSync(join(directory, 'SKILL.md'), join(directory, DISABLED_SKILL_MANIFEST));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function canonicalPath(path) {
|
|
163
|
+
const absolute = resolve(path);
|
|
164
|
+
try {
|
|
165
|
+
return realpathSync(absolute);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
const parent = dirname(absolute);
|
|
169
|
+
return parent === absolute ? absolute : join(canonicalPath(parent), basename(absolute));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function hasOnlySetupRegistrations(directory) {
|
|
173
|
+
const contains = (root, entry) => {
|
|
174
|
+
const path = relative(root, entry);
|
|
175
|
+
return path !== '' && path !== '..' && !path.startsWith('..' + sep) && !isAbsolute(path);
|
|
176
|
+
};
|
|
177
|
+
const registrations = listPluginNames().flatMap(name => findPluginRegistration(name) ?? []).filter(registration => contains(resolve(directory), resolve(registration.entry)) || contains(canonicalPath(directory), canonicalPath(registration.entry)));
|
|
178
|
+
return registrations.length > 0 && registrations.every(registration => registration.managedBy === OFFICIAL_PLUGIN_MANAGER);
|
|
179
|
+
}
|
|
240
180
|
/** CLI and Agent readiness use the same source, manifest and registration evidence. */
|
|
241
181
|
export function inspectGlobalSkill(source, name, version, options = {}) {
|
|
242
182
|
if (!source.trim() || !name || name === '.' || name === '..' || /[\\/\0\r\n]/.test(name))
|
|
@@ -244,10 +184,17 @@ export function inspectGlobalSkill(source, name, version, options = {}) {
|
|
|
244
184
|
const directory = join(options.homeDirectory ?? homedir(), '.agents', 'skills', name);
|
|
245
185
|
if (existsSync(join(directory, 'SKILL.md')) && existsSync(join(directory, DISABLED_SKILL_MANIFEST)))
|
|
246
186
|
return { state: 'conflict', reason: 'SKILL_MANIFEST_CONFLICT' };
|
|
247
|
-
const
|
|
187
|
+
const receipt = (options.readSources ?? readTrackedSkillSources)('global', undefined, { homeDirectory: options.homeDirectory }).find(entry => entry.name === name);
|
|
188
|
+
const tracked = receipt
|
|
248
189
|
?? (!/(?:#|\/(?:tree|blob)\/)/.test(source) ? readLinkedSkillSource(directory, name) : undefined);
|
|
249
|
-
if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource)))
|
|
190
|
+
if (tracked && ((!options.externalManager && !isPublicSkillSource(tracked)) || !matchesTrackedSkillSource(source, tracked, options.normalizeSource))) {
|
|
191
|
+
const metadata = readInstalledSkillMetadata(directory);
|
|
192
|
+
if (receipt && (options.externalManager || isPublicSkillSource(receipt)) && metadata?.name === name &&
|
|
193
|
+
options.migrateFrom?.some(previous => matchesTrackedSkillSource(previous, receipt, options.normalizeSource)) && hasOnlySetupRegistrations(directory)) {
|
|
194
|
+
return { state: 'migration', reason: 'SKILL_SOURCE_MIGRATION_REQUIRED' };
|
|
195
|
+
}
|
|
250
196
|
return { state: 'conflict', reason: 'SKILL_SOURCE_CONFLICT' };
|
|
197
|
+
}
|
|
251
198
|
const metadata = readInstalledSkillMetadata(directory);
|
|
252
199
|
if (!metadata)
|
|
253
200
|
return { state: 'missing', reason: 'SKILL_NOT_INSTALLED' };
|