skill-flow 1.0.3 → 1.0.5

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.
Files changed (84) hide show
  1. package/README.md +40 -3
  2. package/README.zh.md +40 -3
  3. package/dist/adapters/channel-adapters.js +11 -3
  4. package/dist/adapters/channel-adapters.js.map +1 -1
  5. package/dist/cli.js +69 -37
  6. package/dist/cli.js.map +1 -1
  7. package/dist/domain/types.d.ts +54 -1
  8. package/dist/services/config-coordinator.d.ts +38 -0
  9. package/dist/services/config-coordinator.js +81 -0
  10. package/dist/services/config-coordinator.js.map +1 -0
  11. package/dist/services/doctor-service.d.ts +2 -0
  12. package/dist/services/doctor-service.js +62 -0
  13. package/dist/services/doctor-service.js.map +1 -1
  14. package/dist/services/inventory-service.d.ts +3 -1
  15. package/dist/services/inventory-service.js +12 -5
  16. package/dist/services/inventory-service.js.map +1 -1
  17. package/dist/services/skill-flow.d.ts +50 -26
  18. package/dist/services/skill-flow.js +502 -89
  19. package/dist/services/skill-flow.js.map +1 -1
  20. package/dist/services/source-service.d.ts +20 -10
  21. package/dist/services/source-service.js +359 -75
  22. package/dist/services/source-service.js.map +1 -1
  23. package/dist/services/workflow-service.d.ts +2 -2
  24. package/dist/services/workflow-service.js +17 -4
  25. package/dist/services/workflow-service.js.map +1 -1
  26. package/dist/services/workspace-bootstrap-service.d.ts +25 -0
  27. package/dist/services/workspace-bootstrap-service.js +140 -0
  28. package/dist/services/workspace-bootstrap-service.js.map +1 -0
  29. package/dist/state/store.d.ts +16 -0
  30. package/dist/state/store.js +93 -18
  31. package/dist/state/store.js.map +1 -1
  32. package/dist/tests/clawhub.test.d.ts +1 -0
  33. package/dist/tests/clawhub.test.js +63 -0
  34. package/dist/tests/clawhub.test.js.map +1 -0
  35. package/dist/tests/cli-utils.test.d.ts +1 -0
  36. package/dist/tests/cli-utils.test.js +15 -0
  37. package/dist/tests/cli-utils.test.js.map +1 -0
  38. package/dist/tests/config-coordinator.test.d.ts +1 -0
  39. package/dist/tests/config-coordinator.test.js +172 -0
  40. package/dist/tests/config-coordinator.test.js.map +1 -0
  41. package/dist/tests/config-integration.test.d.ts +1 -0
  42. package/dist/tests/config-integration.test.js +238 -0
  43. package/dist/tests/config-integration.test.js.map +1 -0
  44. package/dist/tests/config-ui-utils.test.d.ts +1 -0
  45. package/dist/tests/config-ui-utils.test.js +389 -0
  46. package/dist/tests/config-ui-utils.test.js.map +1 -0
  47. package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
  48. package/dist/tests/find-and-naming-utils.test.js +127 -0
  49. package/dist/tests/find-and-naming-utils.test.js.map +1 -0
  50. package/dist/tests/skill-flow.test.js +334 -881
  51. package/dist/tests/skill-flow.test.js.map +1 -1
  52. package/dist/tests/source-lifecycle.test.d.ts +1 -0
  53. package/dist/tests/source-lifecycle.test.js +605 -0
  54. package/dist/tests/source-lifecycle.test.js.map +1 -0
  55. package/dist/tests/target-definitions.test.d.ts +1 -0
  56. package/dist/tests/target-definitions.test.js +51 -0
  57. package/dist/tests/target-definitions.test.js.map +1 -0
  58. package/dist/tests/test-helpers.d.ts +18 -0
  59. package/dist/tests/test-helpers.js +123 -0
  60. package/dist/tests/test-helpers.js.map +1 -0
  61. package/dist/tui/config-app.d.ts +147 -24
  62. package/dist/tui/config-app.js +1081 -335
  63. package/dist/tui/config-app.js.map +1 -1
  64. package/dist/tui/find-app.d.ts +1 -1
  65. package/dist/tui/find-app.js +36 -4
  66. package/dist/tui/find-app.js.map +1 -1
  67. package/dist/utils/clawhub.d.ts +3 -0
  68. package/dist/utils/clawhub.js +32 -3
  69. package/dist/utils/clawhub.js.map +1 -1
  70. package/dist/utils/cli.d.ts +1 -0
  71. package/dist/utils/cli.js +15 -0
  72. package/dist/utils/cli.js.map +1 -0
  73. package/dist/utils/constants.d.ts +4 -0
  74. package/dist/utils/constants.js +31 -0
  75. package/dist/utils/constants.js.map +1 -1
  76. package/dist/utils/fs.d.ts +5 -0
  77. package/dist/utils/fs.js +52 -1
  78. package/dist/utils/fs.js.map +1 -1
  79. package/dist/utils/naming.d.ts +1 -0
  80. package/dist/utils/naming.js +7 -1
  81. package/dist/utils/naming.js.map +1 -1
  82. package/dist/utils/source-id.js +4 -0
  83. package/dist/utils/source-id.js.map +1 -1
  84. 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.init();
