skill-flow 1.0.4 → 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 (72) hide show
  1. package/README.md +1 -1
  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 +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 +37 -16
  18. package/dist/services/skill-flow.js +446 -186
  19. package/dist/services/skill-flow.js.map +1 -1
  20. package/dist/services/source-service.d.ts +10 -10
  21. package/dist/services/source-service.js +306 -64
  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/clawhub.test.d.ts +1 -0
  29. package/dist/tests/clawhub.test.js +63 -0
  30. package/dist/tests/clawhub.test.js.map +1 -0
  31. package/dist/tests/cli-utils.test.d.ts +1 -0
  32. package/dist/tests/cli-utils.test.js +15 -0
  33. package/dist/tests/cli-utils.test.js.map +1 -0
  34. package/dist/tests/config-coordinator.test.d.ts +1 -0
  35. package/dist/tests/config-coordinator.test.js +172 -0
  36. package/dist/tests/config-coordinator.test.js.map +1 -0
  37. package/dist/tests/config-integration.test.d.ts +1 -0
  38. package/dist/tests/config-integration.test.js +238 -0
  39. package/dist/tests/config-integration.test.js.map +1 -0
  40. package/dist/tests/config-ui-utils.test.d.ts +1 -0
  41. package/dist/tests/config-ui-utils.test.js +389 -0
  42. package/dist/tests/config-ui-utils.test.js.map +1 -0
  43. package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
  44. package/dist/tests/find-and-naming-utils.test.js +127 -0
  45. package/dist/tests/find-and-naming-utils.test.js.map +1 -0
  46. package/dist/tests/skill-flow.test.js +311 -889
  47. package/dist/tests/skill-flow.test.js.map +1 -1
  48. package/dist/tests/source-lifecycle.test.d.ts +1 -0
  49. package/dist/tests/source-lifecycle.test.js +605 -0
  50. package/dist/tests/source-lifecycle.test.js.map +1 -0
  51. package/dist/tests/target-definitions.test.d.ts +1 -0
  52. package/dist/tests/target-definitions.test.js +51 -0
  53. package/dist/tests/target-definitions.test.js.map +1 -0
  54. package/dist/tests/test-helpers.d.ts +18 -0
  55. package/dist/tests/test-helpers.js +123 -0
  56. package/dist/tests/test-helpers.js.map +1 -0
  57. package/dist/tui/config-app.d.ts +144 -24
  58. package/dist/tui/config-app.js +1025 -339
  59. package/dist/tui/config-app.js.map +1 -1
  60. package/dist/utils/clawhub.d.ts +3 -0
  61. package/dist/utils/clawhub.js +32 -3
  62. package/dist/utils/clawhub.js.map +1 -1
  63. package/dist/utils/cli.d.ts +1 -0
  64. package/dist/utils/cli.js +15 -0
  65. package/dist/utils/cli.js.map +1 -0
  66. package/dist/utils/constants.d.ts +4 -0
  67. package/dist/utils/constants.js +31 -0
  68. package/dist/utils/constants.js.map +1 -1
  69. package/dist/utils/fs.d.ts +5 -0
  70. package/dist/utils/fs.js +52 -1
  71. package/dist/utils/fs.js.map +1 -1
  72. 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,23 +18,44 @@ 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;
@@ -47,7 +69,7 @@ export class SkillFlowApp {
47
69
  enabledTargets,
48
70
  selectedLeafIds: this.selectLeafIdsForRequestedPath(lockFile.leafInventory.filter((leaf) => leaf.sourceId === source.id), source.requestedPath),
49
71
  });
50
- await this.store.writeManifest(manifest);
72
+ await this.store.writeState(manifest, lockFile);
51
73
  if (addOptions.project === false) {
52
74
  return result;
53
75
  }
@@ -56,17 +78,15 @@ export class SkillFlowApp {
56
78
  return fail(plan.errors, [...result.warnings, ...plan.warnings]);
57
79
  }
58
80
  const applied = await this.applier.applyPlan(lockFile, plan.data.actions);
59
- await this.store.writeLock(lockFile);
81
+ await this.store.writeState(manifest, lockFile);
60
82
  if (!applied.ok) {
61
83
  return fail(applied.errors, [...result.warnings, ...plan.warnings, ...applied.warnings]);
62
84
  }
63
85
  return ok(result.data, [...result.warnings, ...plan.warnings, ...applied.warnings]);
64
86
  }
65
87
  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();
88
+ const { manifest, lockFile } = await this.store.readState();
89
+ const normalizedQuery = this.normalizeSearchQuery(query);
70
90
  const warnings = [];
71
91
  const localKeys = new Set();
72
92
  const candidates = [];
@@ -111,7 +131,7 @@ export class SkillFlowApp {
111
131
  }
112
132
  }
