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
@@ -2,14 +2,15 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createChannelAdapters } from "../adapters/channel-adapters.js";
4
4
  import { StateStore } from "../state/store.js";
5
- import { ensureDir, hashDirectory, pathExists, readJsonFile, removePath, writeJsonFile } from "../utils/fs.js";
5
+ import { ensureDir, hashDirectory, pathExists, readJsonFile, removePath, writeJsonFile, } from "../utils/fs.js";
6
6
  import { getBuiltinGitSources } from "../utils/builtin-git-sources.js";
7
7
  import { fetchGitHubSkillPaths } from "../utils/github-catalog.js";
8
- import { parseGitHubRepo } from "../utils/naming.js";
8
+ import { buildProjectedSkillNameCandidates, parseGitHubRepo, resolveProjectedSkillNames, } from "../utils/naming.js";
9
9
  import { fail, ok } from "../utils/result.js";
10
10
  import { searchClawHubSkills } from "../utils/clawhub.js";
11
11
  import { deriveDisplayName, deriveSourceId } from "../utils/source-id.js";
12
12
  import { DeploymentApplier } from "./deployment-applier.js";
13
+ import { ConfigCoordinator } from "./config-coordinator.js";
13
14
  import { DeploymentPlanner } from "./deployment-planner.js";
14
15
  import { DoctorService } from "./doctor-service.js";
15
16
  import { InventoryService } from "./inventory-service.js";
@@ -17,56 +18,98 @@ import { SourceService } from "./source-service.js";
17
18
  import { WorkflowService } from "./workflow-service.js";
18
19
  import { WorkspaceBootstrapService, } from "./workspace-bootstrap-service.js";
19
20
  export class SkillFlowApp {
20
- store = new StateStore();
21
- inventoryService = new InventoryService();
22
- sourceService = new SourceService(this.store, this.inventoryService);
23
- planner = new DeploymentPlanner(createChannelAdapters());
24
- applier = new DeploymentApplier();
25
- doctorService = new DoctorService();
26
- workflowService = new WorkflowService();
27
- workspaceBootstrapService = new WorkspaceBootstrapService(this.store);
21
+ store;
22
+ inventoryService;
23
+ sourceService;
24
+ planner;
25
+ applier;
26
+ doctorService;
27
+ workflowService;
28
+ workspaceBootstrapService;
29
+ configCoordinator;
30
+ mutationQueue = Promise.resolve();
31
+ constructor() {
32
+ this.store = new StateStore();
33
+ this.inventoryService = new InventoryService();
34
+ this.sourceService = new SourceService(this.store, this.inventoryService);
35
+ this.planner = new DeploymentPlanner(createChannelAdapters());
36
+ this.applier = new DeploymentApplier();
37
+ this.doctorService = new DoctorService();
38
+ this.workflowService = new WorkflowService();
39
+ this.workspaceBootstrapService = new WorkspaceBootstrapService(this.store);
40
+ this.configCoordinator = new ConfigCoordinator({
41
+ store: this.store,
42
+ doctorService: this.doctorService,
43
+ workflowService: this.workflowService,
44
+ getAvailableTargets: () => this.getAvailableTargets(),
45
+ pruneMissingCheckouts: () => this.pruneMissingCheckoutsImpl(),
46
+ getConfigData: () => this.getConfigDataImpl(),
47
+ });
48
+ }
28
49
  async addSource(locator, options) {
50
+ return this.runSerializedMutation(() => this.addSourceImpl(locator, options));
51
+ }
52
+ async addSourceImpl(locator, options) {
29
53
  const addOptions = options ?? {};
30
54
  const result = await this.sourceService.addSource(locator, addOptions);
31
55
  if (!result.ok) {
32
56
  return result;
33
57
  }
34
- await this.store.init();
35
- const manifest = await this.store.readManifest();
36
- const lockFile = await this.store.readLock();
58
+ const { manifest, lockFile } = await this.store.readState();
37
59
  const source = manifest.sources.find((item) => item.id === result.data.manifest.id);
38
60
  if (!source) {
39
61
  return result;
40
62
  }
63
+ const requestedPath = this.normalizeRequestedPath(source.requestedPath);
64
+ if (requestedPath) {
65
+ source.requestedPath = requestedPath;
66
+ result.data.manifest.requestedPath = requestedPath;
67
+ }
68
+ else {
69
+ delete source.requestedPath;
70
+ delete result.data.manifest.requestedPath;
71
+ }
72
+ const sourceLeafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === source.id);
73
+ const selectedLeafIds = this.selectLeafIdsForRequestedPath(sourceLeafs, requestedPath);
41
74
  source.selectionMode =
42
75
  addOptions.selectionMode ??
43
- (source.requestedPath ? "partial" : "all");
76
+ (selectedLeafIds.length >= sourceLeafs.length && sourceLeafs.length > 0
77
+ ? "all"
78
+ : "partial");
79
+ result.data.manifest.selectionMode = source.selectionMode;
44
80
  const enabledTargets = addOptions.enabledTargets ??
45
81
  await this.getAvailableTargets();
46
82
  manifest.bindings[source.id] = this.bindingFromDraft({
47
83
  enabledTargets,
48
- selectedLeafIds: this.selectLeafIdsForRequestedPath(lockFile.leafInventory.filter((leaf) => leaf.sourceId === source.id), source.requestedPath),
84
+ selectedLeafIds,
49
85
  });
50
- await this.store.writeManifest(manifest);
86
+ await this.store.writeState(manifest, lockFile);
87
+ const warnings = [...result.warnings];
88
+ if (requestedPath && selectedLeafIds.length < sourceLeafs.length) {
89
+ warnings.push({
90
+ code: "ADD_SELECTION_PRESELECTED",
91
+ message: `Preselected ${selectedLeafIds.length} of ${sourceLeafs.length} ` +
92
+ `skill${sourceLeafs.length === 1 ? "" : "s"} under '${requestedPath}'; ` +
93
+ "the full skills group was imported.",
94
+ });
95
+ }
51
96
  if (addOptions.project === false) {
52
- return result;
97
+ return ok(result.data, warnings);
53
98
  }
54
99
  const plan = await this.planForAffectedSources(manifest, lockFile, source.id);
55
100
  if (!plan.ok) {
56
- return fail(plan.errors, [...result.warnings, ...plan.warnings]);
101
+ return fail(plan.errors, [...warnings, ...plan.warnings]);
57
102
  }
58
103
  const applied = await this.applier.applyPlan(lockFile, plan.data.actions);
59
- await this.store.writeLock(lockFile);
104
+ await this.store.writeState(manifest, lockFile);
60
105
  if (!applied.ok) {
61
- return fail(applied.errors, [...result.warnings, ...plan.warnings, ...applied.warnings]);
106
+ return fail(applied.errors, [...warnings, ...plan.warnings, ...applied.warnings]);
62
107
  }
63
- return ok(result.data, [...result.warnings, ...plan.warnings, ...applied.warnings]);
108
+ return ok(result.data, [...warnings, ...plan.warnings, ...applied.warnings]);
64
109
  }