17
- const manifest = await this.store.readManifest();
18
- const lockFile = await this.store.readLock();
19
- const resolved = await this.resolveSource(locator, options);
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",
@@ -31,11 +37,15 @@ export class SourceService {
31
37
  catch (error) {
32
38
  await removePath(checkoutPath);
33
39
  return fail({
34
- code: resolved.kind === "git" ? "GIT_CLONE_FAILED" : "CLAWHUB_FETCH_FAILED",
40
+ code: resolved.kind === "git"
41
+ ? "GIT_CLONE_FAILED"
42
+ : resolved.kind === "local"
43
+ ? "LOCAL_IMPORT_FAILED"
44
+ : "CLAWHUB_FETCH_FAILED",
35
45
  message: `Unable to fetch source '${resolved.locator}': ${String(error)}`,
36
46
  });
37
47
  }
38
- const snapshot = await this.buildSnapshot(resolved.kind, resolved.sourceId, resolved.locator, resolved.displayName, checkoutPath, resolved.requestedPath);
48
+ const snapshot = await this.buildSnapshot(resolved.kind, resolved.sourceId, resolved.locator, resolved.displayName, checkoutPath, resolved.requestedPath, options);
39
49
  if (!snapshot.ok) {
40
50
  await removePath(checkoutPath);
41
51
  return fail(snapshot.errors, snapshot.warnings);
@@ -44,8 +54,7 @@ export class SourceService {
44
54
  manifest.bindings[resolved.sourceId] = { targets: {} };
45
55
  lockFile.sources.push(snapshot.data.lock);
46
56
  lockFile.leafInventory.push(...snapshot.data.leafs);
47
- await this.store.writeManifest(manifest);
48
- await this.store.writeLock(lockFile);
57
+ await this.store.writeState(manifest, lockFile);
49
58
  return ok({
50
59
  manifest: snapshot.data.manifest,
51
60
  lock: snapshot.data.lock,
@@ -54,9 +63,7 @@ export class SourceService {
54
63
  }, snapshot.warnings);
55
64
  }
56
65
  async updateSources(sourceIds) {
57
- await this.store.init();
58
- const manifest = await this.store.readManifest();
59
- const lockFile = await this.store.readLock();
66
+ const { manifest, lockFile } = await this.store.readState();
60
67
  const selectedIds = sourceIds?.length
61
68
  ? sourceIds
62
69
  : manifest.sources.map((source) => source.id);
@@ -70,55 +77,60 @@ export class SourceService {
70
77
  message: `Skills group id '${sourceId}' is not registered.`,
71
78
  });
72
79
  }
73
- let changed;
74
80
  try {
75
- changed = await this.updateSource(source, currentLock);
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
+ });
76
117
  }
77
118
  catch (error) {
78
119
  return fail({
79
- code: source.kind === "git" ? "GIT_UPDATE_FAILED" : "CLAWHUB_UPDATE_FAILED",
120
+ code: source.kind === "git"
121
+ ? "GIT_UPDATE_FAILED"
122
+ : source.kind === "local"
123
+ ? "LOCAL_UPDATE_FAILED"
124
+ : "CLAWHUB_UPDATE_FAILED",
80
125
  message: `Unable to update skills group id '${sourceId}': ${String(error)}`,
81
126
  });
82
127
  }
