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.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/README.zh.md +1 -1
  3. package/dist/adapters/channel-adapters.js +11 -3
  4. package/dist/adapters/channel-adapters.js.map +1 -1
  5. package/dist/cli.js +64 -6
  6. package/dist/cli.js.map +1 -1
  7. package/dist/domain/types.d.ts +42 -0
  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 +4 -1
  15. package/dist/services/inventory-service.js +54 -26
  16. package/dist/services/inventory-service.js.map +1 -1
  17. package/dist/services/skill-flow.d.ts +38 -16
  18. package/dist/services/skill-flow.js +484 -194
  19. package/dist/services/skill-flow.js.map +1 -1
  20. package/dist/services/source-service.d.ts +14 -11
  21. package/dist/services/source-service.js +389 -81
  22. package/dist/services/source-service.js.map +1 -1
  23. package/dist/services/workspace-bootstrap-service.js +2 -10
  24. package/dist/services/workspace-bootstrap-service.js.map +1 -1
  25. package/dist/state/store.d.ts +16 -0
  26. package/dist/state/store.js +93 -19
  27. package/dist/state/store.js.map +1 -1
  28. package/dist/tests/add-selection-and-find-command.test.d.ts +1 -0
  29. package/dist/tests/add-selection-and-find-command.test.js +89 -0
  30. package/dist/tests/add-selection-and-find-command.test.js.map +1 -0
  31. package/dist/tests/clawhub.test.d.ts +1 -0
  32. package/dist/tests/clawhub.test.js +63 -0
  33. package/dist/tests/clawhub.test.js.map +1 -0
  34. package/dist/tests/cli-utils.test.d.ts +1 -0
  35. package/dist/tests/cli-utils.test.js +15 -0
  36. package/dist/tests/cli-utils.test.js.map +1 -0
  37. package/dist/tests/config-coordinator.test.d.ts +1 -0
  38. package/dist/tests/config-coordinator.test.js +172 -0
  39. package/dist/tests/config-coordinator.test.js.map +1 -0
  40. package/dist/tests/config-integration.test.d.ts +1 -0
  41. package/dist/tests/config-integration.test.js +238 -0
  42. package/dist/tests/config-integration.test.js.map +1 -0
  43. package/dist/tests/config-ui-utils.test.d.ts +1 -0
  44. package/dist/tests/config-ui-utils.test.js +473 -0
  45. package/dist/tests/config-ui-utils.test.js.map +1 -0
  46. package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
  47. package/dist/tests/find-and-naming-utils.test.js +127 -0
  48. package/dist/tests/find-and-naming-utils.test.js.map +1 -0
  49. package/dist/tests/inventory-service-precedence.test.d.ts +1 -0
  50. package/dist/tests/inventory-service-precedence.test.js +42 -0
  51. package/dist/tests/inventory-service-precedence.test.js.map +1 -0
  52. package/dist/tests/skill-flow.test.js +311 -889
  53. package/dist/tests/skill-flow.test.js.map +1 -1
  54. package/dist/tests/source-lifecycle.test.d.ts +1 -0
  55. package/dist/tests/source-lifecycle.test.js +605 -0
  56. package/dist/tests/source-lifecycle.test.js.map +1 -0
  57. package/dist/tests/source-parsing-compatibility.test.d.ts +1 -0
  58. package/dist/tests/source-parsing-compatibility.test.js +71 -0
  59. package/dist/tests/source-parsing-compatibility.test.js.map +1 -0
  60. package/dist/tests/target-definitions.test.d.ts +1 -0
  61. package/dist/tests/target-definitions.test.js +51 -0
  62. package/dist/tests/target-definitions.test.js.map +1 -0
  63. package/dist/tests/test-helpers.d.ts +18 -0
  64. package/dist/tests/test-helpers.js +123 -0
  65. package/dist/tests/test-helpers.js.map +1 -0
  66. package/dist/tui/config-app.d.ts +150 -24
  67. package/dist/tui/config-app.js +1034 -338
  68. package/dist/tui/config-app.js.map +1 -1
  69. package/dist/utils/clawhub.d.ts +3 -0
  70. package/dist/utils/clawhub.js +32 -3
  71. package/dist/utils/clawhub.js.map +1 -1
  72. package/dist/utils/cli.d.ts +1 -0
  73. package/dist/utils/cli.js +15 -0
  74. package/dist/utils/cli.js.map +1 -0
  75. package/dist/utils/constants.d.ts +4 -0
  76. package/dist/utils/constants.js +31 -0
  77. package/dist/utils/constants.js.map +1 -1
  78. package/dist/utils/find-command.js +10 -2
  79. package/dist/utils/find-command.js.map +1 -1
  80. package/dist/utils/fs.d.ts +5 -0
  81. package/dist/utils/fs.js +52 -1
  82. package/dist/utils/fs.js.map +1 -1
  83. 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",
@@ -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.writeManifest(manifest);
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.init();
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
- 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
+ });
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.writeLock(lockFile);
129
+ await this.store.writeState(manifest, lockFile);
124
130
  return ok({ updated });
125
131
  }
126
132
  async removeSource(sourceIds) {
127
- await this.store.init();
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.writeManifest(manifest);
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.init();
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.writeManifest(manifest);
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 resolvedPath = path.resolve(trimmed);
304
- if (await pathExists(resolvedPath)) {
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.parseGitHubTreeLocator(trimmed);
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
- ...(options.path ?? treeLocator.requestedPath
323
- ? { requestedPath: options.path ?? treeLocator.requestedPath }
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
- parseGitHubTreeLocator(locator) {
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 (parts.length < 5 || parts[2] !== "tree") {
372
- return null;
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
- const owner = parts[0];
375
- const repo = parts[1];
376
- const requestedPath = parts.slice(4).join("/");
377
- if (!owner || !repo || !requestedPath) {
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
- if (!requestedPath) {
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
- const contentHash = await hashDirectory(currentLock.checkoutPath);
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 {