65
110
  async findSkills(query) {
66
- await this.store.init();
67
- const manifest = await this.store.readManifest();
68
- const lockFile = await this.store.readLock();
69
- const normalizedQuery = query.trim().toLowerCase();
111
+ const { manifest, lockFile } = await this.store.readState();
112
+ const normalizedQuery = this.normalizeSearchQuery(query);
70
113
  const warnings = [];
71
114
  const localKeys = new Set();
72
115
  const candidates = [];
@@ -111,7 +154,7 @@ export class SkillFlowApp {
111
154
  }
112
155
  }
113
156
  try {
114
- const results = await searchClawHubSkills(query, 8);
157
+ const results = await searchClawHubSkills(normalizedQuery, 8);
115
158
  remoteSearchSucceeded = true;
116
159
  for (const result of results) {
117
160
  candidates.push({
@@ -147,124 +190,63 @@ export class SkillFlowApp {
147
190
  return ok({ candidates }, warnings);
148
191
  }
149
192
  async listWorkflows() {
193
+ return this.runSerializedMutation(() => this.listWorkflowsImpl());
194
+ }
195
+ async listWorkflowsImpl() {
196
+ const pruned = await this.pruneMissingCheckoutsImpl();
197
+ if (!pruned.ok) {
198
+ return fail(pruned.errors, pruned.warnings);
199
+ }
150
200
  const reconciled = await this.sourceService.reconcileInventory(undefined, {
151
201
  force: true,
152
202
  });
153
203
  if (!reconciled.ok) {
154
204
  return fail(reconciled.errors, reconciled.warnings);
155
205
  }
156
- await this.store.init();
157
- const manifest = await this.store.readManifest();
158
- const lockFile = await this.store.readLock();
206
+ const { manifest, lockFile } = await this.store.readState();
159
207
  await this.persistNormalizedBindings(manifest, lockFile);
160
208
  return ok({
161
209
  summaries: this.workflowService.getSummaries(manifest, lockFile),
162
- });
210
+ }, pruned.warnings);
163
211
  }
164
212
  async getConfigData() {
213
+ return this.runSerializedMutation(() => this.getConfigDataImpl());
214
+ }
215
+ async getConfigDataImpl() {
216
+ const pruned = await this.pruneMissingCheckoutsImpl();
217
+ if (!pruned.ok) {
218
+ return fail(pruned.errors, pruned.warnings);
219
+ }
165
220
  const reconciled = await this.sourceService.reconcileInventory(undefined, {
166
221
  force: true,
167
222
  });
168
223
  if (!reconciled.ok) {
169
224
  return fail(reconciled.errors, reconciled.warnings);
170
225
  }
171
- await this.store.init();
172
- const manifest = await this.store.readManifest();
173
- const lockFile = await this.store.readLock();
226
+ const { manifest, lockFile } = await this.store.readState();
174
227
  await this.persistNormalizedBindings(manifest, lockFile);
175
228
  return ok({
176
229
  manifest,
177
230
  lockFile,
178
231
  summaries: this.workflowService.getSummaries(manifest, lockFile),
179
- });
232
+ }, pruned.warnings);
180
233
  }