83
- if (!changed) {
84
- updated.push({
85
- sourceId,
86
- changed: false,
87
- addedLeafIds: [],
88
- removedLeafIds: [],
89
- invalidatedLeafIds: [],
90
- });
91
- continue;
92
- }
93
- const snapshot = await this.buildSnapshot(source.kind, source.id, source.locator, source.displayName, currentLock.checkoutPath, source.requestedPath, { allowEmptyLeafs: true });
94
- if (!snapshot.ok) {
95
- return fail(snapshot.errors, snapshot.warnings);
96
- }
97
- const previousLeafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === sourceId);
98
- const previousLeafIds = new Set(previousLeafs.map((leaf) => leaf.id));
99
- const nextLeafIds = new Set(snapshot.data.leafs.map((leaf) => leaf.id));
100
- const previousInvalidPaths = new Set(currentLock.invalidLeafs.map((leaf) => leaf.path));
101
- const nextInvalidPaths = new Set(snapshot.data.lock.invalidLeafs.map((leaf) => leaf.path));
102
- lockFile.sources = lockFile.sources.map((item) => item.id === sourceId ? snapshot.data.lock : item);
103
- lockFile.leafInventory = [
104
- ...lockFile.leafInventory.filter((leaf) => leaf.sourceId !== sourceId),
105
- ...snapshot.data.leafs,
106
- ];
107
- updated.push({
108
- sourceId,
109
- changed: true,
110
- addedLeafIds: [...nextLeafIds].filter((id) => !previousLeafIds.has(id)),
111
- removedLeafIds: [...previousLeafIds].filter((id) => !nextLeafIds.has(id)),
112
- invalidatedLeafIds: [...nextInvalidPaths].filter((value) => !previousInvalidPaths.has(value)),
113
- });
114
128
  }
115
- await this.store.writeLock(lockFile);
129
+ await this.store.writeState(manifest, lockFile);
116
130
  return ok({ updated });
117
131
  }
118
132
  async removeSource(sourceIds) {
119
- await this.store.init();
120
- const manifest = await this.store.readManifest();
121
- const lockFile = await this.store.readLock();
133
+ const { manifest, lockFile } = await this.store.readState();
122
134
  const removed = [];
123
135
  for (const sourceId of sourceIds) {
124
136
  const currentSource = manifest.sources.find((source) => source.id === sourceId);
@@ -139,14 +151,11 @@ export class SourceService {
139
151
  }
140
152
  removed.push(sourceId);
141
153
  }
142
- await this.store.writeManifest(manifest);
143
- await this.store.writeLock(lockFile);
154
+ await this.store.writeState(manifest, lockFile);
144
155
  return ok({ removed });
145
156
  }
146
157
  async reconcileInventory(sourceIds, options = {}) {
147
- await this.store.init();
148
- const manifest = await this.store.readManifest();
149
- const lockFile = await this.store.readLock();
158
+ const { manifest, lockFile } = await this.store.readState();
150
159
  const selectedIds = sourceIds?.length
151
160
  ? sourceIds
152
161
  : manifest.sources.map((source) => source.id);
@@ -163,7 +172,7 @@ export class SourceService {
163
172
  !this.needsInventoryReconcile(sourceId, sourceLeafs, sourceDeployments)) {
164
173
  continue;
165
174
  }
166
- const snapshot = await this.buildSnapshot(source.kind, source.id, source.locator, source.displayName, currentLock.checkoutPath, source.requestedPath, { allowEmptyLeafs: true });
175
+ const snapshot = await this.buildSnapshot(source.kind, source.id, source.locator, source.displayName, currentLock.checkoutPath, source.requestedPath, {}, { allowEmptyLeafs: true });
167
176
  if (!snapshot.ok) {
168
177
  return fail(snapshot.errors, snapshot.warnings);
169
178
  }
@@ -196,8 +205,7 @@ export class SourceService {
196
205
  updatedSourceIds.push(sourceId);
197
206
  }
198
207
  if (updatedSourceIds.length > 0) {
199
- await this.store.writeManifest(manifest);
200
- await this.store.writeLock(lockFile);
208
+ await this.store.writeState(manifest, lockFile);
201
209
  }
202
210
  return ok({ updatedSourceIds });
203
211
  }
@@ -206,7 +214,148 @@ export class SourceService {
206
214
  const hasLegacyTargetNames = sourceDeployments.some((deployment) => path.basename(deployment.targetPath).startsWith(`${sourceId}--`));
207
215
  return hasGeneratedLeafs || hasLegacyTargetNames;
208
216
  }
