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,51 +1,92 @@
1
1
  import fs from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { createChannelAdapters } from "../adapters/channel-adapters.js";
3
4
  import { StateStore } from "../state/store.js";
4
- import { ensureDir, hashDirectory, pathExists, readJsonFile, removePath, writeJsonFile } from "../utils/fs.js";
5
+ import { ensureDir, hashDirectory, pathExists, readJsonFile, removePath, writeJsonFile, } from "../utils/fs.js";
5
6
  import { getBuiltinGitSources } from "../utils/builtin-git-sources.js";
6
7
  import { fetchGitHubSkillPaths } from "../utils/github-catalog.js";
7
- import { parseGitHubRepo } from "../utils/naming.js";
8
+ import { buildProjectedSkillNameCandidates, parseGitHubRepo, resolveProjectedSkillNames, } from "../utils/naming.js";
8
9
  import { fail, ok } from "../utils/result.js";
9
10
  import { searchClawHubSkills } from "../utils/clawhub.js";
10
11
  import { deriveDisplayName, deriveSourceId } from "../utils/source-id.js";
11
12
  import { DeploymentApplier } from "./deployment-applier.js";
13
+ import { ConfigCoordinator } from "./config-coordinator.js";
12
14
  import { DeploymentPlanner } from "./deployment-planner.js";
13
15
  import { DoctorService } from "./doctor-service.js";
14
16
  import { InventoryService } from "./inventory-service.js";
15
17
  import { SourceService } from "./source-service.js";
16
18
  import { WorkflowService } from "./workflow-service.js";
19
+ import { WorkspaceBootstrapService, } from "./workspace-bootstrap-service.js";
17
20
  export class SkillFlowApp {
18
- store = new StateStore();
19
- inventoryService = new InventoryService();
20
- sourceService = new SourceService(this.store, this.inventoryService);
21
- planner = new DeploymentPlanner(createChannelAdapters());
22
- applier = new DeploymentApplier();
23
- doctorService = new DoctorService();
24
- workflowService = new WorkflowService();
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
+ }
25
49
  async addSource(locator, options) {
26
- const result = await this.sourceService.addSource(locator, options);
50
+ return this.runSerializedMutation(() => this.addSourceImpl(locator, options));
51
+ }
52
+ async addSourceImpl(locator, options) {
53
+ const addOptions = options ?? {};
54
+ const result = await this.sourceService.addSource(locator, addOptions);
27
55
  if (!result.ok) {
28
56
  return result;
29
57
  }
30
- await this.store.init();
31
- const manifest = await this.store.readManifest();
32
- const lockFile = await this.store.readLock();
58
+ const { manifest, lockFile } = await this.store.readState();
33
59
  const source = manifest.sources.find((item) => item.id === result.data.manifest.id);
34
60
  if (!source) {
35
61
  return result;
36
62
  }
63
+ source.selectionMode =
64
+ addOptions.selectionMode ??
65
+ (source.requestedPath ? "partial" : "all");
66
+ const enabledTargets = addOptions.enabledTargets ??
67
+ await this.getAvailableTargets();
37
68
  manifest.bindings[source.id] = this.bindingFromDraft({
38
- enabledTargets: await this.getAvailableTargets(),
69
+ enabledTargets,
39
70
  selectedLeafIds: this.selectLeafIdsForRequestedPath(lockFile.leafInventory.filter((leaf) => leaf.sourceId === source.id), source.requestedPath),
40
71
  });
41
- await this.store.writeManifest(manifest);
42
- return result;
72
+ await this.store.writeState(manifest, lockFile);
73
+ if (addOptions.project === false) {
74
+ return result;
75
+ }
76
+ const plan = await this.planForAffectedSources(manifest, lockFile, source.id);
77
+ if (!plan.ok) {
78
+ return fail(plan.errors, [...result.warnings, ...plan.warnings]);
79
+ }
80
+ const applied = await this.applier.applyPlan(lockFile, plan.data.actions);
81
+ await this.store.writeState(manifest, lockFile);
82
+ if (!applied.ok) {
83
+ return fail(applied.errors, [...result.warnings, ...plan.warnings, ...applied.warnings]);
84
+ }
85
+ return ok(result.data, [...result.warnings, ...plan.warnings, ...applied.warnings]);
43
86
  }