113
133
  try {
114
- const results = await searchClawHubSkills(query, 8);
134
+ const results = await searchClawHubSkills(normalizedQuery, 8);
115
135
  remoteSearchSucceeded = true;
116
136
  for (const result of results) {
117
137
  candidates.push({
@@ -147,124 +167,63 @@ export class SkillFlowApp {
147
167
  return ok({ candidates }, warnings);
148
168
  }
149
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
+ }
150
177
  const reconciled = await this.sourceService.reconcileInventory(undefined, {
151
178
  force: true,
152
179
  });
153
180
  if (!reconciled.ok) {
154
181
  return fail(reconciled.errors, reconciled.warnings);
155
182
  }
156
- await this.store.init();
157
- const manifest = await this.store.readManifest();
158
- const lockFile = await this.store.readLock();
183
+ const { manifest, lockFile } = await this.store.readState();
159
184
  await this.persistNormalizedBindings(manifest, lockFile);
160
185
  return ok({
161
186
  summaries: this.workflowService.getSummaries(manifest, lockFile),
162
- });
187
+ }, pruned.warnings);
163
188
  }
164
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
+ }
165
197
  const reconciled = await this.sourceService.reconcileInventory(undefined, {
166
198
  force: true,
167
199
  });
168
200
  if (!reconciled.ok) {
169
201
  return fail(reconciled.errors, reconciled.warnings);
170
202
  }
171
- await this.store.init();
172
- const manifest = await this.store.readManifest();
173
- const lockFile = await this.store.readLock();
203
+ const { manifest, lockFile } = await this.store.readState();
174
204
  await this.persistNormalizedBindings(manifest, lockFile);
175
205
  return ok({
176
206
  manifest,
177
207
  lockFile,
178
208
  summaries: this.workflowService.getSummaries(manifest, lockFile),
179
- });
209
+ }, pruned.warnings);
180
210
  }
181
211
  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);
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);
231
218
  }
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
219
  return ok({
261
- availableTargets,
262
- manifest,
263
- lockFile,
264
- summaries,
265
- initialDrafts: this.buildInitialDrafts(summaries),
266
- audit: audit.data,
267
- importedSourceIds,
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: [],
268
227
  });
269
228
  }
270
229
  async getAvailableTargets() {
@@ -282,9 +241,7 @@ export class SkillFlowApp {
282
241
  // config TUI state flow:
283
242
  // draft -> previewDraft() -> plan only
284
243
  // 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();
244
+ const { manifest, lockFile } = await this.store.readState();
288
245
  this.normalizeBindings(manifest, lockFile);
289
246
  const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
290
247
  const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
@@ -294,15 +251,10 @@ export class SkillFlowApp {
294
251
  return ok({ plan: plan.data, manifest: prepared.manifest, lockFile }, [...prepared.warnings, ...plan.warnings]);
295
252
  }
296
253
  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();
254
+ return this.runSerializedMutation(() => this.applyDraftImpl(sourceId, draft));
255
+ }
256
+ async applyDraftImpl(sourceId, draft) {
257
+ const { manifest, lockFile } = await this.store.readState();
306
258
  this.normalizeBindings(manifest, lockFile);
307
259
  const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
308
260
  const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
@@ -310,56 +262,143 @@ export class SkillFlowApp {
310
262
  return fail(plan.errors, [...prepared.warnings, ...plan.warnings]);
311
263
  }
312
264
  const applyResult = await this.applier.applyPlan(lockFile, plan.data.actions);
313
- await this.store.writeManifest(prepared.manifest);
314
- await this.store.writeLock(lockFile);
265
+ await this.store.writeState(prepared.manifest, lockFile);
315
266
  if (!applyResult.ok) {
316
267
  return fail(applyResult.errors, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
317
268
  }
318
269
  return ok({ actions: plan.data.actions, draft: prepared.draft }, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
319
270
  }
320
271
  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);
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);
326
278
  }
327
- 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);
328
284
  if (!updated.ok) {
329
285
  return updated;
330
286
  }
331
- const manifest = await this.store.readManifest();
332
- const lockFile = await this.store.readLock();
333
- this.applySelectionModeForUpdatedSources(manifest, lockFile, updated.data.updated);
287
+ const { manifest, lockFile } = await this.store.readState();
288
+ this.applySourceUpdateResults(manifest, lockFile, updated.data.updated);
334
289
  await this.persistNormalizedBindings(manifest, lockFile);
335
- const activeSourceIds = manifest.sources
290
+ const planSourceIds = manifest.sources
336
291
  .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);
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]);
344
298
  }
345
- await this.store.writeLock(lockFile);
346
- return updated;
299
+ await this.store.writeState(manifest, lockFile);
300
+ return ok(updated.data, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
347
301
  }
348
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
+ }
349
310
  const reconciled = await this.sourceService.reconcileInventory();
350
311
  if (!reconciled.ok) {
351
312
  return fail(reconciled.errors, reconciled.warnings);
352
313
  }
353
- await this.store.init();
354
- const manifest = await this.store.readManifest();
355
- const lockFile = await this.store.readLock();
314
+ const { manifest, lockFile } = await this.store.readState();
315
+ await this.persistNormalizedBindings(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();
356
389
  await this.persistNormalizedBindings(manifest, lockFile);
357
- return this.doctorService.run(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]);
358
396
  }