209
- async buildSnapshot(kind, sourceId, locator, displayName, checkoutPath, requestedPathOrOptions = undefined, maybeOptions = {}) {
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
+ }
358
+ async buildSnapshot(kind, sourceId, locator, displayName, checkoutPath, requestedPathOrOptions = undefined, addOptions = {}, maybeOptions = {}) {
210
359
  const requestedPath = typeof requestedPathOrOptions === "string" ? requestedPathOrOptions : undefined;
211
360
  const options = typeof requestedPathOrOptions === "string"
212
361
  ? maybeOptions
@@ -222,11 +371,14 @@ export class SourceService {
222
371
  })));
223
372
  if (((requestedPath && requestedMatches.length === 0) || scanned.leafs.length === 0) &&
224
373
  !options.allowEmptyLeafs) {
374
+ const emptyReason = scanned.skillFileCount === 0
375
+ ? " No SKILL.md files were found."
376
+ : "";
225
377
  return fail({
226
378
  code: requestedPath ? "SOURCE_PATH_NOT_FOUND" : "NO_VALID_LEAFS",
227
379
  message: requestedPath
228
380
  ? `Source '${displayName}' does not contain a valid skill at '${requestedPath}'.`
229
- : `Source '${displayName}' has no valid skills.`,
381
+ : `Source '${displayName}' has no valid skills.${emptyReason}`,
230
382
  }, scanned.invalidLeafs.map((leaf) => ({
231
383
  code: "INVALID_LEAF",
232
384
  message: `${leaf.path}: ${leaf.reason}`,
@@ -240,6 +392,11 @@ export class SourceService {
240
392
  displayName,
241
393
  addedAt: new Date().toISOString(),
242
394
  ...(requestedPath ? { requestedPath } : {}),
395
+ ...(addOptions.selectionMode ? { selectionMode: addOptions.selectionMode } : {}),
396
+ ...(addOptions.originLocator ? { originLocator: addOptions.originLocator } : {}),
397
+ ...(addOptions.originRequestedPath
398
+ ? { originRequestedPath: addOptions.originRequestedPath }
399
+ : {}),
243
400
  },
244
401
  lock: {
245
402
  id: sourceId,
@@ -251,6 +408,11 @@ export class SourceService {
251
408
  leafIds: scanned.leafs.map((leaf) => leaf.id),
252
409
  invalidLeafs: scanned.invalidLeafs,
253
410
  ...sourceMetadata,
411
+ ...(addOptions.originBranch ? { originBranch: addOptions.originBranch } : {}),
412
+ ...(addOptions.importedFromTargets
413
+ ? { importedFromTargets: addOptions.importedFromTargets }
414
+ : {}),
415
+ ...(addOptions.importMode ? { importMode: addOptions.importMode } : {}),
254
416
  ...(kind === "clawhub"
255
417
  ? {
256
418
  versionMode: locator.includes("@") ? "pinned" : "floating",
@@ -260,6 +422,10 @@ export class SourceService {
260
422
  leafs: scanned.leafs,
261
423
  }, [
262
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
+ })),
263
429
  ...scanned.invalidLeafs.map((leaf) => ({
264
430
  code: "INVALID_LEAF",
265
431
  message: `${leaf.path}: ${leaf.reason}`,
@@ -268,12 +434,12 @@ export class SourceService {
268
434
  }
269
435
  async normalizeLocator(locator) {
270
436
  const trimmed = locator.trim();
271
- if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
272
- return `https://github.com/${trimmed}.git`;
273
- }
274
437
  if (trimmed.startsWith("git@") || trimmed.startsWith("http")) {
275
438
  return trimmed;
276
439
  }
440
+ if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
441
+ return `https://github.com/${trimmed}.git`;
442
+ }
277
443
  const resolvedPath = path.resolve(trimmed);
278
444
  if (await pathExists(resolvedPath)) {
279
445
  return resolvedPath;
@@ -282,17 +448,27 @@ export class SourceService {
282
448
  }
283
449
  async resolveSource(locator, options) {
284
450
  const trimmed = locator.trim();
451
+ const resolvedPath = path.resolve(trimmed);
452
+ if (await pathExists(resolvedPath)) {
453
+ return {
454
+ kind: "local",
455
+ locator: resolvedPath,
456
+ localPath: resolvedPath,
457
+ displayName: options.displayNameOverride ?? deriveDisplayName(resolvedPath),
458
+ sourceId: options.sourceIdOverride ?? deriveSourceId(resolvedPath),
459
+ ...(options.path ? { requestedPath: options.path } : {}),
460
+ };
461
+ }
285
462
  const treeLocator = this.parseGitHubTreeLocator(trimmed);
286
463
  if (treeLocator) {
464
+ const requestedPath = this.joinRequestedPaths(treeLocator.requestedPath, options.path);
287
465
  return {
288
466
  kind: "git",
289
467
  locator: treeLocator.repoLocator,
290
468
  gitLocator: await this.normalizeLocator(treeLocator.repoLocator),
291
- displayName: deriveDisplayName(treeLocator.repoLocator),
292
- sourceId: deriveSourceId(treeLocator.repoLocator),
293
- ...(options.path ?? treeLocator.requestedPath
294
- ? { requestedPath: options.path ?? treeLocator.requestedPath }
295
- : {}),
469
+ displayName: options.displayNameOverride ?? deriveDisplayName(treeLocator.repoLocator),
470
+ sourceId: options.sourceIdOverride ?? deriveSourceId(treeLocator.repoLocator),
471
+ ...(requestedPath ? { requestedPath } : {}),
296
472
  };
297
473
  }
298
474
  const clawhubMatch = trimmed.match(/^clawhub:([^@\s]+)(?:@(.+))?$/);
@@ -306,8 +482,8 @@ export class SourceService {
306
482
  ? {
307
483
  kind: "clawhub",
308
484
  locator: trimmed,
309
- displayName: deriveDisplayName(trimmed),
310
- sourceId: deriveSourceId(trimmed),
485
+ displayName: options.displayNameOverride ?? deriveDisplayName(trimmed),
486
+ sourceId: options.sourceIdOverride ?? deriveSourceId(trimmed),
311
487
  ...(options.path ? { requestedPath: options.path } : {}),
312
488
  clawhubSlug: slug,
313
489
  requestedVersion: version,
@@ -316,8 +492,8 @@ export class SourceService {
316
492
  : {
317
493
  kind: "clawhub",
318
494
  locator: trimmed,
319
- displayName: deriveDisplayName(trimmed),
320
- sourceId: deriveSourceId(trimmed),
495
+ displayName: options.displayNameOverride ?? deriveDisplayName(trimmed),
496
+ sourceId: options.sourceIdOverride ?? deriveSourceId(trimmed),
321
497
  ...(options.path ? { requestedPath: options.path } : {}),
322
498
  clawhubSlug: slug,
323
499
  versionMode: "floating",
@@ -327,11 +503,53 @@ export class SourceService {
327
503
  kind: "git",
328
504
  locator,
329
505
  gitLocator: await this.normalizeLocator(locator),
330
- displayName: deriveDisplayName(locator),
331
- sourceId: deriveSourceId(locator),
506
+ displayName: options.displayNameOverride ?? deriveDisplayName(locator),
507
+ sourceId: options.sourceIdOverride ?? deriveSourceId(locator),
332
508
  ...(options.path ? { requestedPath: options.path } : {}),
333
509
  };
334
510
  }
511
+ resolveUniqueLocalSource(resolved, existingSources) {
512
+ if (resolved.kind !== "local" || !resolved.localPath) {
513
+ return resolved;
514
+ }
515
+ if (existingSources.some((source) => source.kind === "local" && path.resolve(source.locator) === resolved.localPath)) {
516
+ return resolved;
517
+ }
518
+ const folderName = path.basename(resolved.localPath);
519
+ const parentFolderName = path.basename(path.dirname(resolved.localPath));
520
+ const displayCandidates = [
521
+ folderName,
522
+ `${parentFolderName}_${folderName}`,
523
+ ];
524
+ const takenIds = new Set(existingSources.map((source) => source.id));
525
+ const takenLabels = new Set(existingSources
526
+ .filter((source) => source.kind === "local")
527
+ .map((source) => source.displayName));
528
+ for (const candidate of displayCandidates) {
529
+ const sourceId = deriveSourceId(candidate);
530
+ if (!takenIds.has(sourceId) && !takenLabels.has(candidate)) {
531
+ return {
532
+ ...resolved,
533
+ displayName: candidate,
534
+ sourceId,
535
+ };
536
+ }
537
+ }
538
+ const baseDisplayName = `${parentFolderName}_${folderName}`;
539
+ let index = 2;
540
+ while (true) {
541
+ const displayName = `${baseDisplayName}_${index}`;
542
+ const sourceId = deriveSourceId(displayName);
543
+ if (!takenIds.has(sourceId) && !takenLabels.has(displayName)) {
544
+ return {
545
+ ...resolved,
546
+ displayName,
547
+ sourceId,
548
+ };
549
+ }
550
+ index += 1;
551
+ }
552
+ }
335
553
  parseGitHubTreeLocator(locator) {
336
554
  try {
337
555
  const url = new URL(locator);
@@ -357,15 +575,34 @@ export class SourceService {
357
575
  return null;
358
576
  }
359
577
  }
578
+ joinRequestedPaths(basePath, childPath) {
579
+ const normalizedBase = this.normalizeRequestedPath(basePath);
580
+ const normalizedChild = this.normalizeRequestedPath(childPath);
581
+ if (!normalizedBase) {
582
+ return normalizedChild;
583
+ }
584
+ if (!normalizedChild) {
585
+ return normalizedBase;
586
+ }
587
+ if (normalizedChild === normalizedBase ||
588
+ normalizedChild.startsWith(`${normalizedBase}/`)) {
589
+ return normalizedChild;
590
+ }
591
+ return `${normalizedBase}/${normalizedChild}`;
592
+ }
360
593
  findRequestedLeafs(leafs, requestedPath) {
361
- if (!requestedPath) {
594
+ const normalizedPath = this.normalizeRequestedPath(requestedPath);
595
+ if (!normalizedPath) {
362
596
  return leafs;
363
597
  }
364
- const normalizedPath = requestedPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
365
598
  return leafs.filter((leaf) => leaf.relativePath === normalizedPath ||
366
599
  leaf.relativePath.startsWith(`${normalizedPath}/`));
367
600
  }
368
601
  async fetchSource(source, checkoutPath) {
602
+ if (source.kind === "local") {
603
+ await copyDirectory(source.localPath, checkoutPath);
604
+ return;
605
+ }
369
606
  if (source.kind === "git") {
370
607
  await git(["clone", "--depth", "1", source.gitLocator, checkoutPath]);
371
608
  return;
@@ -382,6 +619,9 @@ export class SourceService {
382
619
  }
383
620
  }
384
621
  async updateSource(source, currentLock) {
622
+ if (source.kind === "local") {
623
+ return this.refreshLocalSourceCheckout(source, currentLock);
624
+ }
385
625
  if (source.kind === "git") {
386
626
  await git(["pull", "--ff-only"], { cwd: currentLock.checkoutPath });
387
627
  const latestCommitSha = await git(["rev-parse", "HEAD"], {
@@ -407,7 +647,51 @@ export class SourceService {
407
647
  }
408
648
  return false;
409
649
  }
650
+ async refreshLocalSourceCheckout(source, currentLock) {
651
+ const tempCheckoutPath = `${currentLock.checkoutPath}.${process.pid}.${crypto.randomUUID()}.refresh`;
652
+ const backupPath = `${currentLock.checkoutPath}.${process.pid}.${crypto.randomUUID()}.backup`;
653
+ try {
654
+ if (!(await pathExists(source.locator))) {
655
+ throw new Error(`Local source origin is missing at ${source.locator}.`);
656
+ }
657
+ await copyDirectory(source.locator, tempCheckoutPath);
658
+ const nextHash = await hashDirectory(tempCheckoutPath);
659
+ const checkoutExists = await pathExists(currentLock.checkoutPath);
660
+ const currentHash = checkoutExists ? await hashDirectory(currentLock.checkoutPath) : undefined;
661
+ if (checkoutExists && nextHash === currentLock.contentHash && currentHash === nextHash) {
662
+ await removePath(tempCheckoutPath);
663
+ return false;
664
+ }
665
+ if (checkoutExists) {
666
+ await fs.rename(currentLock.checkoutPath, backupPath);
667
+ try {
668
+ await fs.rename(tempCheckoutPath, currentLock.checkoutPath);
669
+ }
670
+ catch (error) {
671
+ await fs.rename(backupPath, currentLock.checkoutPath).catch(() => { });
672
+ throw error;
673
+ }
674
+ finally {
675
+ await removePath(backupPath).catch(() => { });
676
+ }
677
+ }
678
+ else {
679
+ await fs.rename(tempCheckoutPath, currentLock.checkoutPath);
680
+ }
681
+ return true;
682
+ }
683
+ catch (error) {
684
+ await removePath(tempCheckoutPath).catch(() => { });
685
+ await removePath(backupPath).catch(() => { });
686
+ throw error;
687
+ }
688
+ }
410
689
  async readSourceSnapshot(kind, checkoutPath) {
690
+ if (kind === "local") {
691
+ return {
692
+ contentHash: await hashDirectory(checkoutPath),
693
+ };
694
+ }
411
695
  if (kind === "git") {
412
696
  return {
413
697
  commitSha: await git(["rev-parse", "HEAD"], { cwd: checkoutPath }),