skill-flow 1.0.4 → 1.0.6
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 +2 -2
- package/README.zh.md +1 -1
- package/dist/adapters/channel-adapters.js +11 -3
- package/dist/adapters/channel-adapters.js.map +1 -1
- package/dist/cli.js +64 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/types.d.ts +42 -0
- package/dist/services/config-coordinator.d.ts +38 -0
- package/dist/services/config-coordinator.js +81 -0
- package/dist/services/config-coordinator.js.map +1 -0
- package/dist/services/doctor-service.d.ts +2 -0
- package/dist/services/doctor-service.js +62 -0
- package/dist/services/doctor-service.js.map +1 -1
- package/dist/services/inventory-service.d.ts +4 -1
- package/dist/services/inventory-service.js +54 -26
- package/dist/services/inventory-service.js.map +1 -1
- package/dist/services/skill-flow.d.ts +38 -16
- package/dist/services/skill-flow.js +484 -194
- package/dist/services/skill-flow.js.map +1 -1
- package/dist/services/source-service.d.ts +14 -11
- package/dist/services/source-service.js +389 -81
- package/dist/services/source-service.js.map +1 -1
- package/dist/services/workspace-bootstrap-service.js +2 -10
- package/dist/services/workspace-bootstrap-service.js.map +1 -1
- package/dist/state/store.d.ts +16 -0
- package/dist/state/store.js +93 -19
- package/dist/state/store.js.map +1 -1
- package/dist/tests/add-selection-and-find-command.test.d.ts +1 -0
- package/dist/tests/add-selection-and-find-command.test.js +89 -0
- package/dist/tests/add-selection-and-find-command.test.js.map +1 -0
- package/dist/tests/clawhub.test.d.ts +1 -0
- package/dist/tests/clawhub.test.js +63 -0
- package/dist/tests/clawhub.test.js.map +1 -0
- package/dist/tests/cli-utils.test.d.ts +1 -0
- package/dist/tests/cli-utils.test.js +15 -0
- package/dist/tests/cli-utils.test.js.map +1 -0
- package/dist/tests/config-coordinator.test.d.ts +1 -0
- package/dist/tests/config-coordinator.test.js +172 -0
- package/dist/tests/config-coordinator.test.js.map +1 -0
- package/dist/tests/config-integration.test.d.ts +1 -0
- package/dist/tests/config-integration.test.js +238 -0
- package/dist/tests/config-integration.test.js.map +1 -0
- package/dist/tests/config-ui-utils.test.d.ts +1 -0
- package/dist/tests/config-ui-utils.test.js +473 -0
- package/dist/tests/config-ui-utils.test.js.map +1 -0
- package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
- package/dist/tests/find-and-naming-utils.test.js +127 -0
- package/dist/tests/find-and-naming-utils.test.js.map +1 -0
- package/dist/tests/inventory-service-precedence.test.d.ts +1 -0
- package/dist/tests/inventory-service-precedence.test.js +42 -0
- package/dist/tests/inventory-service-precedence.test.js.map +1 -0
- package/dist/tests/skill-flow.test.js +311 -889
- package/dist/tests/skill-flow.test.js.map +1 -1
- package/dist/tests/source-lifecycle.test.d.ts +1 -0
- package/dist/tests/source-lifecycle.test.js +605 -0
- package/dist/tests/source-lifecycle.test.js.map +1 -0
- package/dist/tests/source-parsing-compatibility.test.d.ts +1 -0
- package/dist/tests/source-parsing-compatibility.test.js +71 -0
- package/dist/tests/source-parsing-compatibility.test.js.map +1 -0
- package/dist/tests/target-definitions.test.d.ts +1 -0
- package/dist/tests/target-definitions.test.js +51 -0
- package/dist/tests/target-definitions.test.js.map +1 -0
- package/dist/tests/test-helpers.d.ts +18 -0
- package/dist/tests/test-helpers.js +123 -0
- package/dist/tests/test-helpers.js.map +1 -0
- package/dist/tui/config-app.d.ts +150 -24
- package/dist/tui/config-app.js +1034 -338
- package/dist/tui/config-app.js.map +1 -1
- package/dist/utils/clawhub.d.ts +3 -0
- package/dist/utils/clawhub.js +32 -3
- package/dist/utils/clawhub.js.map +1 -1
- package/dist/utils/cli.d.ts +1 -0
- package/dist/utils/cli.js +15 -0
- package/dist/utils/cli.js.map +1 -0
- package/dist/utils/constants.d.ts +4 -0
- package/dist/utils/constants.js +31 -0
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/find-command.js +10 -2
- package/dist/utils/find-command.js.map +1 -1
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +52 -1
- package/dist/utils/fs.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import { copyDirectory, ensureDir, hashDirectory, pathExists, readJsonFile, removePath } from "../utils/fs.js";
|
|
3
5
|
import { installClawHubSkill, } from "../utils/clawhub.js";
|
|
@@ -13,10 +15,14 @@ export class SourceService {
|
|
|
13
15
|
this.inventoryService = inventoryService;
|
|
14
16
|
}
|
|
15
17
|
async addSource(locator, options = {}) {
|
|
16
|
-
await this.store.
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
19
|
+
const resolved = this.resolveUniqueLocalSource(await this.resolveSource(locator, options), manifest.sources);
|
|
20
|
+
if (manifest.sources.some((source) => source.id === resolved.sourceId && source.locator === resolved.locator)) {
|
|
21
|
+
return fail({
|
|
22
|
+
code: "SOURCE_EXISTS",
|
|
23
|
+
message: `Skills group '${formatGroupLabel({ id: resolved.sourceId, locator: resolved.locator, displayName: resolved.displayName })}' is already registered with id '${resolved.sourceId}'.`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
20
26
|
if (manifest.sources.some((source) => source.id === resolved.sourceId)) {
|
|
21
27
|
return fail({
|
|
22
28
|
code: "SOURCE_EXISTS",
|
|
@@ -48,8 +54,7 @@ export class SourceService {
|
|
|
48
54
|
manifest.bindings[resolved.sourceId] = { targets: {} };
|
|
49
55
|
lockFile.sources.push(snapshot.data.lock);
|
|
50
56
|
lockFile.leafInventory.push(...snapshot.data.leafs);
|
|
51
|
-
await this.store.
|
|
52
|
-
await this.store.writeLock(lockFile);
|
|
57
|
+
await this.store.writeState(manifest, lockFile);
|
|
53
58
|
return ok({
|
|
54
59
|
manifest: snapshot.data.manifest,
|
|
55
60
|
lock: snapshot.data.lock,
|
|
@@ -58,9 +63,7 @@ export class SourceService {
|
|
|
58
63
|
}, snapshot.warnings);
|
|
59
64
|
}
|
|
60
65
|
async updateSources(sourceIds) {
|
|
61
|
-
await this.store.
|
|
62
|
-
const manifest = await this.store.readManifest();
|
|
63
|
-
const lockFile = await this.store.readLock();
|
|
66
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
64
67
|
const selectedIds = sourceIds?.length
|
|
65
68
|
? sourceIds
|
|
66
69
|
: manifest.sources.map((source) => source.id);
|
|
@@ -74,9 +77,43 @@ export class SourceService {
|
|
|
74
77
|
message: `Skills group id '${sourceId}' is not registered.`,
|
|
75
78
|
});
|
|
76
79
|
}
|
|
77
|
-
let changed;
|
|
78
80
|
try {
|
|
79
|
-
|
|
81
|
+
const sourceChanged = await this.updateSource(source, currentLock);
|
|
82
|
+
const sourceMeta = {
|
|
83
|
+
...(source.requestedPath ? { requestedPath: source.requestedPath } : {}),
|
|
84
|
+
...(source.selectionMode ? { selectionMode: source.selectionMode } : {}),
|
|
85
|
+
};
|
|
86
|
+
if (!sourceChanged) {
|
|
87
|
+
updated.push({
|
|
88
|
+
sourceId,
|
|
89
|
+
changed: false,
|
|
90
|
+
addedLeafIds: [],
|
|
91
|
+
removedLeafIds: [],
|
|
92
|
+
invalidatedLeafIds: [],
|
|
93
|
+
diffs: [],
|
|
94
|
+
...sourceMeta,
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const snapshot = await this.buildSnapshot(source.kind, source.id, source.locator, source.displayName, currentLock.checkoutPath, source.requestedPath, {}, { allowEmptyLeafs: true });
|
|
99
|
+
if (!snapshot.ok) {
|
|
100
|
+
return fail(snapshot.errors, snapshot.warnings);
|
|
101
|
+
}
|
|
102
|
+
const diff = this.buildSourceUpdateDiff(source, lockFile.leafInventory.filter((leaf) => leaf.sourceId === sourceId), snapshot.data.leafs, snapshot.data.lock.invalidLeafs);
|
|
103
|
+
lockFile.sources = lockFile.sources.map((item) => item.id === sourceId ? snapshot.data.lock : item);
|
|
104
|
+
lockFile.leafInventory = [
|
|
105
|
+
...lockFile.leafInventory.filter((leaf) => leaf.sourceId !== sourceId),
|
|
106
|
+
...snapshot.data.leafs,
|
|
107
|
+
];
|
|
108
|
+
updated.push({
|
|
109
|
+
sourceId,
|
|
110
|
+
changed: sourceChanged,
|
|
111
|
+
addedLeafIds: diff.addedLeafIds,
|
|
112
|
+
removedLeafIds: diff.removedLeafIds,
|
|
113
|
+
invalidatedLeafIds: diff.invalidatedLeafIds,
|
|
114
|
+
diffs: diff.diffs,
|
|
115
|
+
...sourceMeta,
|
|
116
|
+
});
|
|
80
117
|
}
|
|
81
118
|
catch (error) {
|
|
82
119
|
return fail({
|
|
@@ -88,45 +125,12 @@ export class SourceService {
|
|
|
88
125
|
message: `Unable to update skills group id '${sourceId}': ${String(error)}`,
|
|
89
126
|
});
|
|
90
127
|
}
|
|
91
|
-
if (!changed) {
|
|
92
|
-
updated.push({
|
|
93
|
-
sourceId,
|
|
94
|
-
changed: false,
|
|
95
|
-
addedLeafIds: [],
|
|
96
|
-
removedLeafIds: [],
|
|
97
|
-
invalidatedLeafIds: [],
|
|
98
|
-
});
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
const snapshot = await this.buildSnapshot(source.kind, source.id, source.locator, source.displayName, currentLock.checkoutPath, source.requestedPath, {}, { allowEmptyLeafs: true });
|
|
102
|
-
if (!snapshot.ok) {
|
|
103
|
-
return fail(snapshot.errors, snapshot.warnings);
|
|
104
|
-
}
|
|
105
|
-
const previousLeafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === sourceId);
|
|
106
|
-
const previousLeafIds = new Set(previousLeafs.map((leaf) => leaf.id));
|
|
107
|
-
const nextLeafIds = new Set(snapshot.data.leafs.map((leaf) => leaf.id));
|
|
108
|
-
const previousInvalidPaths = new Set(currentLock.invalidLeafs.map((leaf) => leaf.path));
|
|
109
|
-
const nextInvalidPaths = new Set(snapshot.data.lock.invalidLeafs.map((leaf) => leaf.path));
|
|
110
|
-
lockFile.sources = lockFile.sources.map((item) => item.id === sourceId ? snapshot.data.lock : item);
|
|
111
|
-
lockFile.leafInventory = [
|
|
112
|
-
...lockFile.leafInventory.filter((leaf) => leaf.sourceId !== sourceId),
|
|
113
|
-
...snapshot.data.leafs,
|
|
114
|
-
];
|
|
115
|
-
updated.push({
|
|
116
|
-
sourceId,
|
|
117
|
-
changed: true,
|
|
118
|
-
addedLeafIds: [...nextLeafIds].filter((id) => !previousLeafIds.has(id)),
|
|
119
|
-
removedLeafIds: [...previousLeafIds].filter((id) => !nextLeafIds.has(id)),
|
|
120
|
-
invalidatedLeafIds: [...nextInvalidPaths].filter((value) => !previousInvalidPaths.has(value)),
|
|
121
|
-
});
|
|
122
128
|
}
|
|
123
|
-
await this.store.
|
|
129
|
+
await this.store.writeState(manifest, lockFile);
|
|
124
130
|
return ok({ updated });
|
|
125
131
|
}
|
|
126
132
|
async removeSource(sourceIds) {
|
|
127
|
-
await this.store.
|
|
128
|
-
const manifest = await this.store.readManifest();
|
|
129
|
-
const lockFile = await this.store.readLock();
|
|
133
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
130
134
|
const removed = [];
|
|
131
135
|
for (const sourceId of sourceIds) {
|
|
132
136
|
const currentSource = manifest.sources.find((source) => source.id === sourceId);
|
|
@@ -147,14 +151,11 @@ export class SourceService {
|
|
|
147
151
|
}
|
|
148
152
|
removed.push(sourceId);
|
|
149
153
|
}
|
|
150
|
-
await this.store.
|
|
151
|
-
await this.store.writeLock(lockFile);
|
|
154
|
+
await this.store.writeState(manifest, lockFile);
|
|
152
155
|
return ok({ removed });
|
|
153
156
|
}
|
|
154
157
|
async reconcileInventory(sourceIds, options = {}) {
|
|
155
|
-
await this.store.
|
|
156
|
-
const manifest = await this.store.readManifest();
|
|
157
|
-
const lockFile = await this.store.readLock();
|
|
158
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
158
159
|
const selectedIds = sourceIds?.length
|
|
159
160
|
? sourceIds
|
|
160
161
|
: manifest.sources.map((source) => source.id);
|
|
@@ -204,8 +205,7 @@ export class SourceService {
|
|
|
204
205
|
updatedSourceIds.push(sourceId);
|
|
205
206
|
}
|
|
206
207
|
if (updatedSourceIds.length > 0) {
|
|
207
|
-
await this.store.
|
|
208
|
-
await this.store.writeLock(lockFile);
|
|
208
|
+
await this.store.writeState(manifest, lockFile);
|
|
209
209
|
}
|
|
210
210
|
return ok({ updatedSourceIds });
|
|
211
211
|
}
|
|
@@ -214,6 +214,147 @@ export class SourceService {
|
|
|
214
214
|
const hasLegacyTargetNames = sourceDeployments.some((deployment) => path.basename(deployment.targetPath).startsWith(`${sourceId}--`));
|
|
215
215
|
return hasGeneratedLeafs || hasLegacyTargetNames;
|
|
216
216
|
}
|
|
217
|
+
buildSourceUpdateDiff(source, previousLeafs, nextLeafs, nextInvalidLeafs) {
|
|
218
|
+
const requestedPath = this.normalizeRequestedPath(source.requestedPath);
|
|
219
|
+
const requestedPathOption = requestedPath ? { requestedPath } : {};
|
|
220
|
+
const diffs = [];
|
|
221
|
+
const addedLeafIds = [];
|
|
222
|
+
const removedLeafIds = [];
|
|
223
|
+
const invalidatedLeafIds = [];
|
|
224
|
+
const matchedPreviousIds = new Set();
|
|
225
|
+
const matchedNextIds = new Set();
|
|
226
|
+
const nextById = new Map(nextLeafs.map((leaf) => [leaf.id, leaf]));
|
|
227
|
+
const nextInvalidPaths = new Set(nextInvalidLeafs.map((leaf) => leaf.path));
|
|
228
|
+
for (const previousLeaf of previousLeafs) {
|
|
229
|
+
const nextLeaf = nextById.get(previousLeaf.id);
|
|
230
|
+
if (!nextLeaf || previousLeaf.contentHash === nextLeaf.contentHash) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
diffs.push(this.createDiffItem("changed", source.id, nextLeaf, {
|
|
234
|
+
...requestedPathOption,
|
|
235
|
+
previousLeafId: previousLeaf.id,
|
|
236
|
+
previousRelativePath: previousLeaf.relativePath,
|
|
237
|
+
previousContentHash: previousLeaf.contentHash,
|
|
238
|
+
}));
|
|
239
|
+
matchedPreviousIds.add(previousLeaf.id);
|
|
240
|
+
matchedNextIds.add(nextLeaf.id);
|
|
241
|
+
}
|
|
242
|
+
const previousByHash = new Map();
|
|
243
|
+
const nextByHash = new Map();
|
|
244
|
+
for (const previousLeaf of previousLeafs) {
|
|
245
|
+
if (matchedPreviousIds.has(previousLeaf.id)) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const list = previousByHash.get(previousLeaf.contentHash) ?? [];
|
|
249
|
+
list.push(previousLeaf);
|
|
250
|
+
previousByHash.set(previousLeaf.contentHash, list);
|
|
251
|
+
}
|
|
252
|
+
for (const nextLeaf of nextLeafs) {
|
|
253
|
+
if (matchedNextIds.has(nextLeaf.id)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const list = nextByHash.get(nextLeaf.contentHash) ?? [];
|
|
257
|
+
list.push(nextLeaf);
|
|
258
|
+
nextByHash.set(nextLeaf.contentHash, list);
|
|
259
|
+
}
|
|
260
|
+
for (const contentHash of [...previousByHash.keys()].sort()) {
|
|
261
|
+
const previousGroup = previousByHash.get(contentHash) ?? [];
|
|
262
|
+
const nextGroup = nextByHash.get(contentHash) ?? [];
|
|
263
|
+
if (previousGroup.length !== 1 || nextGroup.length !== 1) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const previousLeaf = previousGroup[0];
|
|
267
|
+
const nextLeaf = nextGroup[0];
|
|
268
|
+
if (previousLeaf.id === nextLeaf.id ||
|
|
269
|
+
!this.canClassifyAsMoved(requestedPath, previousLeaf.relativePath, nextLeaf.relativePath)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
diffs.push(this.createDiffItem("moved", source.id, nextLeaf, {
|
|
273
|
+
...requestedPathOption,
|
|
274
|
+
previousLeafId: previousLeaf.id,
|
|
275
|
+
previousRelativePath: previousLeaf.relativePath,
|
|
276
|
+
previousContentHash: previousLeaf.contentHash,
|
|
277
|
+
}));
|
|
278
|
+
matchedPreviousIds.add(previousLeaf.id);
|
|
279
|
+
matchedNextIds.add(nextLeaf.id);
|
|
280
|
+
}
|
|
281
|
+
for (const previousLeaf of previousLeafs) {
|
|
282
|
+
if (matchedPreviousIds.has(previousLeaf.id)) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (nextInvalidPaths.has(previousLeaf.relativePath)) {
|
|
286
|
+
diffs.push(this.createDiffItem("invalidated", source.id, previousLeaf, {
|
|
287
|
+
...requestedPathOption,
|
|
288
|
+
previousLeafId: previousLeaf.id,
|
|
289
|
+
previousRelativePath: previousLeaf.relativePath,
|
|
290
|
+
previousContentHash: previousLeaf.contentHash,
|
|
291
|
+
}));
|
|
292
|
+
invalidatedLeafIds.push(previousLeaf.id);
|
|
293
|
+
matchedPreviousIds.add(previousLeaf.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const previousLeaf of previousLeafs) {
|
|
297
|
+
if (matchedPreviousIds.has(previousLeaf.id)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
diffs.push(this.createDiffItem("removed", source.id, previousLeaf, {
|
|
301
|
+
...requestedPathOption,
|
|
302
|
+
previousLeafId: previousLeaf.id,
|
|
303
|
+
previousRelativePath: previousLeaf.relativePath,
|
|
304
|
+
previousContentHash: previousLeaf.contentHash,
|
|
305
|
+
}));
|
|
306
|
+
removedLeafIds.push(previousLeaf.id);
|
|
307
|
+
matchedPreviousIds.add(previousLeaf.id);
|
|
308
|
+
}
|
|
309
|
+
for (const nextLeaf of nextLeafs) {
|
|
310
|
+
if (matchedNextIds.has(nextLeaf.id)) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
diffs.push(this.createDiffItem("added", source.id, nextLeaf, requestedPathOption));
|
|
314
|
+
addedLeafIds.push(nextLeaf.id);
|
|
315
|
+
matchedNextIds.add(nextLeaf.id);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
diffs,
|
|
319
|
+
addedLeafIds,
|
|
320
|
+
removedLeafIds,
|
|
321
|
+
invalidatedLeafIds,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
createDiffItem(kind, sourceId, leaf, extras = {}) {
|
|
325
|
+
return {
|
|
326
|
+
kind,
|
|
327
|
+
sourceId,
|
|
328
|
+
leafId: leaf.id,
|
|
329
|
+
relativePath: leaf.relativePath,
|
|
330
|
+
contentHash: leaf.contentHash,
|
|
331
|
+
...(extras.requestedPath ? { requestedPath: extras.requestedPath } : {}),
|
|
332
|
+
...(extras.previousLeafId ? { previousLeafId: extras.previousLeafId } : {}),
|
|
333
|
+
...(extras.previousRelativePath ? { previousRelativePath: extras.previousRelativePath } : {}),
|
|
334
|
+
...(extras.previousContentHash ? { previousContentHash: extras.previousContentHash } : {}),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
canClassifyAsMoved(requestedPath, previousRelativePath, nextRelativePath) {
|
|
338
|
+
if (!requestedPath) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
return (this.isWithinRequestedPath(previousRelativePath, requestedPath) &&
|
|
342
|
+
this.isWithinRequestedPath(nextRelativePath, requestedPath));
|
|
343
|
+
}
|
|
344
|
+
normalizeRequestedPath(requestedPath) {
|
|
345
|
+
if (!requestedPath) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
const normalized = requestedPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
349
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
350
|
+
}
|
|
351
|
+
isWithinRequestedPath(relativePath, requestedPath) {
|
|
352
|
+
const normalizedPath = this.normalizeRequestedPath(requestedPath);
|
|
353
|
+
if (!normalizedPath) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
return (relativePath === normalizedPath || relativePath.startsWith(`${normalizedPath}/`));
|
|
357
|
+
}
|
|
217
358
|
async buildSnapshot(kind, sourceId, locator, displayName, checkoutPath, requestedPathOrOptions = undefined, addOptions = {}, maybeOptions = {}) {
|
|
218
359
|
const requestedPath = typeof requestedPathOrOptions === "string" ? requestedPathOrOptions : undefined;
|
|
219
360
|
const options = typeof requestedPathOrOptions === "string"
|
|
@@ -230,11 +371,14 @@ export class SourceService {
|
|
|
230
371
|
})));
|
|
231
372
|
if (((requestedPath && requestedMatches.length === 0) || scanned.leafs.length === 0) &&
|
|
232
373
|
!options.allowEmptyLeafs) {
|
|
374
|
+
const emptyReason = scanned.skillFileCount === 0
|
|
375
|
+
? " No SKILL.md files were found."
|
|
376
|
+
: "";
|
|
233
377
|
return fail({
|
|
234
378
|
code: requestedPath ? "SOURCE_PATH_NOT_FOUND" : "NO_VALID_LEAFS",
|
|
235
379
|
message: requestedPath
|
|
236
380
|
? `Source '${displayName}' does not contain a valid skill at '${requestedPath}'.`
|
|
237
|
-
: `Source '${displayName}' has no valid skills
|
|
381
|
+
: `Source '${displayName}' has no valid skills.${emptyReason}`,
|
|
238
382
|
}, scanned.invalidLeafs.map((leaf) => ({
|
|
239
383
|
code: "INVALID_LEAF",
|
|
240
384
|
message: `${leaf.path}: ${leaf.reason}`,
|
|
@@ -278,6 +422,10 @@ export class SourceService {
|
|
|
278
422
|
leafs: scanned.leafs,
|
|
279
423
|
}, [
|
|
280
424
|
...metadataWarnings,
|
|
425
|
+
...scanned.duplicateLeafs.map((leaf) => ({
|
|
426
|
+
code: "DUPLICATE_LEAF",
|
|
427
|
+
message: `${leaf.path}: Duplicate skill content skipped because ${leaf.keptPath} was discovered first`,
|
|
428
|
+
})),
|
|
281
429
|
...scanned.invalidLeafs.map((leaf) => ({
|
|
282
430
|
code: "INVALID_LEAF",
|
|
283
431
|
message: `${leaf.path}: ${leaf.reason}`,
|
|
@@ -286,12 +434,12 @@ export class SourceService {
|
|
|
286
434
|
}
|
|
287
435
|
async normalizeLocator(locator) {
|
|
288
436
|
const trimmed = locator.trim();
|
|
289
|
-
if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
290
|
-
return `https://github.com/${trimmed}.git`;
|
|
291
|
-
}
|
|
292
437
|
if (trimmed.startsWith("git@") || trimmed.startsWith("http")) {
|
|
293
438
|
return trimmed;
|
|
294
439
|
}
|
|
440
|
+
if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
441
|
+
return `https://github.com/${trimmed}.git`;
|
|
442
|
+
}
|
|
295
443
|
const resolvedPath = path.resolve(trimmed);
|
|
296
444
|
if (await pathExists(resolvedPath)) {
|
|
297
445
|
return resolvedPath;
|
|
@@ -300,8 +448,10 @@ export class SourceService {
|
|
|
300
448
|
}
|
|
301
449
|
async resolveSource(locator, options) {
|
|
302
450
|
const trimmed = locator.trim();
|
|
303
|
-
const
|
|
304
|
-
|
|
451
|
+
const fileLocatorPath = this.parseFileLocator(trimmed);
|
|
452
|
+
const resolvedPath = path.resolve(fileLocatorPath ?? trimmed);
|
|
453
|
+
if (await pathExists(resolvedPath) &&
|
|
454
|
+
(!fileLocatorPath || !(await this.isGitRepositoryPath(resolvedPath)))) {
|
|
305
455
|
return {
|
|
306
456
|
kind: "local",
|
|
307
457
|
locator: resolvedPath,
|
|
@@ -311,17 +461,28 @@ export class SourceService {
|
|
|
311
461
|
...(options.path ? { requestedPath: options.path } : {}),
|
|
312
462
|
};
|
|
313
463
|
}
|
|
314
|
-
const treeLocator = this.
|
|
464
|
+
const treeLocator = this.parseTreeLocator(trimmed);
|
|
315
465
|
if (treeLocator) {
|
|
466
|
+
const requestedPath = this.joinRequestedPaths(treeLocator.requestedPath, options.path);
|
|
316
467
|
return {
|
|
317
468
|
kind: "git",
|
|
318
469
|
locator: treeLocator.repoLocator,
|
|
319
470
|
gitLocator: await this.normalizeLocator(treeLocator.repoLocator),
|
|
320
471
|
displayName: options.displayNameOverride ?? deriveDisplayName(treeLocator.repoLocator),
|
|
321
472
|
sourceId: options.sourceIdOverride ?? deriveSourceId(treeLocator.repoLocator),
|
|
322
|
-
...(
|
|
323
|
-
|
|
324
|
-
|
|
473
|
+
...(requestedPath ? { requestedPath } : {}),
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const shorthandLocator = this.parseGitHubShorthandSubpath(trimmed);
|
|
477
|
+
if (shorthandLocator) {
|
|
478
|
+
const requestedPath = this.joinRequestedPaths(shorthandLocator.requestedPath, options.path);
|
|
479
|
+
return {
|
|
480
|
+
kind: "git",
|
|
481
|
+
locator: shorthandLocator.repoLocator,
|
|
482
|
+
gitLocator: await this.normalizeLocator(shorthandLocator.repoLocator),
|
|
483
|
+
displayName: options.displayNameOverride ?? deriveDisplayName(shorthandLocator.repoLocator),
|
|
484
|
+
sourceId: options.sourceIdOverride ?? deriveSourceId(shorthandLocator.repoLocator),
|
|
485
|
+
...(requestedPath ? { requestedPath } : {}),
|
|
325
486
|
};
|
|
326
487
|
}
|
|
327
488
|
const clawhubMatch = trimmed.match(/^clawhub:([^@\s]+)(?:@(.+))?$/);
|
|
@@ -361,36 +522,145 @@ export class SourceService {
|
|
|
361
522
|
...(options.path ? { requestedPath: options.path } : {}),
|
|
362
523
|
};
|
|
363
524
|
}
|
|
364
|
-
|
|
525
|
+
resolveUniqueLocalSource(resolved, existingSources) {
|
|
526
|
+
if (resolved.kind !== "local" || !resolved.localPath) {
|
|
527
|
+
return resolved;
|
|
528
|
+
}
|
|
529
|
+
if (existingSources.some((source) => source.kind === "local" && path.resolve(source.locator) === resolved.localPath)) {
|
|
530
|
+
return resolved;
|
|
531
|
+
}
|
|
532
|
+
const folderName = path.basename(resolved.localPath);
|
|
533
|
+
const parentFolderName = path.basename(path.dirname(resolved.localPath));
|
|
534
|
+
const displayCandidates = [
|
|
535
|
+
folderName,
|
|
536
|
+
`${parentFolderName}_${folderName}`,
|
|
537
|
+
];
|
|
538
|
+
const takenIds = new Set(existingSources.map((source) => source.id));
|
|
539
|
+
const takenLabels = new Set(existingSources
|
|
540
|
+
.filter((source) => source.kind === "local")
|
|
541
|
+
.map((source) => source.displayName));
|
|
542
|
+
for (const candidate of displayCandidates) {
|
|
543
|
+
const sourceId = deriveSourceId(candidate);
|
|
544
|
+
if (!takenIds.has(sourceId) && !takenLabels.has(candidate)) {
|
|
545
|
+
return {
|
|
546
|
+
...resolved,
|
|
547
|
+
displayName: candidate,
|
|
548
|
+
sourceId,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const baseDisplayName = `${parentFolderName}_${folderName}`;
|
|
553
|
+
let index = 2;
|
|
554
|
+
while (true) {
|
|
555
|
+
const displayName = `${baseDisplayName}_${index}`;
|
|
556
|
+
const sourceId = deriveSourceId(displayName);
|
|
557
|
+
if (!takenIds.has(sourceId) && !takenLabels.has(displayName)) {
|
|
558
|
+
return {
|
|
559
|
+
...resolved,
|
|
560
|
+
displayName,
|
|
561
|
+
sourceId,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
index += 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
parseTreeLocator(locator) {
|
|
365
568
|
try {
|
|
366
569
|
const url = new URL(locator);
|
|
367
|
-
if (url.hostname !== "github.com") {
|
|
368
|
-
return null;
|
|
369
|
-
}
|
|
370
570
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
371
|
-
if (
|
|
372
|
-
|
|
571
|
+
if (url.hostname === "github.com") {
|
|
572
|
+
if (parts.length < 5 || parts[2] !== "tree") {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
const owner = parts[0];
|
|
576
|
+
const repo = parts[1];
|
|
577
|
+
const requestedPath = parts.slice(4).join("/");
|
|
578
|
+
if (!owner || !repo || !requestedPath) {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
repoLocator: `https://github.com/${owner}/${repo}.git`,
|
|
583
|
+
requestedPath,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (url.hostname === "gitlab.com") {
|
|
587
|
+
const markerIndex = parts.findIndex((segment, index) => segment === "-" && parts[index + 1] === "tree");
|
|
588
|
+
if (markerIndex < 2) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const requestedPath = parts.slice(markerIndex + 3).join("/");
|
|
592
|
+
return {
|
|
593
|
+
repoLocator: `https://gitlab.com/${parts.slice(0, markerIndex).join("/")}.git`,
|
|
594
|
+
...(requestedPath ? { requestedPath } : {}),
|
|
595
|
+
};
|
|
373
596
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
parseGitHubShorthandSubpath(locator) {
|
|
604
|
+
const trimmed = locator.replace(/\/+$/, "");
|
|
605
|
+
const parts = trimmed.split("/");
|
|
606
|
+
if (parts.length < 3) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
const owner = parts[0];
|
|
610
|
+
const rawRepo = parts[1];
|
|
611
|
+
const requestedPath = parts.slice(2).join("/");
|
|
612
|
+
if (!owner || !rawRepo || !requestedPath) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
const repo = rawRepo.replace(/\.git$/i, "");
|
|
616
|
+
return {
|
|
617
|
+
repoLocator: `https://github.com/${owner}/${repo}.git`,
|
|
618
|
+
requestedPath,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
parseFileLocator(locator) {
|
|
622
|
+
if (!locator.startsWith("file://")) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const fileUrl = new URL(locator);
|
|
627
|
+
if (fileUrl.protocol !== "file:") {
|
|
378
628
|
return null;
|
|
379
629
|
}
|
|
380
|
-
return
|
|
381
|
-
repoLocator: `https://github.com/${owner}/${repo}.git`,
|
|
382
|
-
requestedPath,
|
|
383
|
-
};
|
|
630
|
+
return path.resolve(decodeURIComponent(fileUrl.pathname));
|
|
384
631
|
}
|
|
385
632
|
catch {
|
|
386
633
|
return null;
|
|
387
634
|
}
|
|
388
635
|
}
|
|
636
|
+
async isGitRepositoryPath(candidatePath) {
|
|
637
|
+
if (await pathExists(path.join(candidatePath, ".git"))) {
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
return (await pathExists(path.join(candidatePath, "HEAD")) &&
|
|
641
|
+
await pathExists(path.join(candidatePath, "objects")) &&
|
|
642
|
+
await pathExists(path.join(candidatePath, "refs")));
|
|
643
|
+
}
|
|
644
|
+
joinRequestedPaths(basePath, childPath) {
|
|
645
|
+
const normalizedBase = this.normalizeRequestedPath(basePath);
|
|
646
|
+
const normalizedChild = this.normalizeRequestedPath(childPath);
|
|
647
|
+
if (!normalizedBase) {
|
|
648
|
+
return normalizedChild;
|
|
649
|
+
}
|
|
650
|
+
if (!normalizedChild) {
|
|
651
|
+
return normalizedBase;
|
|
652
|
+
}
|
|
653
|
+
if (normalizedChild === normalizedBase ||
|
|
654
|
+
normalizedChild.startsWith(`${normalizedBase}/`)) {
|
|
655
|
+
return normalizedChild;
|
|
656
|
+
}
|
|
657
|
+
return `${normalizedBase}/${normalizedChild}`;
|
|
658
|
+
}
|
|
389
659
|
findRequestedLeafs(leafs, requestedPath) {
|
|
390
|
-
|
|
660
|
+
const normalizedPath = this.normalizeRequestedPath(requestedPath);
|
|
661
|
+
if (!normalizedPath) {
|
|
391
662
|
return leafs;
|
|
392
663
|
}
|
|
393
|
-
const normalizedPath = requestedPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
394
664
|
return leafs.filter((leaf) => leaf.relativePath === normalizedPath ||
|
|
395
665
|
leaf.relativePath.startsWith(`${normalizedPath}/`));
|
|
396
666
|
}
|
|
@@ -416,8 +686,7 @@ export class SourceService {
|
|
|
416
686
|
}
|
|
417
687
|
async updateSource(source, currentLock) {
|
|
418
688
|
if (source.kind === "local") {
|
|
419
|
-
|
|
420
|
-
return contentHash !== currentLock.contentHash;
|
|
689
|
+
return this.refreshLocalSourceCheckout(source, currentLock);
|
|
421
690
|
}
|
|
422
691
|
if (source.kind === "git") {
|
|
423
692
|
await git(["pull", "--ff-only"], { cwd: currentLock.checkoutPath });
|
|
@@ -444,6 +713,45 @@ export class SourceService {
|
|
|
444
713
|
}
|
|
445
714
|
return false;
|
|
446
715
|
}
|
|
716
|
+
async refreshLocalSourceCheckout(source, currentLock) {
|
|
717
|
+
const tempCheckoutPath = `${currentLock.checkoutPath}.${process.pid}.${crypto.randomUUID()}.refresh`;
|
|
718
|
+
const backupPath = `${currentLock.checkoutPath}.${process.pid}.${crypto.randomUUID()}.backup`;
|
|
719
|
+
try {
|
|
720
|
+
if (!(await pathExists(source.locator))) {
|
|
721
|
+
throw new Error(`Local source origin is missing at ${source.locator}.`);
|
|
722
|
+
}
|
|
723
|
+
await copyDirectory(source.locator, tempCheckoutPath);
|
|
724
|
+
const nextHash = await hashDirectory(tempCheckoutPath);
|
|
725
|
+
const checkoutExists = await pathExists(currentLock.checkoutPath);
|
|
726
|
+
const currentHash = checkoutExists ? await hashDirectory(currentLock.checkoutPath) : undefined;
|
|
727
|
+
if (checkoutExists && nextHash === currentLock.contentHash && currentHash === nextHash) {
|
|
728
|
+
await removePath(tempCheckoutPath);
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
if (checkoutExists) {
|
|
732
|
+
await fs.rename(currentLock.checkoutPath, backupPath);
|
|
733
|
+
try {
|
|
734
|
+
await fs.rename(tempCheckoutPath, currentLock.checkoutPath);
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
await fs.rename(backupPath, currentLock.checkoutPath).catch(() => { });
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
await removePath(backupPath).catch(() => { });
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
await fs.rename(tempCheckoutPath, currentLock.checkoutPath);
|
|
746
|
+
}
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
await removePath(tempCheckoutPath).catch(() => { });
|
|
751
|
+
await removePath(backupPath).catch(() => { });
|
|
752
|
+
throw error;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
447
755
|
async readSourceSnapshot(kind, checkoutPath) {
|
|
448
756
|
if (kind === "local") {
|
|
449
757
|
return {
|