359
397
  async uninstall(sourceIds) {
360
- await this.store.init();
361
- const manifest = await this.store.readManifest();
362
- 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();
363
402
  const warnings = [];
364
403
  const removedRefs = sourceIds
365
404
  .map((sourceId) => manifest.sources.find((source) => source.id === sourceId))
@@ -370,26 +409,33 @@ export class SkillFlowApp {
370
409
  if (!(await pathExists(deployment.targetPath))) {
371
410
  continue;
372
411
  }
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) {
412
+ try {
385
413
  await removePath(deployment.targetPath);
386
414
  }
387
- else {
388
- 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)}`);
389
417
  }
390
418
  }
391
419
  }
392
- 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
+ }
393
439
  if (!removed.ok) {
394
440
  return fail(removed.errors, removed.warnings);
395
441
  }
@@ -405,11 +451,58 @@ export class SkillFlowApp {
405
451
  }
406
452
  return { targets };
407
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
+ }
408
496
  async persistNormalizedBindings(manifest, lockFile) {
409
497
  if (!this.normalizeBindings(manifest, lockFile)) {
410
498
  return;
411
499
  }
412
- 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;
413
506
  }
414
507
  normalizeBindings(manifest, lockFile) {
415
508
  let changed = false;
@@ -510,9 +603,13 @@ export class SkillFlowApp {
510
603
  const sourceIds = manifest.sources
511
604
  .map((source) => source.id)
512
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)];
513
610
  const actions = [];
514
611
  const warnings = [];
515
- for (const sourceId of sourceIds) {
612
+ for (const sourceId of uniqueSourceIds) {
516
613
  const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
517
614
  if (!plan.ok) {
518
615
  return fail(plan.errors, [...warnings, ...plan.warnings]);
@@ -526,6 +623,148 @@ export class SkillFlowApp {
526
623
  blocked: actions.filter((action) => action.kind === "blocked"),
527
624
  }, warnings);
528
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
+ }
529
768
  hasActiveTargets(manifest, sourceId) {
530
769
  const binding = manifest.bindings[sourceId];
531
770
  if (!binding) {
@@ -547,7 +786,7 @@ export class SkillFlowApp {
547
786
  const source = manifest.sources.find((item) => item.id === leaf.sourceId);
548
787
  return {
549
788
  id: `local:${leaf.id}`,
550
- title: leaf.title,
789
+ title: this.getCandidateTitle(leaf),
551
790
  description: leaf.description,
552
791
  source: "local",
553
792
  sourceLabel: source
@@ -666,7 +905,7 @@ export class SkillFlowApp {
666
905
  if (tokens.length === 0) {
667
906
  return true;
668
907
  }
669
- const haystack = fields.join("\n").toLowerCase();
908
+ const haystack = this.normalizeSearchQuery(fields.join("\n"));
670
909
  return tokens.every((token) => haystack.includes(token));
671
910
  }
672
911
  compareCandidates(left, right, query) {
@@ -696,8 +935,8 @@ export class SkillFlowApp {
696
935
  }
697
936
  getQueryScore(candidate, query) {
698
937
  const tokens = query.split(/\s+/).filter(Boolean);
699
- const titleField = candidate.title.toLowerCase();
700
- const pathTail = (candidate.relativePath ?? "").split("/").pop()?.toLowerCase() ?? "";
938
+ const titleField = this.normalizeSearchQuery(candidate.title);
939
+ const pathTail = this.normalizeSearchQuery((candidate.relativePath ?? "").split("/").pop() ?? "");
701
940
  const fields = [
702
941
  titleField,
703
942
  pathTail,
@@ -723,6 +962,16 @@ export class SkillFlowApp {
723
962
  }
724
963
  return candidate.locator;
725
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
+ }
726
975
  formatSourceLabel(locator, displayName) {
727
976
  if (locator.startsWith("clawhub:")) {
728
977
  return `${displayName}@clawhub`;
@@ -736,30 +985,41 @@ export class SkillFlowApp {
736
985
  }
737
986
  return `${displayName}@${repo.owner}`;
738
987
  }
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) {
988
+ applySourceUpdateResults(manifest, lockFile, updates) {
749
989
  for (const update of updates) {
750
- if (!update.changed || update.addedLeafIds.length === 0) {
990
+ if (!update.changed) {
751
991
  continue;
752
992
  }
753
993
  const source = manifest.sources.find((item) => item.id === update.sourceId);
754
994
  const binding = manifest.bindings[update.sourceId];
755
- if (!source || !binding || source.selectionMode !== "all") {
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) {
756
1016
  continue;
757
1017
  }
758
1018
  for (const targetBinding of Object.values(binding.targets)) {
759
1019
  if (!targetBinding?.enabled) {
760
1020
  continue;
761
1021
  }
762
- const merged = new Set([...targetBinding.leafIds, ...update.addedLeafIds]);
1022
+ const merged = new Set([...targetBinding.leafIds, ...addedLeafIds]);
763
1023
  targetBinding.leafIds = [...merged].filter((leafId) => lockFile.leafInventory.some((leaf) => leaf.id === leafId && leaf.sourceId === update.sourceId));
764
1024
  }
765
1025
  }