44
87
  async findSkills(query) {
45
- await this.store.init();
46
- const manifest = await this.store.readManifest();
47
- const lockFile = await this.store.readLock();
48
- const normalizedQuery = query.trim().toLowerCase();
88
+ const { manifest, lockFile } = await this.store.readState();
89
+ const normalizedQuery = this.normalizeSearchQuery(query);
49
90
  const warnings = [];
50
91
  const localKeys = new Set();
51
92
  const candidates = [];
@@ -90,7 +131,7 @@ export class SkillFlowApp {
90
131
  }
91
132
  }
92
133
  try {
93
- const results = await searchClawHubSkills(query, 8);
134
+ const results = await searchClawHubSkills(normalizedQuery, 8);
94
135
  remoteSearchSucceeded = true;
95
136
  for (const result of results) {
96
137
  candidates.push({
@@ -126,35 +167,63 @@ export class SkillFlowApp {
126
167
  return ok({ candidates }, warnings);
127
168
  }
128
169
  async listWorkflows() {
170
+ return this.runSerializedMutation(() => this.listWorkflowsImpl());
171
+ }
172
+ async listWorkflowsImpl() {
173
+ const pruned = await this.pruneMissingCheckoutsImpl();
174
+ if (!pruned.ok) {
175
+ return fail(pruned.errors, pruned.warnings);
176
+ }
129
177
  const reconciled = await this.sourceService.reconcileInventory(undefined, {
130
178
  force: true,
131
179
  });
132
180
  if (!reconciled.ok) {
133
181
  return fail(reconciled.errors, reconciled.warnings);
134
182
  }
135
- await this.store.init();
136
- const manifest = await this.store.readManifest();
137
- const lockFile = await this.store.readLock();
183
+ const { manifest, lockFile } = await this.store.readState();
138
184
  await this.persistNormalizedBindings(manifest, lockFile);
139
185
  return ok({
140
186
  summaries: this.workflowService.getSummaries(manifest, lockFile),
141
- });
187
+ }, pruned.warnings);
142
188
  }
143
189
  async getConfigData() {
190
+ return this.runSerializedMutation(() => this.getConfigDataImpl());
191
+ }
192
+ async getConfigDataImpl() {
193
+ const pruned = await this.pruneMissingCheckoutsImpl();
194
+ if (!pruned.ok) {
195
+ return fail(pruned.errors, pruned.warnings);
196
+ }
144
197
  const reconciled = await this.sourceService.reconcileInventory(undefined, {
145
198
  force: true,
146
199
  });
147
200
  if (!reconciled.ok) {
148
201
  return fail(reconciled.errors, reconciled.warnings);
149
202
  }
150
- await this.store.init();
151
- const manifest = await this.store.readManifest();
152
- const lockFile = await this.store.readLock();
203
+ const { manifest, lockFile } = await this.store.readState();
153
204
  await this.persistNormalizedBindings(manifest, lockFile);
154
205
  return ok({
155
206
  manifest,
156
207
  lockFile,
157
208
  summaries: this.workflowService.getSummaries(manifest, lockFile),
209
+ }, pruned.warnings);
210
+ }
211
+ async bootstrapWorkspaceState(onEvent) {
212
+ return this.runSerializedMutation(() => this.bootstrapWorkspaceStateImpl(onEvent));
213
+ }
214
+ async bootstrapWorkspaceStateImpl(onEvent) {
215
+ const boot = await this.configCoordinator.bootstrapWorkspaceState(onEvent);
216
+ if (!boot.ok) {
217
+ return fail(boot.errors, boot.warnings);
218
+ }
219
+ return ok({
220
+ availableTargets: boot.data.availableTargets,
221
+ manifest: boot.data.manifest,
222
+ lockFile: boot.data.lockFile,
223
+ summaries: boot.data.summaries,
224
+ initialDrafts: boot.data.initialDrafts,
225
+ audit: boot.data.audit,
226
+ importedSourceIds: [],
158
227
  });
159
228
  }
160
229
  async getAvailableTargets() {
@@ -172,9 +241,7 @@ export class SkillFlowApp {
172
241
  // config TUI state flow:
173
242
  // draft -> previewDraft() -> plan only
174
243
  // draft -> applyDraft() -> plan + filesystem + manifest/lock writes
175
- await this.store.init();
176
- const manifest = await this.store.readManifest();
177
- const lockFile = await this.store.readLock();
244
+ const { manifest, lockFile } = await this.store.readState();
178
245
  this.normalizeBindings(manifest, lockFile);
179
246
  const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
180
247
  const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
@@ -184,15 +251,10 @@ export class SkillFlowApp {
184
251
  return ok({ plan: plan.data, manifest: prepared.manifest, lockFile }, [...prepared.warnings, ...plan.warnings]);
185
252
  }
186
253
  async applyDraft(sourceId, draft) {
187
- const reconciled = await this.sourceService.reconcileInventory([sourceId], {
188
- force: true,
189
- });
190
- if (!reconciled.ok) {
191
- return fail(reconciled.errors, reconciled.warnings);
192
- }
193
- await this.store.init();
194
- const manifest = await this.store.readManifest();
195
- const lockFile = await this.store.readLock();
254
+ return this.runSerializedMutation(() => this.applyDraftImpl(sourceId, draft));
255
+ }
256
+ async applyDraftImpl(sourceId, draft) {
257
+ const { manifest, lockFile } = await this.store.readState();
196
258
  this.normalizeBindings(manifest, lockFile);
197
259
  const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
198
260
  const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
@@ -200,55 +262,143 @@ export class SkillFlowApp {
200
262
  return fail(plan.errors, [...prepared.warnings, ...plan.warnings]);
201
263
  }
202
264
  const applyResult = await this.applier.applyPlan(lockFile, plan.data.actions);
203
- await this.store.writeManifest(prepared.manifest);
204
- await this.store.writeLock(lockFile);
265
+ await this.store.writeState(prepared.manifest, lockFile);
205
266
  if (!applyResult.ok) {
206
267
  return fail(applyResult.errors, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
207
268
  }
208
269
  return ok({ actions: plan.data.actions, draft: prepared.draft }, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
209
270
  }
210
271
  async updateSources(sourceIds) {
211
- const reconciled = await this.sourceService.reconcileInventory(sourceIds, {
212
- force: true,
213
- });
214
- if (!reconciled.ok) {
215
- return fail(reconciled.errors, reconciled.warnings);
272
+ return this.runSerializedMutation(() => this.updateSourcesImpl(sourceIds));
273
+ }
274
+ async updateSourcesImpl(sourceIds) {
275
+ const pruned = await this.pruneMissingCheckoutsImpl();
276
+ if (!pruned.ok) {
277
+ return fail(pruned.errors, pruned.warnings);
216
278
  }
217
- const updated = await this.sourceService.updateSources(sourceIds);
279
+ const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
280
+ if (sourceIds?.length && requestedIds?.length === 0) {
281
+ return ok({ updated: [] }, pruned.warnings);
282
+ }
283
+ const updated = await this.sourceService.updateSources(requestedIds);
218
284
  if (!updated.ok) {
219
285
  return updated;
220
286
  }
221
- const manifest = await this.store.readManifest();
222
- const lockFile = await this.store.readLock();
287
+ const { manifest, lockFile } = await this.store.readState();
288
+ this.applySourceUpdateResults(manifest, lockFile, updated.data.updated);
223
289
  await this.persistNormalizedBindings(manifest, lockFile);
224
- const activeSourceIds = manifest.sources
290
+ const planSourceIds = manifest.sources
225
291
  .map((source) => source.id)
226
- .filter((id) => this.hasActiveTargets(manifest, id));
227
- for (const sourceId of activeSourceIds) {
228
- const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
229
- if (!plan.ok) {
230
- return fail(plan.errors, plan.warnings);
231
- }
232
- await this.applier.applyPlan(lockFile, plan.data.actions);
292
+ .filter((id) => updated.data.updated.some((item) => item.sourceId === id) ||
293
+ this.hasActiveTargets(manifest, id) ||
294
+ lockFile.deployments.some((deployment) => deployment.sourceId === id));
295
+ const planned = await this.planAndApplySources(manifest, lockFile, planSourceIds);
296
+ if (!planned.ok) {
297
+ return fail(planned.errors, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
233
298
  }
234
- await this.store.writeLock(lockFile);
235
- return updated;
299
+ await this.store.writeState(manifest, lockFile);
300
+ return ok(updated.data, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
236
301
  }
237
302
  async doctor() {
303
+ return this.runSerializedMutation(() => this.doctorImpl());
304
+ }
305
+ async doctorImpl() {
306
+ const pruned = await this.pruneMissingCheckoutsImpl();
307
+ if (!pruned.ok) {
308
+ return fail(pruned.errors, pruned.warnings);
309
+ }
238
310
  const reconciled = await this.sourceService.reconcileInventory();
239
311
  if (!reconciled.ok) {
240
312
  return fail(reconciled.errors, reconciled.warnings);
241
313
  }
242
- await this.store.init();
243
- const manifest = await this.store.readManifest();
244
- const lockFile = await this.store.readLock();
314
+ const { manifest, lockFile } = await this.store.readState();
245
315
  await this.persistNormalizedBindings(manifest, lockFile);
246
- return this.doctorService.run(manifest, lockFile);
316
+ const doctor = await this.doctorService.run(manifest, lockFile);
317
+ if (!doctor.ok) {
318
+ return doctor;
319
+ }
320
+ return ok(doctor.data, [...pruned.warnings, ...doctor.warnings]);
321
+ }
322
+ async repairTargets(sourceIds) {
323
+ return this.runSerializedMutation(() => this.repairTargetsImpl(sourceIds));
324
+ }
325
+ async repairTargetsImpl(sourceIds) {
326
+ const { manifest, lockFile } = await this.store.readState();
327
+ this.normalizeBindings(manifest, lockFile);
328
+ const requestedIds = sourceIds?.length
329
+ ? sourceIds
330
+ : manifest.sources.map((source) => source.id);
331
+ for (const sourceId of requestedIds) {
332
+ if (!manifest.sources.some((source) => source.id === sourceId)) {
333
+ return fail({
334
+ code: "SOURCE_NOT_FOUND",
335
+ message: `Skills group id '${sourceId}' is not registered.`,
336
+ });
337
+ }
338
+ }
339
+ const planSourceIds = requestedIds.filter((sourceId) => this.hasActiveTargets(manifest, sourceId) ||
340
+ lockFile.deployments.some((deployment) => deployment.sourceId === sourceId));
341
+ const planned = await this.planAndApplySources(manifest, lockFile, planSourceIds);
342
+ if (!planned.ok) {
343
+ return fail(planned.errors, planned.warnings);
344
+ }
345
+ await this.store.writeState(manifest, lockFile);
346
+ return ok({ actions: planned.data.actions }, planned.warnings);
347
+ }
348
+ async repairSource(sourceIds) {
349
+ return this.runSerializedMutation(() => this.repairSourceImpl(sourceIds));
350
+ }
351
+ async repairSourceImpl(sourceIds) {
352
+ const pruned = await this.pruneMissingCheckoutsImpl();
353
+ if (!pruned.ok) {
354
+ return fail(pruned.errors, pruned.warnings);
355
+ }
356
+ const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
357
+ if (sourceIds?.length && requestedIds?.length === 0) {
358
+ return ok({ updated: [] }, pruned.warnings);
359
+ }
360
+ const repaired = await this.sourceService.updateSources(requestedIds);
361
+ if (!repaired.ok) {
362
+ return repaired;
363
+ }
364
+ const { manifest, lockFile } = await this.store.readState();
365
+ this.applySourceUpdateResults(manifest, lockFile, repaired.data.updated);
366
+ await this.persistNormalizedBindings(manifest, lockFile);
367
+ await this.store.writeState(manifest, lockFile);
368
+ return ok(repaired.data, [...pruned.warnings, ...repaired.warnings]);
369
+ }
370
+ async repairState(sourceIds) {
371
+ return this.runSerializedMutation(() => this.repairStateImpl(sourceIds));
372
+ }
373
+ async repairStateImpl(sourceIds) {
374
+ const pruned = await this.pruneMissingCheckoutsImpl();
375
+ if (!pruned.ok) {
376
+ return fail(pruned.errors, pruned.warnings);
377
+ }
378
+ const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
379
+ if (sourceIds?.length && requestedIds?.length === 0) {
380
+ return ok({ repairedSourceIds: [], removedDeploymentCount: 0 }, pruned.warnings);
381
+ }
382
+ const reconciled = await this.sourceService.reconcileInventory(requestedIds, {
383
+ force: true,
384
+ });
385
+ if (!reconciled.ok) {
386
+ return fail(reconciled.errors, [...pruned.warnings, ...reconciled.warnings]);
387
+ }
388
+ const { manifest, lockFile } = await this.store.readState();
389
+ await this.persistNormalizedBindings(manifest, lockFile);
390
+ const removedDeploymentCount = await this.rebuildDeploymentState(manifest, lockFile, requestedIds);
391
+ await this.store.writeState(manifest, lockFile);
392
+ return ok({
393
+ repairedSourceIds: reconciled.data.updatedSourceIds,
394
+ removedDeploymentCount,
395
+ }, [...pruned.warnings, ...reconciled.warnings]);
247
396
  }
248
397
  async uninstall(sourceIds) {
249
- await this.store.init();
250
- const manifest = await this.store.readManifest();
251
- const lockFile = await this.store.readLock();
398
+ return this.runSerializedMutation(() => this.uninstallImpl(sourceIds));
399
+ }
400
+ async uninstallImpl(sourceIds) {
401
+ const { manifest, lockFile } = await this.store.readState();
252
402
  const warnings = [];
253
403
  const removedRefs = sourceIds
254
404
  .map((sourceId) => manifest.sources.find((source) => source.id === sourceId))
@@ -259,26 +409,33 @@ export class SkillFlowApp {
259
409
  if (!(await pathExists(deployment.targetPath))) {
260
410
  continue;
261
411
  }
262
- if (deployment.strategy === "symlink") {
263
- const stats = await fs.lstat(deployment.targetPath);
264
- if (stats.isSymbolicLink()) {
265
- await removePath(deployment.targetPath);
266
- }
267
- else {
268
- warnings.push(`Skipped ${deployment.targetPath} because it no longer looks like a managed symlink.`);
269
- }
270
- continue;
271
- }
272
- const currentHash = await hashDirectory(deployment.targetPath);
273
- if (currentHash === deployment.contentHash) {
412
+ try {
274
413
  await removePath(deployment.targetPath);
275
414
  }
276
- else {
277
- warnings.push(`Skipped ${deployment.targetPath} because the copied skill has drifted from saved state.`);
415
+ catch (error) {
416
+ warnings.push(`Unable to remove ${deployment.targetPath}: ${String(error)}`);
278
417
  }
279
418
  }
280
419
  }
281
- const removed = await this.sourceService.removeSource(sourceIds);
420
+ if (warnings.length > 0) {
421
+ return fail({
422
+ code: "GROUP_DELETE_INCOMPLETE",
423
+ message: `Unable to fully delete ${warnings.length} managed path${warnings.length === 1 ? "" : "s"}.`,
424
+ }, warnings.map((message) => ({
425
+ code: "GROUP_DELETE_PATH_FAILED",
426
+ message,
427
+ })));
428
+ }
429
+ let removed;
430
+ try {
431
+ removed = await this.sourceService.removeSource(sourceIds);
432
+ }
433
+ catch (error) {
434
+ return fail({
435
+ code: "GROUP_DELETE_INCOMPLETE",
436
+ message: `Unable to fully delete selected skills groups: ${String(error)}`,
437
+ });
438
+ }
282
439
  if (!removed.ok) {
283
440
  return fail(removed.errors, removed.warnings);
284
441
  }
@@ -294,11 +451,58 @@ export class SkillFlowApp {
294
451
  }
295
452
  return { targets };
296
453
  }
454
+ async pruneMissingCheckoutsImpl() {
455
+ const { manifest, lockFile } = await this.store.readState();
456
+ const removedSourceIds = [];
457
+ const warnings = [];
458
+ for (const source of lockFile.sources) {
459
+ if (await pathExists(source.checkoutPath)) {
460
+ continue;
461
+ }
462
+ removedSourceIds.push(source.id);
463
+ warnings.push({
464
+ code: "SOURCE_CHECKOUT_MISSING",
465
+ message: `Removed ${source.displayName} because checkout is missing at ${source.checkoutPath}.`,
466
+ });
467
+ const deployments = lockFile.deployments.filter((deployment) => deployment.sourceId === source.id);
468
+ for (const deployment of deployments) {
469
+ if (!(await pathExists(deployment.targetPath))) {
470
+ continue;
471
+ }
472
+ try {
473
+ await removePath(deployment.targetPath);
474
+ }
475
+ catch (error) {
476
+ return fail({
477
+ code: "SOURCE_CHECKOUT_PRUNE_FAILED",
478
+ message: `Unable to clean deployment ${deployment.targetPath}: ${String(error)}`,
479
+ }, warnings);
480
+ }
481
+ }
482
+ }
483
+ if (removedSourceIds.length === 0) {
484
+ return ok({ removedSourceIds: [] });
485
+ }
486
+ manifest.sources = manifest.sources.filter((source) => !removedSourceIds.includes(source.id));
487
+ for (const sourceId of removedSourceIds) {
488
+ delete manifest.bindings[sourceId];
489
+ }
490
+ lockFile.sources = lockFile.sources.filter((source) => !removedSourceIds.includes(source.id));
491
+ lockFile.leafInventory = lockFile.leafInventory.filter((leaf) => !removedSourceIds.includes(leaf.sourceId));
492
+ lockFile.deployments = lockFile.deployments.filter((deployment) => !removedSourceIds.includes(deployment.sourceId));
493
+ await this.store.writeState(manifest, lockFile);
494
+ return ok({ removedSourceIds }, warnings);
495
+ }
297
496
  async persistNormalizedBindings(manifest, lockFile) {
298
497
  if (!this.normalizeBindings(manifest, lockFile)) {
299
498
  return;
300
499
  }
301
- await this.store.writeManifest(manifest);
500
+ await this.store.writeState(manifest, lockFile);
501
+ }
502
+ async runSerializedMutation(task) {
503
+ const run = this.mutationQueue.then(() => this.store.withMutationLock(task), () => this.store.withMutationLock(task));
504
+ this.mutationQueue = run.then(() => undefined, () => undefined);
505
+ return run;
302
506
  }
303
507
  normalizeBindings(manifest, lockFile) {
304
508
  let changed = false;
@@ -339,6 +543,14 @@ export class SkillFlowApp {
339
543
  }
340
544
  prepareManifestForDraft(manifest, lockFile, sourceId, draft) {
341
545
  manifest.bindings[sourceId] = this.bindingFromDraft(draft);
546
+ const source = manifest.sources.find((item) => item.id === sourceId);
547
+ if (source) {
548
+ const sourceLeafCount = lockFile.leafInventory.filter((leaf) => leaf.sourceId === sourceId).length;
549
+ source.selectionMode =
550
+ draft.selectedLeafIds.length >= sourceLeafCount && sourceLeafCount > 0
551
+ ? "all"
552
+ : "partial";
553
+ }
342
554
  const conflictingLeafIds = this.findExactDuplicateLeafSelections(manifest, lockFile, sourceId, draft.enabledTargets);
343
555
  const normalizedDraft = {
344
556
  enabledTargets: [...draft.enabledTargets],
@@ -391,9 +603,13 @@ export class SkillFlowApp {
391
603
  const sourceIds = manifest.sources
392
604
  .map((source) => source.id)
393
605
  .filter((sourceId) => sourceId === primarySourceId || this.hasActiveTargets(manifest, sourceId));
606
+ return this.planForSources(manifest, lockFile, sourceIds);
607
+ }
608
+ async planForSources(manifest, lockFile, sourceIds) {
609
+ const uniqueSourceIds = [...new Set(sourceIds)];
394
610
  const actions = [];
395
611
  const warnings = [];
396
- for (const sourceId of sourceIds) {
612
+ for (const sourceId of uniqueSourceIds) {
397
613
  const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
398
614
  if (!plan.ok) {
399
615
  return fail(plan.errors, [...warnings, ...plan.warnings]);
@@ -407,6 +623,148 @@ export class SkillFlowApp {
407
623
  blocked: actions.filter((action) => action.kind === "blocked"),
408
624
  }, warnings);
409
625
  }
626
+ async planAndApplySources(manifest, lockFile, sourceIds) {
627
+ const planned = await this.planForSources(manifest, lockFile, sourceIds);
628
+ if (!planned.ok) {
629
+ return fail(planned.errors, planned.warnings);
630
+ }
631
+ const applyResult = await this.applier.applyPlan(lockFile, planned.data.actions);
632
+ if (!applyResult.ok) {
633
+ return fail(applyResult.errors, [...planned.warnings, ...applyResult.warnings]);
634
+ }
635
+ return ok({ actions: planned.data.actions }, [...planned.warnings, ...applyResult.warnings]);
636
+ }
637
+ async rebuildDeploymentState(manifest, lockFile, sourceIds) {
638
+ const requested = sourceIds?.length ? new Set(sourceIds) : undefined;
639
+ const previousDeployments = lockFile.deployments;
640
+ const previousCount = previousDeployments.length;
641
+ const previousByKey = new Map(previousDeployments.map((deployment) => [
642
+ this.getDeploymentKey(deployment.sourceId, deployment.leafId, deployment.target),
643
+ deployment,
644
+ ]));
645
+ const nextDeployments = previousDeployments.filter((deployment) => requested ? !requested.has(deployment.sourceId) : false);
646
+ const adapters = createChannelAdapters();
647
+ const detectionCache = new Map();
648
+ const projectedNameCache = new Map();
649
+ for (const source of manifest.sources) {
650
+ if (requested && !requested.has(source.id)) {
651
+ continue;
652
+ }
653
+ const binding = manifest.bindings[source.id] ?? { targets: {} };
654
+ for (const adapter of adapters) {
655
+ const targetBinding = binding.targets[adapter.target];
656
+ if (!targetBinding?.enabled) {
657
+ continue;
658
+ }
659
+ let detection = detectionCache.get(adapter.target);
660
+ if (!detection) {
661
+ detection = await adapter.detect();
662
+ detectionCache.set(adapter.target, detection);
663
+ }
664
+ for (const leafId of targetBinding.leafIds) {
665
+ const leaf = lockFile.leafInventory.find((candidate) => candidate.sourceId === source.id && candidate.id === leafId);
666
+ if (!leaf) {
667
+ continue;
668
+ }
669
+ const existing = previousByKey.get(this.getDeploymentKey(source.id, leaf.id, adapter.target));
670
+ if (!detection.available) {
671
+ if (existing) {
672
+ nextDeployments.push({
673
+ ...existing,
674
+ contentHash: leaf.contentHash,
675
+ status: "active",
676
+ });
677
+ }
678
+ continue;
679
+ }
680
+ let projectedLinkNames = projectedNameCache.get(adapter.target);
681
+ if (!projectedLinkNames) {
682
+ projectedLinkNames = this.buildProjectedLinkNameMap(manifest, lockFile, adapter.target);
683
+ projectedNameCache.set(adapter.target, projectedLinkNames);
684
+ }
685
+ const rebuilt = await this.findManagedDeploymentOnDisk(source, leaf, adapter.target, adapter.strategy, detection.rootPath, projectedLinkNames, existing);
686
+ if (!rebuilt) {
687
+ continue;
688
+ }
689
+ nextDeployments.push(rebuilt);
690
+ }
691
+ }
692
+ }
693
+ lockFile.deployments = nextDeployments;
694
+ return Math.max(0, previousCount - nextDeployments.length);
695
+ }
696
+ buildProjectedLinkNameMap(manifest, lockFile, target) {
697
+ return resolveProjectedSkillNames(manifest.sources.flatMap((source) => {
698
+ const targetBinding = manifest.bindings[source.id]?.targets[target];
699
+ if (!targetBinding?.enabled) {
700
+ return [];
701
+ }
702
+ return targetBinding.leafIds
703
+ .map((leafId) => lockFile.leafInventory.find((leaf) => leaf.id === leafId))
704
+ .filter((leaf) => Boolean(leaf))
705
+ .map((leaf) => ({
706
+ leafId: leaf.id,
707
+ groupId: source.id,
708
+ groupName: source.displayName,
709
+ groupAuthor: parseGitHubRepo(source.locator)?.owner,
710
+ skillName: leaf.linkName,
711
+ }));
712
+ }));
713
+ }
714
+ async findManagedDeploymentOnDisk(source, leaf, target, strategy, rootPath, projectedLinkNames, existing) {
715
+ const projectedLinkName = projectedLinkNames.get(leaf.id) ?? leaf.linkName;
716
+ const candidatePaths = buildProjectedSkillNameCandidates({
717
+ preferredName: projectedLinkName,
718
+ groupId: source.id,
719
+ groupName: source.displayName,
720
+ groupAuthor: parseGitHubRepo(source.locator)?.owner,
721
+ skillName: leaf.linkName,
722
+ }).map((name) => path.join(rootPath, name));
723
+ if (existing?.targetPath && !candidatePaths.includes(existing.targetPath)) {
724
+ candidatePaths.unshift(existing.targetPath);
725
+ }
726
+ for (const targetPath of candidatePaths) {
727
+ const matches = await this.matchesManagedProjection(strategy, targetPath, leaf);
728
+ if (!matches) {
729
+ continue;
730
+ }
731
+ return {
732
+ sourceId: source.id,
733
+ leafId: leaf.id,
734
+ target,
735
+ targetPath,
736
+ strategy,
737
+ status: "active",
738
+ contentHash: leaf.contentHash,
739
+ appliedAt: existing?.appliedAt ?? new Date().toISOString(),
740
+ };
741
+ }
742
+ return undefined;
743
+ }
744
+ async matchesManagedProjection(strategy, targetPath, leaf) {
745
+ try {
746
+ const stats = await fs.lstat(targetPath);
747
+ if (strategy === "symlink") {
748
+ if (!stats.isSymbolicLink()) {
749
+ return false;
750
+ }
751
+ const linked = await fs.readlink(targetPath);
752
+ const resolved = path.resolve(path.dirname(targetPath), linked);
753
+ return resolved === leaf.absolutePath;
754
+ }
755
+ if (!stats.isDirectory()) {
756
+ return false;
757
+ }
758
+ const onDiskHash = await hashDirectory(targetPath);
759
+ return onDiskHash === leaf.contentHash;
760
+ }
761
+ catch {
762
+ return false;
763
+ }
764
+ }
765
+ getDeploymentKey(sourceId, leafId, target) {
766
+ return `${sourceId}\n${leafId}\n${target}`;
767
+ }
410
768
  hasActiveTargets(manifest, sourceId) {
411
769
  const binding = manifest.bindings[sourceId];
412
770
  if (!binding) {
@@ -428,7 +786,7 @@ export class SkillFlowApp {
428
786
  const source = manifest.sources.find((item) => item.id === leaf.sourceId);
429
787
  return {
430
788
  id: `local:${leaf.id}`,
431
- title: leaf.title,
789
+ title: this.getCandidateTitle(leaf),
432
790
  description: leaf.description,
433
791
  source: "local",
434
792
  sourceLabel: source
@@ -547,7 +905,7 @@ export class SkillFlowApp {
547
905
  if (tokens.length === 0) {
548
906
  return true;
549
907
  }
550
- const haystack = fields.join("\n").toLowerCase();
908
+ const haystack = this.normalizeSearchQuery(fields.join("\n"));
551
909
  return tokens.every((token) => haystack.includes(token));
552
910
  }
553
911
  compareCandidates(left, right, query) {
@@ -577,8 +935,8 @@ export class SkillFlowApp {
577
935
  }
578
936
  getQueryScore(candidate, query) {
579
937
  const tokens = query.split(/\s+/).filter(Boolean);
580
- const titleField = candidate.title.toLowerCase();
581
- const pathTail = (candidate.relativePath ?? "").split("/").pop()?.toLowerCase() ?? "";
938
+ const titleField = this.normalizeSearchQuery(candidate.title);
939
+ const pathTail = this.normalizeSearchQuery((candidate.relativePath ?? "").split("/").pop() ?? "");
582
940
  const fields = [
583
941
  titleField,
584
942
  pathTail,
@@ -604,12 +962,67 @@ export class SkillFlowApp {
604
962
  }
605
963
  return candidate.locator;
606
964
  }
965
+ getCandidateTitle(leaf) {
966
+ const title = leaf.title.trim();
967
+ if (title.length === 0 || /^\{[^}]+\}$/.test(title)) {
968
+ return leaf.linkName || leaf.name;
969
+ }
970
+ return title;
971
+ }
972
+ normalizeSearchQuery(value) {
973
+ return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
974
+ }
607
975
  formatSourceLabel(locator, displayName) {
976
+ if (locator.startsWith("clawhub:")) {
977
+ return `${displayName}@clawhub`;
978
+ }
979
+ if (path.isAbsolute(locator)) {
980
+ return `${displayName}@local`;
981
+ }
608
982
  const repo = parseGitHubRepo(locator);
609
983
  if (!repo) {
610
984
  return displayName;
611
985
  }
612
- return `${displayName}(@${repo.owner})`;
986
+ return `${displayName}@${repo.owner}`;
987
+ }
988
+ applySourceUpdateResults(manifest, lockFile, updates) {
989
+ for (const update of updates) {
990
+ if (!update.changed) {
991
+ continue;
992
+ }
993
+ const source = manifest.sources.find((item) => item.id === update.sourceId);
994
+ const binding = manifest.bindings[update.sourceId];
995
+ if (!source || !binding) {
996
+ continue;
997
+ }
998
+ for (const diff of update.diffs) {
999
+ if (diff.kind !== "moved" || !diff.previousLeafId) {
1000
+ continue;
1001
+ }
1002
+ for (const targetBinding of Object.values(binding.targets)) {
1003
+ if (!targetBinding?.enabled || !targetBinding.leafIds.includes(diff.previousLeafId)) {
1004
+ continue;
1005
+ }
1006
+ targetBinding.leafIds = targetBinding.leafIds.map((leafId) => leafId === diff.previousLeafId ? diff.leafId : leafId);
1007
+ }
1008
+ }
1009
+ if ((update.selectionMode ?? source.selectionMode) !== "all") {
1010
+ continue;
1011
+ }
1012
+ const addedLeafIds = update.diffs
1013
+ .filter((diff) => diff.kind === "added")
1014
+ .map((diff) => diff.leafId);
1015
+ if (addedLeafIds.length === 0) {
1016
+ continue;
1017
+ }
1018
+ for (const targetBinding of Object.values(binding.targets)) {
1019
+ if (!targetBinding?.enabled) {
1020
+ continue;
1021
+ }
1022
+ const merged = new Set([...targetBinding.leafIds, ...addedLeafIds]);
1023
+ targetBinding.leafIds = [...merged].filter((leafId) => lockFile.leafInventory.some((leaf) => leaf.id === leafId && leaf.sourceId === update.sourceId));
1024
+ }
1025
+ }
613
1026
  }
614
1027
  }
615
1028
  //# sourceMappingURL=skill-flow.js.map