181
234
  async bootstrapWorkspaceState(onEvent) {
182
- onEvent?.({
183
- phase: "detect-targets",
184
- level: "info",
185
- message: "Detecting available agent targets...",
186
- });
187
- const availableTargets = await this.getAvailableTargets();
188
- await this.store.init();
189
- let manifest = await this.store.readManifest();
190
- let lockFile = await this.store.readLock();
191
- const detected = await this.workspaceBootstrapService.detectUnmanagedExternalSkills(manifest, lockFile, onEvent);
192
- const importedSourceIds = [];
193
- if (detected.length > 0) {
194
- onEvent?.({
195
- phase: "import-unmanaged-skills",
196
- level: "info",
197
- message: `Importing ${detected.length} unmanaged skill${detected.length === 1 ? "" : "s"} into skill-flow...`,
198
- });
199
- }
200
- for (const item of detected) {
201
- const imported = await this.addSource(item.path, {
202
- enabledTargets: item.importedFromTargets,
203
- selectionMode: "all",
204
- project: false,
205
- sourceIdOverride: item.sourceId,
206
- displayNameOverride: item.displayName,
207
- importedFromTargets: item.importedFromTargets,
208
- importMode: "bootstrap-detected",
209
- ...(item.originLocator ? { originLocator: item.originLocator } : {}),
210
- ...(item.originRequestedPath ? { originRequestedPath: item.originRequestedPath } : {}),
211
- ...(item.originBranch ? { originBranch: item.originBranch } : {}),
212
- });
213
- if (!imported.ok) {
214
- return fail(imported.errors, imported.warnings);
215
- }
216
- importedSourceIds.push(imported.data.manifest.id);
217
- onEvent?.({
218
- phase: "import-unmanaged-skills",
219
- level: "success",
220
- message: `Imported ${imported.data.manifest.displayName}.`,
221
- });
222
- }
223
- onEvent?.({
224
- phase: "refresh-sources",
225
- level: "info",
226
- message: "Refreshing managed inventory...",
227
- });
228
- const reconciled = await this.sourceService.reconcileInventory(undefined, { force: true });
229
- if (!reconciled.ok) {
230
- return fail(reconciled.errors, reconciled.warnings);
235
+ return this.runSerializedMutation(() => this.bootstrapWorkspaceStateImpl(onEvent));
236
+ }
237
+ async bootstrapWorkspaceStateImpl(onEvent) {
238
+ const boot = await this.configCoordinator.bootstrapWorkspaceState(onEvent);
239
+ if (!boot.ok) {
240
+ return fail(boot.errors, boot.warnings);
231
241
  }
232
- manifest = await this.store.readManifest();
233
- lockFile = await this.store.readLock();
234
- onEvent?.({
235
- phase: "normalize-bindings",
236
- level: "info",
237
- message: "Normalizing config state...",
238
- });
239
- await this.persistNormalizedBindings(manifest, lockFile);
240
- onEvent?.({
241
- phase: "audit-projections",
242
- level: "info",
243
- message: "Auditing current projections...",
244
- });
245
- const audit = await this.doctorService.run(manifest, lockFile);
246
- if (!audit.ok) {
247
- return fail(audit.errors, audit.warnings);
248
- }
249
- onEvent?.({
250
- phase: "build-summaries",
251
- level: "info",
252
- message: "Building config summaries...",
253
- });
254
- const summaries = this.workflowService.getSummaries(manifest, lockFile, audit.data);
255
- onEvent?.({
256
- phase: "done",
257
- level: "success",
258
- message: "Config bootstrap complete.",
259
- });
260
242
  return ok({
261
- availableTargets,
262
- manifest,
263
- lockFile,
264
- summaries,
265
- initialDrafts: this.buildInitialDrafts(summaries),
266
- audit: audit.data,
267
- importedSourceIds,
243
+ availableTargets: boot.data.availableTargets,
244
+ manifest: boot.data.manifest,
245
+ lockFile: boot.data.lockFile,
246
+ summaries: boot.data.summaries,
247
+ initialDrafts: boot.data.initialDrafts,
248
+ audit: boot.data.audit,
249
+ importedSourceIds: [],
268
250
  });
269
251
  }
270
252
  async getAvailableTargets() {
@@ -282,9 +264,7 @@ export class SkillFlowApp {
282
264
  // config TUI state flow:
283
265
  // draft -> previewDraft() -> plan only
284
266
  // draft -> applyDraft() -> plan + filesystem + manifest/lock writes
285
- await this.store.init();
286
- const manifest = await this.store.readManifest();
287
- const lockFile = await this.store.readLock();
267
+ const { manifest, lockFile } = await this.store.readState();
288
268
  this.normalizeBindings(manifest, lockFile);
289
269
  const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
290
270
  const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
@@ -294,15 +274,10 @@ export class SkillFlowApp {
294
274
  return ok({ plan: plan.data, manifest: prepared.manifest, lockFile }, [...prepared.warnings, ...plan.warnings]);
295
275
  }
296
276
  async applyDraft(sourceId, draft) {
297
- const reconciled = await this.sourceService.reconcileInventory([sourceId], {
298
- force: true,
299
- });
300
- if (!reconciled.ok) {
301
- return fail(reconciled.errors, reconciled.warnings);
302
- }
303
- await this.store.init();
304
- const manifest = await this.store.readManifest();
305
- const lockFile = await this.store.readLock();
277
+ return this.runSerializedMutation(() => this.applyDraftImpl(sourceId, draft));
278
+ }
279
+ async applyDraftImpl(sourceId, draft) {
280
+ const { manifest, lockFile } = await this.store.readState();
306
281
  this.normalizeBindings(manifest, lockFile);
307
282
  const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
308
283
  const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
@@ -310,56 +285,143 @@ export class SkillFlowApp {
310
285
  return fail(plan.errors, [...prepared.warnings, ...plan.warnings]);
311
286
  }
312
287
  const applyResult = await this.applier.applyPlan(lockFile, plan.data.actions);
313
- await this.store.writeManifest(prepared.manifest);
314
- await this.store.writeLock(lockFile);
288
+ await this.store.writeState(prepared.manifest, lockFile);
315
289
  if (!applyResult.ok) {
316
290
  return fail(applyResult.errors, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
317
291
  }
318
292
  return ok({ actions: plan.data.actions, draft: prepared.draft }, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
319
293
  }
320
294
  async updateSources(sourceIds) {
321
- const reconciled = await this.sourceService.reconcileInventory(sourceIds, {
322
- force: true,
323
- });
324
- if (!reconciled.ok) {
325
- return fail(reconciled.errors, reconciled.warnings);
295
+ return this.runSerializedMutation(() => this.updateSourcesImpl(sourceIds));
296
+ }
297
+ async updateSourcesImpl(sourceIds) {
298
+ const pruned = await this.pruneMissingCheckoutsImpl();
299
+ if (!pruned.ok) {
300
+ return fail(pruned.errors, pruned.warnings);
326
301
  }
327
- const updated = await this.sourceService.updateSources(sourceIds);
302
+ const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
303
+ if (sourceIds?.length && requestedIds?.length === 0) {
304
+ return ok({ updated: [] }, pruned.warnings);
305
+ }
306
+ const updated = await this.sourceService.updateSources(requestedIds);
328
307
  if (!updated.ok) {
329
308
  return updated;
330
309
  }
331
- const manifest = await this.store.readManifest();
332
- const lockFile = await this.store.readLock();
333
- this.applySelectionModeForUpdatedSources(manifest, lockFile, updated.data.updated);
310
+ const { manifest, lockFile } = await this.store.readState();
311
+ this.applySourceUpdateResults(manifest, lockFile, updated.data.updated);
334
312
  await this.persistNormalizedBindings(manifest, lockFile);
335
- const activeSourceIds = manifest.sources
313
+ const planSourceIds = manifest.sources
336
314
  .map((source) => source.id)
337
- .filter((id) => this.hasActiveTargets(manifest, id));
338
- for (const sourceId of activeSourceIds) {
339
- const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
340
- if (!plan.ok) {
341
- return fail(plan.errors, plan.warnings);
342
- }
343
- await this.applier.applyPlan(lockFile, plan.data.actions);
315
+ .filter((id) => updated.data.updated.some((item) => item.sourceId === id) ||
316
+ this.hasActiveTargets(manifest, id) ||
317
+ lockFile.deployments.some((deployment) => deployment.sourceId === id));
318
+ const planned = await this.planAndApplySources(manifest, lockFile, planSourceIds);
319
+ if (!planned.ok) {
320
+ return fail(planned.errors, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
344
321
  }
345
- await this.store.writeLock(lockFile);
346
- return updated;
322
+ await this.store.writeState(manifest, lockFile);
323
+ return ok(updated.data, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
347
324
  }
348
325
  async doctor() {
326
+ return this.runSerializedMutation(() => this.doctorImpl());
327
+ }
328
+ async doctorImpl() {
329
+ const pruned = await this.pruneMissingCheckoutsImpl();
330
+ if (!pruned.ok) {
331
+ return fail(pruned.errors, pruned.warnings);
332
+ }
349
333
  const reconciled = await this.sourceService.reconcileInventory();
350
334
  if (!reconciled.ok) {
351
335
  return fail(reconciled.errors, reconciled.warnings);
352
336
  }
353
- await this.store.init();
354
- const manifest = await this.store.readManifest();
355
- const lockFile = await this.store.readLock();
337
+ const { manifest, lockFile } = await this.store.readState();
356
338
  await this.persistNormalizedBindings(manifest, lockFile);
357
- return this.doctorService.run(manifest, lockFile);
339
+ const doctor = await this.doctorService.run(manifest, lockFile);
340
+ if (!doctor.ok) {
341
+ return doctor;
342
+ }
343
+ return ok(doctor.data, [...pruned.warnings, ...doctor.warnings]);
344
+ }
345
+ async repairTargets(sourceIds) {
346
+ return this.runSerializedMutation(() => this.repairTargetsImpl(sourceIds));
347
+ }
348
+ async repairTargetsImpl(sourceIds) {
349
+ const { manifest, lockFile } = await this.store.readState();
350
+ this.normalizeBindings(manifest, lockFile);
351
+ const requestedIds = sourceIds?.length
352
+ ? sourceIds
353
+ : manifest.sources.map((source) => source.id);
354
+ for (const sourceId of requestedIds) {
355
+ if (!manifest.sources.some((source) => source.id === sourceId)) {
356
+ return fail({
357
+ code: "SOURCE_NOT_FOUND",
358
+ message: `Skills group id '${sourceId}' is not registered.`,
359
+ });
360
+ }
361
+ }
362
+ const planSourceIds = requestedIds.filter((sourceId) => this.hasActiveTargets(manifest, sourceId) ||
363
+ lockFile.deployments.some((deployment) => deployment.sourceId === sourceId));
364
+ const planned = await this.planAndApplySources(manifest, lockFile, planSourceIds);
365
+ if (!planned.ok) {
366
+ return fail(planned.errors, planned.warnings);
367
+ }
368
+ await this.store.writeState(manifest, lockFile);
369
+ return ok({ actions: planned.data.actions }, planned.warnings);
370
+ }
371
+ async repairSource(sourceIds) {
372
+ return this.runSerializedMutation(() => this.repairSourceImpl(sourceIds));
373
+ }
374
+ async repairSourceImpl(sourceIds) {
375
+ const pruned = await this.pruneMissingCheckoutsImpl();
376
+ if (!pruned.ok) {
377
+ return fail(pruned.errors, pruned.warnings);
378
+ }
379
+ const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
380
+ if (sourceIds?.length && requestedIds?.length === 0) {
381
+ return ok({ updated: [] }, pruned.warnings);
382
+ }
383
+ const repaired = await this.sourceService.updateSources(requestedIds);
384
+ if (!repaired.ok) {
385
+ return repaired;
386
+ }
387
+ const { manifest, lockFile } = await this.store.readState();
388
+ this.applySourceUpdateResults(manifest, lockFile, repaired.data.updated);
389
+ await this.persistNormalizedBindings(manifest, lockFile);
390
+ await this.store.writeState(manifest, lockFile);
391
+ return ok(repaired.data, [...pruned.warnings, ...repaired.warnings]);
392
+ }
393
+ async repairState(sourceIds) {
394
+ return this.runSerializedMutation(() => this.repairStateImpl(sourceIds));
395
+ }
396
+ async repairStateImpl(sourceIds) {
397
+ const pruned = await this.pruneMissingCheckoutsImpl();
398
+ if (!pruned.ok) {
399
+ return fail(pruned.errors, pruned.warnings);
400
+ }
401
+ const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
402
+ if (sourceIds?.length && requestedIds?.length === 0) {
403
+ return ok({ repairedSourceIds: [], removedDeploymentCount: 0 }, pruned.warnings);
404
+ }
405
+ const reconciled = await this.sourceService.reconcileInventory(requestedIds, {
406
+ force: true,
407
+ });
408
+ if (!reconciled.ok) {
409
+ return fail(reconciled.errors, [...pruned.warnings, ...reconciled.warnings]);
410
+ }
411
+ const { manifest, lockFile } = await this.store.readState();
412
+ await this.persistNormalizedBindings(manifest, lockFile);
413
+ const removedDeploymentCount = await this.rebuildDeploymentState(manifest, lockFile, requestedIds);
414
+ await this.store.writeState(manifest, lockFile);
415
+ return ok({
416
+ repairedSourceIds: reconciled.data.updatedSourceIds,
417
+ removedDeploymentCount,
418
+ }, [...pruned.warnings, ...reconciled.warnings]);
358
419
  }
359
420
  async uninstall(sourceIds) {
360
- await this.store.init();
361
- const manifest = await this.store.readManifest();
362
- const lockFile = await this.store.readLock();
421
+ return this.runSerializedMutation(() => this.uninstallImpl(sourceIds));
422
+ }
423
+ async uninstallImpl(sourceIds) {
424
+ const { manifest, lockFile } = await this.store.readState();
363
425
  const warnings = [];
364
426
  const removedRefs = sourceIds
365
427
  .map((sourceId) => manifest.sources.find((source) => source.id === sourceId))
@@ -370,26 +432,33 @@ export class SkillFlowApp {
370
432
  if (!(await pathExists(deployment.targetPath))) {
371
433
  continue;
372
434
  }
373
- if (deployment.strategy === "symlink") {
374
- const stats = await fs.lstat(deployment.targetPath);
375
- if (stats.isSymbolicLink()) {
376
- await removePath(deployment.targetPath);
377
- }
378
- else {
379
- warnings.push(`Skipped ${deployment.targetPath} because it no longer looks like a managed symlink.`);
380
- }
381
- continue;
382
- }
383
- const currentHash = await hashDirectory(deployment.targetPath);
384
- if (currentHash === deployment.contentHash) {
435
+ try {
385
436
  await removePath(deployment.targetPath);
386
437
  }
387
- else {
388
- warnings.push(`Skipped ${deployment.targetPath} because the copied skill has drifted from saved state.`);
438
+ catch (error) {
439
+ warnings.push(`Unable to remove ${deployment.targetPath}: ${String(error)}`);
389
440
  }
390
441
  }
391
442
  }
392
- const removed = await this.sourceService.removeSource(sourceIds);
443
+ if (warnings.length > 0) {
444
+ return fail({
445
+ code: "GROUP_DELETE_INCOMPLETE",
446
+ message: `Unable to fully delete ${warnings.length} managed path${warnings.length === 1 ? "" : "s"}.`,
447
+ }, warnings.map((message) => ({
448
+ code: "GROUP_DELETE_PATH_FAILED",
449
+ message,
450
+ })));
451
+ }
452
+ let removed;
453
+ try {
454
+ removed = await this.sourceService.removeSource(sourceIds);
455
+ }
456
+ catch (error) {
457
+ return fail({
458
+ code: "GROUP_DELETE_INCOMPLETE",
459
+ message: `Unable to fully delete selected skills groups: ${String(error)}`,
460
+ });
461
+ }
393
462
  if (!removed.ok) {
394
463
  return fail(removed.errors, removed.warnings);
395
464
  }
@@ -405,11 +474,58 @@ export class SkillFlowApp {
405
474
  }
406
475
  return { targets };
407
476
  }
477
+ async pruneMissingCheckoutsImpl() {
478
+ const { manifest, lockFile } = await this.store.readState();
479
+ const removedSourceIds = [];
480
+ const warnings = [];
481
+ for (const source of lockFile.sources) {
482
+ if (await pathExists(source.checkoutPath)) {
483
+ continue;
484
+ }
485
+ removedSourceIds.push(source.id);
486
+ warnings.push({
487
+ code: "SOURCE_CHECKOUT_MISSING",
488
+ message: `Removed ${source.displayName} because checkout is missing at ${source.checkoutPath}.`,
489
+ });
490
+ const deployments = lockFile.deployments.filter((deployment) => deployment.sourceId === source.id);
491
+ for (const deployment of deployments) {
492
+ if (!(await pathExists(deployment.targetPath))) {
493
+ continue;
494
+ }
495
+ try {
496
+ await removePath(deployment.targetPath);
497
+ }
498
+ catch (error) {
499
+ return fail({
500
+ code: "SOURCE_CHECKOUT_PRUNE_FAILED",
501
+ message: `Unable to clean deployment ${deployment.targetPath}: ${String(error)}`,
502
+ }, warnings);
503
+ }
504
+ }
505
+ }
506
+ if (removedSourceIds.length === 0) {
507
+ return ok({ removedSourceIds: [] });
508
+ }
509
+ manifest.sources = manifest.sources.filter((source) => !removedSourceIds.includes(source.id));
510
+ for (const sourceId of removedSourceIds) {
511
+ delete manifest.bindings[sourceId];
512
+ }
513
+ lockFile.sources = lockFile.sources.filter((source) => !removedSourceIds.includes(source.id));
514
+ lockFile.leafInventory = lockFile.leafInventory.filter((leaf) => !removedSourceIds.includes(leaf.sourceId));
515
+ lockFile.deployments = lockFile.deployments.filter((deployment) => !removedSourceIds.includes(deployment.sourceId));
516
+ await this.store.writeState(manifest, lockFile);
517
+ return ok({ removedSourceIds }, warnings);
518
+ }
408
519
  async persistNormalizedBindings(manifest, lockFile) {
409
520
  if (!this.normalizeBindings(manifest, lockFile)) {
410
521
  return;
411
522
  }
412
- await this.store.writeManifest(manifest);
523
+ await this.store.writeState(manifest, lockFile);
524
+ }
525
+ async runSerializedMutation(task) {
526
+ const run = this.mutationQueue.then(() => this.store.withMutationLock(task), () => this.store.withMutationLock(task));
527
+ this.mutationQueue = run.then(() => undefined, () => undefined);
528
+ return run;
413
529
  }
414
530
  normalizeBindings(manifest, lockFile) {
415
531
  let changed = false;
@@ -439,15 +555,22 @@ export class SkillFlowApp {
439
555
  };
440
556
  }
441
557
  selectLeafIdsForRequestedPath(leafs, requestedPath) {
442
- if (!requestedPath) {
558
+ const normalizedPath = this.normalizeRequestedPath(requestedPath);
559
+ if (!normalizedPath) {
443
560
  return leafs.map((leaf) => leaf.id);
444
561
  }
445
- const normalizedPath = requestedPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
446
562
  return leafs
447
563
  .filter((leaf) => leaf.relativePath === normalizedPath ||
448
564
  leaf.relativePath.startsWith(`${normalizedPath}/`))
449
565
  .map((leaf) => leaf.id);
450
566
  }
567
+ normalizeRequestedPath(requestedPath) {
568
+ if (!requestedPath) {
569
+ return undefined;
570
+ }
571
+ const normalized = requestedPath.trim().replace(/^\.\/+/, "").replace(/\/+$/, "");
572
+ return normalized.length > 0 && normalized !== "." ? normalized : undefined;
573
+ }
451
574
  prepareManifestForDraft(manifest, lockFile, sourceId, draft) {
452
575
  manifest.bindings[sourceId] = this.bindingFromDraft(draft);
453
576
  const source = manifest.sources.find((item) => item.id === sourceId);
@@ -510,9 +633,13 @@ export class SkillFlowApp {
510
633
  const sourceIds = manifest.sources
511
634
  .map((source) => source.id)
512
635
  .filter((sourceId) => sourceId === primarySourceId || this.hasActiveTargets(manifest, sourceId));
636
+ return this.planForSources(manifest, lockFile, sourceIds);
637
+ }
638
+ async planForSources(manifest, lockFile, sourceIds) {
639
+ const uniqueSourceIds = [...new Set(sourceIds)];
513
640
  const actions = [];
514
641
  const warnings = [];
515
- for (const sourceId of sourceIds) {
642
+ for (const sourceId of uniqueSourceIds) {
516
643
  const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
517
644
  if (!plan.ok) {
518
645
  return fail(plan.errors, [...warnings, ...plan.warnings]);
@@ -526,6 +653,148 @@ export class SkillFlowApp {
526
653
  blocked: actions.filter((action) => action.kind === "blocked"),
527
654
  }, warnings);
528
655
  }
656
+ async planAndApplySources(manifest, lockFile, sourceIds) {
657
+ const planned = await this.planForSources(manifest, lockFile, sourceIds);
658
+ if (!planned.ok) {
659
+ return fail(planned.errors, planned.warnings);
660
+ }
661
+ const applyResult = await this.applier.applyPlan(lockFile, planned.data.actions);
662
+ if (!applyResult.ok) {
663
+ return fail(applyResult.errors, [...planned.warnings, ...applyResult.warnings]);
664
+ }
665
+ return ok({ actions: planned.data.actions }, [...planned.warnings, ...applyResult.warnings]);
666
+ }
667
+ async rebuildDeploymentState(manifest, lockFile, sourceIds) {
668
+ const requested = sourceIds?.length ? new Set(sourceIds) : undefined;
669
+ const previousDeployments = lockFile.deployments;
670
+ const previousCount = previousDeployments.length;
671
+ const previousByKey = new Map(previousDeployments.map((deployment) => [
672
+ this.getDeploymentKey(deployment.sourceId, deployment.leafId, deployment.target),
673
+ deployment,
674
+ ]));
675
+ const nextDeployments = previousDeployments.filter((deployment) => requested ? !requested.has(deployment.sourceId) : false);
676
+ const adapters = createChannelAdapters();
677
+ const detectionCache = new Map();
678
+ const projectedNameCache = new Map();
679
+ for (const source of manifest.sources) {
680
+ if (requested && !requested.has(source.id)) {
681
+ continue;
682
+ }
683
+ const binding = manifest.bindings[source.id] ?? { targets: {} };
684
+ for (const adapter of adapters) {
685
+ const targetBinding = binding.targets[adapter.target];
686
+ if (!targetBinding?.enabled) {
687
+ continue;
688
+ }
689
+ let detection = detectionCache.get(adapter.target);
690
+ if (!detection) {
691
+ detection = await adapter.detect();
692
+ detectionCache.set(adapter.target, detection);
693
+ }
694
+ for (const leafId of targetBinding.leafIds) {
695
+ const leaf = lockFile.leafInventory.find((candidate) => candidate.sourceId === source.id && candidate.id === leafId);
696
+ if (!leaf) {
697
+ continue;
698
+ }
699
+ const existing = previousByKey.get(this.getDeploymentKey(source.id, leaf.id, adapter.target));
700
+ if (!detection.available) {
701
+ if (existing) {
702
+ nextDeployments.push({
703
+ ...existing,
704
+ contentHash: leaf.contentHash,
705
+ status: "active",
706
+ });
707
+ }
708
+ continue;
709
+ }
710
+ let projectedLinkNames = projectedNameCache.get(adapter.target);
711
+ if (!projectedLinkNames) {
712
+ projectedLinkNames = this.buildProjectedLinkNameMap(manifest, lockFile, adapter.target);
713
+ projectedNameCache.set(adapter.target, projectedLinkNames);
714
+ }
715
+ const rebuilt = await this.findManagedDeploymentOnDisk(source, leaf, adapter.target, adapter.strategy, detection.rootPath, projectedLinkNames, existing);
716
+ if (!rebuilt) {
717
+ continue;
718
+ }
719
+ nextDeployments.push(rebuilt);
720
+ }
721
+ }
722
+ }
723
+ lockFile.deployments = nextDeployments;
724
+ return Math.max(0, previousCount - nextDeployments.length);
725
+ }
726
+ buildProjectedLinkNameMap(manifest, lockFile, target) {
727
+ return resolveProjectedSkillNames(manifest.sources.flatMap((source) => {
728
+ const targetBinding = manifest.bindings[source.id]?.targets[target];
729
+ if (!targetBinding?.enabled) {
730
+ return [];
731
+ }
732
+ return targetBinding.leafIds
733
+ .map((leafId) => lockFile.leafInventory.find((leaf) => leaf.id === leafId))
734
+ .filter((leaf) => Boolean(leaf))
735
+ .map((leaf) => ({
736
+ leafId: leaf.id,
737
+ groupId: source.id,
738
+ groupName: source.displayName,
739
+ groupAuthor: parseGitHubRepo(source.locator)?.owner,
740
+ skillName: leaf.linkName,
741
+ }));
742
+ }));
743
+ }
744
+ async findManagedDeploymentOnDisk(source, leaf, target, strategy, rootPath, projectedLinkNames, existing) {
745
+ const projectedLinkName = projectedLinkNames.get(leaf.id) ?? leaf.linkName;
746
+ const candidatePaths = buildProjectedSkillNameCandidates({
747
+ preferredName: projectedLinkName,
748
+ groupId: source.id,
749
+ groupName: source.displayName,
750
+ groupAuthor: parseGitHubRepo(source.locator)?.owner,
751
+ skillName: leaf.linkName,
752
+ }).map((name) => path.join(rootPath, name));
753
+ if (existing?.targetPath && !candidatePaths.includes(existing.targetPath)) {
754
+ candidatePaths.unshift(existing.targetPath);
755
+ }
756
+ for (const targetPath of candidatePaths) {
757
+ const matches = await this.matchesManagedProjection(strategy, targetPath, leaf);
758
+ if (!matches) {
759
+ continue;
760
+ }
761
+ return {
762
+ sourceId: source.id,
763
+ leafId: leaf.id,
764
+ target,
765
+ targetPath,
766
+ strategy,
767
+ status: "active",
768
+ contentHash: leaf.contentHash,
769
+ appliedAt: existing?.appliedAt ?? new Date().toISOString(),
770
+ };
771
+ }
772
+ return undefined;
773
+ }
774
+ async matchesManagedProjection(strategy, targetPath, leaf) {
775
+ try {
776
+ const stats = await fs.lstat(targetPath);
777
+ if (strategy === "symlink") {
778
+ if (!stats.isSymbolicLink()) {
779
+ return false;
780
+ }
781
+ const linked = await fs.readlink(targetPath);
782
+ const resolved = path.resolve(path.dirname(targetPath), linked);
783
+ return resolved === leaf.absolutePath;
784
+ }
785
+ if (!stats.isDirectory()) {
786
+ return false;
787
+ }
788
+ const onDiskHash = await hashDirectory(targetPath);
789
+ return onDiskHash === leaf.contentHash;
790
+ }
791
+ catch {
792
+ return false;
793
+ }
794
+ }
795
+ getDeploymentKey(sourceId, leafId, target) {
796
+ return `${sourceId}\n${leafId}\n${target}`;
797
+ }
529
798
  hasActiveTargets(manifest, sourceId) {
530
799
  const binding = manifest.bindings[sourceId];
531
800
  if (!binding) {
@@ -547,7 +816,7 @@ export class SkillFlowApp {
547
816
  const source = manifest.sources.find((item) => item.id === leaf.sourceId);
548
817
  return {
549
818
  id: `local:${leaf.id}`,
550
- title: leaf.title,
819
+ title: this.getCandidateTitle(leaf),
551
820
  description: leaf.description,
552
821
  source: "local",
553
822
  sourceLabel: source
@@ -666,7 +935,7 @@ export class SkillFlowApp {
666
935
  if (tokens.length === 0) {
667
936
  return true;
668
937
  }
669
- const haystack = fields.join("\n").toLowerCase();
938
+ const haystack = this.normalizeSearchQuery(fields.join("\n"));
670
939
  return tokens.every((token) => haystack.includes(token));
671
940
  }
672
941
  compareCandidates(left, right, query) {
@@ -696,8 +965,8 @@ export class SkillFlowApp {
696
965
  }
697
966
  getQueryScore(candidate, query) {
698
967
  const tokens = query.split(/\s+/).filter(Boolean);
699
- const titleField = candidate.title.toLowerCase();
700
- const pathTail = (candidate.relativePath ?? "").split("/").pop()?.toLowerCase() ?? "";
968
+ const titleField = this.normalizeSearchQuery(candidate.title);
969
+ const pathTail = this.normalizeSearchQuery((candidate.relativePath ?? "").split("/").pop() ?? "");
701
970
  const fields = [
702
971
  titleField,
703
972
  pathTail,
@@ -723,6 +992,16 @@ export class SkillFlowApp {
723
992
  }
724
993
  return candidate.locator;
725
994
  }
995
+ getCandidateTitle(leaf) {
996
+ const title = leaf.title.trim();
997
+ if (title.length === 0 || /^\{[^}]+\}$/.test(title)) {
998
+ return leaf.linkName || leaf.name;
999
+ }
1000
+ return title;
1001
+ }
1002
+ normalizeSearchQuery(value) {
1003
+ return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
1004
+ }
726
1005
  formatSourceLabel(locator, displayName) {
727
1006
  if (locator.startsWith("clawhub:")) {
728
1007
  return `${displayName}@clawhub`;
@@ -736,30 +1015,41 @@ export class SkillFlowApp {
736
1015
  }
737
1016
  return `${displayName}@${repo.owner}`;
738
1017
  }
739
- buildInitialDrafts(summaries) {
740
- return Object.fromEntries(summaries.map((summary) => {
741
- const enabledTargets = Object.entries(summary.bindings.targets)
742
- .filter(([, value]) => value?.enabled)
743
- .map(([target]) => target);
744
- const selectedLeafIds = [...new Set(enabledTargets.flatMap((target) => summary.bindings.targets[target]?.leafIds ?? []))];
745
- return [summary.source.id, { enabledTargets, selectedLeafIds }];
746
- }));
747
- }
748
- applySelectionModeForUpdatedSources(manifest, lockFile, updates) {
1018
+ applySourceUpdateResults(manifest, lockFile, updates) {
749
1019
  for (const update of updates) {
750
- if (!update.changed || update.addedLeafIds.length === 0) {
1020
+ if (!update.changed) {
751
1021
  continue;
752
1022
  }
753
1023
  const source = manifest.sources.find((item) => item.id === update.sourceId);
754
1024
  const binding = manifest.bindings[update.sourceId];
755
- if (!source || !binding || source.selectionMode !== "all") {
1025
+ if (!source || !binding) {
1026
+ continue;
1027
+ }
1028
+ for (const diff of update.diffs) {
1029
+ if (diff.kind !== "moved" || !diff.previousLeafId) {
1030
+ continue;
1031
+ }
1032
+ for (const targetBinding of Object.values(binding.targets)) {
1033
+ if (!targetBinding?.enabled || !targetBinding.leafIds.includes(diff.previousLeafId)) {
1034
+ continue;
1035
+ }
1036
+ targetBinding.leafIds = targetBinding.leafIds.map((leafId) => leafId === diff.previousLeafId ? diff.leafId : leafId);
1037
+ }
1038
+ }
1039
+ if ((update.selectionMode ?? source.selectionMode) !== "all") {
1040
+ continue;
1041
+ }
1042
+ const addedLeafIds = update.diffs
1043
+ .filter((diff) => diff.kind === "added")
1044
+ .map((diff) => diff.leafId);
1045
+ if (addedLeafIds.length === 0) {
756
1046
  continue;
757
1047
  }
758
1048
  for (const targetBinding of Object.values(binding.targets)) {
759
1049
  if (!targetBinding?.enabled) {
760
1050
  continue;
761
1051
  }
762
- const merged = new Set([...targetBinding.leafIds, ...update.addedLeafIds]);
1052
+ const merged = new Set([...targetBinding.leafIds, ...addedLeafIds]);
763
1053
  targetBinding.leafIds = [...merged].filter((leafId) => lockFile.leafInventory.some((leaf) => leaf.id === leafId && leaf.sourceId === update.sourceId));
764
1054
  }
765
1055
  }