skill-flow 1.0.0

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 (87) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +108 -0
  3. package/README.zh.md +108 -0
  4. package/dist/adapters/channel-adapters.d.ts +8 -0
  5. package/dist/adapters/channel-adapters.js +56 -0
  6. package/dist/adapters/channel-adapters.js.map +1 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +118 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/domain/types.d.ts +133 -0
  11. package/dist/domain/types.js +2 -0
  12. package/dist/domain/types.js.map +1 -0
  13. package/dist/services/deployment-applier.d.ts +6 -0
  14. package/dist/services/deployment-applier.js +54 -0
  15. package/dist/services/deployment-applier.js.map +1 -0
  16. package/dist/services/deployment-planner.d.ts +11 -0
  17. package/dist/services/deployment-planner.js +179 -0
  18. package/dist/services/deployment-planner.js.map +1 -0
  19. package/dist/services/doctor-service.d.ts +5 -0
  20. package/dist/services/doctor-service.js +129 -0
  21. package/dist/services/doctor-service.js.map +1 -0
  22. package/dist/services/inventory-service.d.ts +14 -0
  23. package/dist/services/inventory-service.js +186 -0
  24. package/dist/services/inventory-service.js.map +1 -0
  25. package/dist/services/skill-flow.d.ts +60 -0
  26. package/dist/services/skill-flow.js +260 -0
  27. package/dist/services/skill-flow.js.map +1 -0
  28. package/dist/services/source-service.d.ts +35 -0
  29. package/dist/services/source-service.js +270 -0
  30. package/dist/services/source-service.js.map +1 -0
  31. package/dist/services/workflow-service.d.ts +5 -0
  32. package/dist/services/workflow-service.js +32 -0
  33. package/dist/services/workflow-service.js.map +1 -0
  34. package/dist/state/store.d.ts +14 -0
  35. package/dist/state/store.js +59 -0
  36. package/dist/state/store.js.map +1 -0
  37. package/dist/tests/skill-flow.test.d.ts +1 -0
  38. package/dist/tests/skill-flow.test.js +926 -0
  39. package/dist/tests/skill-flow.test.js.map +1 -0
  40. package/dist/tui/config-app.d.ts +47 -0
  41. package/dist/tui/config-app.js +732 -0
  42. package/dist/tui/config-app.js.map +1 -0
  43. package/dist/tui/selection-state.d.ts +8 -0
  44. package/dist/tui/selection-state.js +32 -0
  45. package/dist/tui/selection-state.js.map +1 -0
  46. package/dist/utils/constants.d.ts +19 -0
  47. package/dist/utils/constants.js +164 -0
  48. package/dist/utils/constants.js.map +1 -0
  49. package/dist/utils/format.d.ts +6 -0
  50. package/dist/utils/format.js +45 -0
  51. package/dist/utils/format.js.map +1 -0
  52. package/dist/utils/fs.d.ts +10 -0
  53. package/dist/utils/fs.js +89 -0
  54. package/dist/utils/fs.js.map +1 -0
  55. package/dist/utils/git.d.ts +3 -0
  56. package/dist/utils/git.js +12 -0
  57. package/dist/utils/git.js.map +1 -0
  58. package/dist/utils/result.d.ts +4 -0
  59. package/dist/utils/result.js +15 -0
  60. package/dist/utils/result.js.map +1 -0
  61. package/dist/utils/source-id.d.ts +2 -0
  62. package/dist/utils/source-id.js +16 -0
  63. package/dist/utils/source-id.js.map +1 -0
  64. package/img/img-1.jpg +0 -0
  65. package/package.json +39 -0
  66. package/src/adapters/channel-adapters.ts +75 -0
  67. package/src/cli.tsx +147 -0
  68. package/src/domain/types.ts +175 -0
  69. package/src/services/deployment-applier.ts +81 -0
  70. package/src/services/deployment-planner.ts +259 -0
  71. package/src/services/doctor-service.ts +156 -0
  72. package/src/services/inventory-service.ts +251 -0
  73. package/src/services/skill-flow.ts +381 -0
  74. package/src/services/source-service.ts +427 -0
  75. package/src/services/workflow-service.ts +56 -0
  76. package/src/state/store.ts +68 -0
  77. package/src/tests/skill-flow.test.ts +1184 -0
  78. package/src/tui/config-app.tsx +1094 -0
  79. package/src/tui/selection-state.ts +45 -0
  80. package/src/utils/constants.ts +201 -0
  81. package/src/utils/format.ts +59 -0
  82. package/src/utils/fs.ts +102 -0
  83. package/src/utils/git.ts +16 -0
  84. package/src/utils/result.ts +23 -0
  85. package/src/utils/source-id.ts +19 -0
  86. package/tsconfig.json +22 -0
  87. package/vitest.config.ts +8 -0
@@ -0,0 +1,259 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type {
4
+ DeploymentAction,
5
+ DeploymentPlan,
6
+ DeploymentRecord,
7
+ DeploymentTargetName,
8
+ LeafRecord,
9
+ LockFile,
10
+ Manifest,
11
+ Result,
12
+ Warning,
13
+ } from "../domain/types.js";
14
+ import type { ChannelAdapter } from "../adapters/channel-adapters.js";
15
+ import { fail, ok } from "../utils/result.js";
16
+
17
+ export class DeploymentPlanner {
18
+ constructor(private readonly adapters: ChannelAdapter[]) {}
19
+
20
+ async planForSource(
21
+ sourceId: string,
22
+ manifest: Manifest,
23
+ lockFile: LockFile,
24
+ ): Promise<Result<DeploymentPlan>> {
25
+ const binding = manifest.bindings[sourceId] ?? { targets: {} };
26
+ const leafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === sourceId);
27
+ const previousDeployments = lockFile.deployments.filter(
28
+ (deployment) => deployment.sourceId === sourceId,
29
+ );
30
+ const actions: DeploymentAction[] = [];
31
+ const warnings: Warning[] = [];
32
+
33
+ for (const adapter of this.adapters) {
34
+ const detection = await adapter.detect();
35
+ const targetBinding = binding.targets[adapter.target];
36
+ const desiredLeafIds =
37
+ targetBinding?.enabled === true ? new Set(targetBinding.leafIds) : new Set<string>();
38
+ const projectedLinkNames = this.buildProjectedLinkNameMap(
39
+ manifest,
40
+ lockFile,
41
+ adapter.target,
42
+ );
43
+
44
+ const plannedForTarget = await this.planTarget(
45
+ sourceId,
46
+ adapter,
47
+ detection.available,
48
+ detection.rootPath,
49
+ detection.reason,
50
+ desiredLeafIds,
51
+ leafs,
52
+ previousDeployments,
53
+ projectedLinkNames,
54
+ );
55
+
56
+ actions.push(...plannedForTarget.actions);
57
+ warnings.push(...plannedForTarget.warnings);
58
+ }
59
+
60
+ return ok({
61
+ actions,
62
+ warnings,
63
+ blocked: actions.filter((action) => action.kind === "blocked"),
64
+ });
65
+ }
66
+
67
+ // fetch -> scan -> diff -> replan -> reapply
68
+ //
69
+ // desired bindings + lock state + disk state
70
+ // -> create | update | remove | noop | blocked
71
+ private async planTarget(
72
+ sourceId: string,
73
+ adapter: ChannelAdapter,
74
+ targetAvailable: boolean,
75
+ rootPath: string,
76
+ unavailableReason: string | undefined,
77
+ desiredLeafIds: Set<string>,
78
+ leafs: LeafRecord[],
79
+ previousDeployments: DeploymentRecord[],
80
+ projectedLinkNames: Map<string, string>,
81
+ ): Promise<DeploymentPlan> {
82
+ const actions: DeploymentAction[] = [];
83
+ const warnings: Warning[] = [];
84
+ const desiredLeafs = leafs.filter((leaf) => desiredLeafIds.has(leaf.id));
85
+ const deploymentsForTarget = previousDeployments.filter(
86
+ (deployment) => deployment.target === adapter.target,
87
+ );
88
+ const managedByLeafId = new Map(
89
+ deploymentsForTarget.map((deployment) => [deployment.leafId, deployment]),
90
+ );
91
+ const missingDesiredLeafIds = [...desiredLeafIds].filter(
92
+ (leafId) => !desiredLeafs.some((leaf) => leaf.id === leafId),
93
+ );
94
+
95
+ for (const missingLeafId of missingDesiredLeafIds) {
96
+ const existing = managedByLeafId.get(missingLeafId);
97
+ warnings.push({
98
+ code: "MISSING_LEAF_SELECTION",
99
+ message: `${missingLeafId} no longer exists in source inventory.`,
100
+ });
101
+ if (existing) {
102
+ actions.push({
103
+ kind: "remove",
104
+ sourceId,
105
+ leafId: missingLeafId,
106
+ target: existing.target,
107
+ strategy: existing.strategy,
108
+ sourcePath: "",
109
+ targetPath: existing.targetPath,
110
+ contentHash: existing.contentHash,
111
+ reason: "Selected leaf no longer exists in source inventory.",
112
+ });
113
+ }
114
+ }
115
+
116
+ for (const leaf of desiredLeafs) {
117
+ const existing = managedByLeafId.get(leaf.id);
118
+ const projectedLinkName = projectedLinkNames.get(leaf.id) ?? leaf.linkName;
119
+ const targetPath = adapter.resolveTargetPath(rootPath, projectedLinkName);
120
+
121
+ if (!targetAvailable) {
122
+ const blockedAction: DeploymentAction = {
123
+ kind: "blocked",
124
+ sourceId,
125
+ leafId: leaf.id,
126
+ target: adapter.target,
127
+ strategy: adapter.strategy,
128
+ sourcePath: leaf.absolutePath,
129
+ targetPath,
130
+ contentHash: leaf.contentHash,
131
+ ...(unavailableReason ? { reason: unavailableReason } : {}),
132
+ };
133
+ actions.push(blockedAction);
134
+ continue;
135
+ }
136
+
137
+ const diskState = await this.inspectTargetPath(targetPath, leaf.absolutePath);
138
+ if (diskState.foreign && !existing) {
139
+ actions.push({
140
+ kind: "blocked",
141
+ sourceId,
142
+ leafId: leaf.id,
143
+ target: adapter.target,
144
+ strategy: adapter.strategy,
145
+ sourcePath: leaf.absolutePath,
146
+ targetPath,
147
+ reason: "Foreign content already exists at target path.",
148
+ contentHash: leaf.contentHash,
149
+ });
150
+ continue;
151
+ }
152
+
153
+ const kind = this.resolveDesiredAction(existing, diskState.matchesExpected, leaf);
154
+ actions.push({
155
+ kind,
156
+ sourceId,
157
+ leafId: leaf.id,
158
+ target: adapter.target,
159
+ strategy: adapter.strategy,
160
+ sourcePath: leaf.absolutePath,
161
+ targetPath,
162
+ ...(existing && existing.targetPath !== targetPath
163
+ ? { previousTargetPath: existing.targetPath }
164
+ : {}),
165
+ contentHash: leaf.contentHash,
166
+ });
167
+ }
168
+
169
+ for (const deployment of deploymentsForTarget) {
170
+ if (desiredLeafIds.has(deployment.leafId)) {
171
+ continue;
172
+ }
173
+
174
+ actions.push({
175
+ kind: "remove",
176
+ sourceId,
177
+ leafId: deployment.leafId,
178
+ target: deployment.target,
179
+ strategy: deployment.strategy,
180
+ sourcePath: "",
181
+ targetPath: deployment.targetPath,
182
+ contentHash: deployment.contentHash,
183
+ });
184
+ }
185
+
186
+ return { actions, warnings, blocked: actions.filter((item) => item.kind === "blocked") };
187
+ }
188
+
189
+ private resolveDesiredAction(
190
+ existing: DeploymentRecord | undefined,
191
+ matchesExpected: boolean,
192
+ leaf: LeafRecord,
193
+ ): DeploymentAction["kind"] {
194
+ if (!existing) {
195
+ return matchesExpected ? "noop" : "create";
196
+ }
197
+
198
+ if (!matchesExpected) {
199
+ return "update";
200
+ }
201
+
202
+ return existing.contentHash === leaf.contentHash ? "noop" : "update";
203
+ }
204
+
205
+ private async inspectTargetPath(
206
+ targetPath: string,
207
+ expectedSourcePath: string,
208
+ ): Promise<{ exists: boolean; matchesExpected: boolean; foreign: boolean }> {
209
+ try {
210
+ const stats = await fs.lstat(targetPath);
211
+ if (stats.isSymbolicLink()) {
212
+ const linked = await fs.readlink(targetPath);
213
+ const resolved = path.resolve(path.dirname(targetPath), linked);
214
+ const matchesExpected = resolved === expectedSourcePath;
215
+ return { exists: true, matchesExpected, foreign: !matchesExpected };
216
+ }
217
+ return { exists: true, matchesExpected: false, foreign: true };
218
+ } catch {
219
+ return { exists: false, matchesExpected: false, foreign: false };
220
+ }
221
+ }
222
+
223
+ private buildProjectedLinkNameMap(
224
+ manifest: Manifest,
225
+ lockFile: LockFile,
226
+ target: DeploymentTargetName,
227
+ ): Map<string, string> {
228
+ const selectedLeafs = manifest.sources.flatMap((source) => {
229
+ const targetBinding = manifest.bindings[source.id]?.targets[target];
230
+ if (!targetBinding?.enabled) {
231
+ return [];
232
+ }
233
+
234
+ return targetBinding.leafIds
235
+ .map((leafId) => lockFile.leafInventory.find((leaf) => leaf.id === leafId))
236
+ .filter((leaf): leaf is LeafRecord => Boolean(leaf));
237
+ });
238
+
239
+ const byLinkName = new Map<string, LeafRecord[]>();
240
+ for (const leaf of selectedLeafs) {
241
+ const group = byLinkName.get(leaf.linkName) ?? [];
242
+ group.push(leaf);
243
+ byLinkName.set(leaf.linkName, group);
244
+ }
245
+
246
+ const result = new Map<string, string>();
247
+ for (const leaf of selectedLeafs) {
248
+ const collisions = byLinkName.get(leaf.linkName) ?? [];
249
+ if (collisions.length <= 1) {
250
+ result.set(leaf.id, leaf.linkName);
251
+ continue;
252
+ }
253
+
254
+ result.set(leaf.id, `${leaf.sourceId}-${leaf.linkName}`);
255
+ }
256
+
257
+ return result;
258
+ }
259
+ }
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs/promises";
2
+ import { createChannelAdapters } from "../adapters/channel-adapters.js";
3
+ import type {
4
+ DoctorIssue,
5
+ DoctorReport,
6
+ LockFile,
7
+ Manifest,
8
+ Result,
9
+ } from "../domain/types.js";
10
+ import { hashDirectory, isBrokenSymlink, pathExists } from "../utils/fs.js";
11
+ import { ok } from "../utils/result.js";
12
+
13
+ export class DoctorService {
14
+ private readonly adapters = createChannelAdapters();
15
+
16
+ async run(manifest: Manifest, lockFile: LockFile): Promise<Result<DoctorReport>> {
17
+ const issues: DoctorIssue[] = [];
18
+
19
+ for (const source of manifest.sources) {
20
+ const binding = manifest.bindings[source.id] ?? { targets: {} };
21
+
22
+ for (const adapter of this.adapters) {
23
+ const configured = binding.targets[adapter.target];
24
+ if (!configured?.enabled) {
25
+ continue;
26
+ }
27
+
28
+ const detection = await adapter.detect();
29
+ if (!detection.available) {
30
+ issues.push({
31
+ severity: "error",
32
+ sourceId: source.id,
33
+ target: adapter.target,
34
+ code: "TARGET_UNAVAILABLE",
35
+ message: detection.reason ?? "Target is unavailable.",
36
+ });
37
+ continue;
38
+ }
39
+
40
+ for (const leafId of configured.leafIds) {
41
+ const leaf = lockFile.leafInventory.find((item) => item.id === leafId);
42
+ const deployment = lockFile.deployments.find(
43
+ (item) =>
44
+ item.sourceId === source.id &&
45
+ item.leafId === leafId &&
46
+ item.target === adapter.target,
47
+ );
48
+
49
+ if (!leaf) {
50
+ issues.push({
51
+ severity: "error",
52
+ sourceId: source.id,
53
+ target: adapter.target,
54
+ leafId,
55
+ code: "LEAF_MISSING",
56
+ message: "This saved selection no longer exists in the source inventory.",
57
+ });
58
+ continue;
59
+ }
60
+
61
+ const targetPath = deployment?.targetPath ?? adapter.resolveTargetPath(
62
+ detection.rootPath,
63
+ leaf.linkName,
64
+ );
65
+ if (!deployment) {
66
+ issues.push({
67
+ severity: "warning",
68
+ sourceId: source.id,
69
+ target: adapter.target,
70
+ leafId,
71
+ code: "DRIFT_NOT_DEPLOYED",
72
+ message: "This selected skill is not currently projected to disk.",
73
+ });
74
+ continue;
75
+ }
76
+
77
+ if (!(await pathExists(targetPath))) {
78
+ issues.push({
79
+ severity: "error",
80
+ sourceId: source.id,
81
+ target: adapter.target,
82
+ leafId,
83
+ code: "TARGET_MISSING",
84
+ message: "Projected target is missing on disk.",
85
+ });
86
+ continue;
87
+ }
88
+
89
+ if (deployment.strategy === "symlink") {
90
+ const stats = await fs.lstat(targetPath);
91
+ if (!stats.isSymbolicLink()) {
92
+ issues.push({
93
+ severity: "warning",
94
+ sourceId: source.id,
95
+ target: adapter.target,
96
+ leafId,
97
+ code: "DRIFT_TYPE",
98
+ message: "Expected a symlink, but found foreign content.",
99
+ });
100
+ continue;
101
+ }
102
+
103
+ if (await isBrokenSymlink(targetPath)) {
104
+ issues.push({
105
+ severity: "error",
106
+ sourceId: source.id,
107
+ target: adapter.target,
108
+ leafId,
109
+ code: "BROKEN_SYMLINK",
110
+ message: "Projected symlink is broken.",
111
+ });
112
+ }
113
+ } else {
114
+ const onDiskHash = await hashDirectory(targetPath);
115
+ if (onDiskHash !== deployment.contentHash) {
116
+ issues.push({
117
+ severity: "warning",
118
+ sourceId: source.id,
119
+ target: adapter.target,
120
+ leafId,
121
+ code: "DRIFT_COPY",
122
+ message: "Projected copy no longer matches saved state.",
123
+ });
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ for (const deployment of lockFile.deployments) {
131
+ const sourceStillExists = manifest.sources.some(
132
+ (source) => source.id === deployment.sourceId,
133
+ );
134
+ if (!sourceStillExists) {
135
+ issues.push({
136
+ severity: "warning",
137
+ sourceId: deployment.sourceId,
138
+ target: deployment.target,
139
+ leafId: deployment.leafId,
140
+ code: "STALE_DEPLOYMENT",
141
+ message: "Saved deployment exists for a workflow group that is no longer registered.",
142
+ });
143
+ }
144
+ }
145
+
146
+ const hasError = issues.some((issue) => issue.severity === "error");
147
+ const hasWarning = issues.some((issue) => issue.severity === "warning");
148
+ const status: DoctorReport["status"] = hasError
149
+ ? "BLOCKED"
150
+ : hasWarning
151
+ ? "PARTIAL"
152
+ : "HEALTHY";
153
+
154
+ return ok({ status, issues });
155
+ }
156
+ }
@@ -0,0 +1,251 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { InvalidLeafRecord, LeafRecord } from "../domain/types.js";
4
+ import { hashDirectory, slugify } from "../utils/fs.js";
5
+
6
+ type InventoryScan = {
7
+ leafs: LeafRecord[];
8
+ invalidLeafs: InvalidLeafRecord[];
9
+ };
10
+
11
+ type ParsedSkillFile =
12
+ | {
13
+ valid: true;
14
+ name: string;
15
+ title: string;
16
+ description: string;
17
+ metadataWarnings: string[];
18
+ }
19
+ | { valid: false; reason: string };
20
+
21
+ export class InventoryService {
22
+ private static readonly IGNORED_DIRECTORIES = new Set([
23
+ ".git",
24
+ "node_modules",
25
+ ]);
26
+
27
+ async scanSource(sourceId: string, checkoutPath: string): Promise<InventoryScan> {
28
+ const skillFiles = await this.findSkillFiles(checkoutPath);
29
+ const candidates: Array<LeafRecord & { dedupeKey: string }> = [];
30
+ const invalidLeafs: InvalidLeafRecord[] = [];
31
+
32
+ for (const skillFilePath of skillFiles) {
33
+ const leafRoot = path.dirname(skillFilePath);
34
+ const relativePath = path.relative(checkoutPath, leafRoot) || ".";
35
+ const raw = await fs.readFile(skillFilePath, "utf8");
36
+ const linkName = path.basename(leafRoot) || sourceId;
37
+ const parsed = this.parseSkillFile(raw, linkName);
38
+
39
+ if (!parsed.valid) {
40
+ invalidLeafs.push({
41
+ path: relativePath,
42
+ reason: parsed.reason,
43
+ });
44
+ continue;
45
+ }
46
+
47
+ const safeName = slugify(parsed.name) || linkName || sourceId;
48
+
49
+ candidates.push({
50
+ id: `${sourceId}:${relativePath}`,
51
+ sourceId,
52
+ name: safeName,
53
+ linkName,
54
+ title: parsed.title,
55
+ description: parsed.description,
56
+ relativePath,
57
+ absolutePath: leafRoot,
58
+ skillFilePath,
59
+ contentHash: await hashDirectory(leafRoot),
60
+ metadataWarnings: parsed.metadataWarnings,
61
+ valid: true,
62
+ dedupeKey: `${parsed.name}\n${parsed.description}`,
63
+ });
64
+ }
65
+
66
+ const leafs = this.dedupeCandidates(candidates, invalidLeafs);
67
+ leafs.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
68
+ invalidLeafs.sort((left, right) => left.path.localeCompare(right.path));
69
+
70
+ return { leafs, invalidLeafs };
71
+ }
72
+
73
+ private async findSkillFiles(rootPath: string): Promise<string[]> {
74
+ const discovered: string[] = [];
75
+
76
+ async function walk(currentPath: string): Promise<void> {
77
+ const entries = await fs.readdir(currentPath, { withFileTypes: true });
78
+ const files = entries
79
+ .filter((entry) => entry.isFile())
80
+ .sort((left, right) => left.name.localeCompare(right.name));
81
+ const visibleDirectories = entries
82
+ .filter(
83
+ (entry) =>
84
+ entry.isDirectory() &&
85
+ !InventoryService.IGNORED_DIRECTORIES.has(entry.name) &&
86
+ !entry.name.startsWith("."),
87
+ )
88
+ .sort((left, right) => left.name.localeCompare(right.name));
89
+ const hiddenDirectories = entries
90
+ .filter(
91
+ (entry) =>
92
+ entry.isDirectory() &&
93
+ !InventoryService.IGNORED_DIRECTORIES.has(entry.name) &&
94
+ entry.name.startsWith("."),
95
+ )
96
+ .sort((left, right) => left.name.localeCompare(right.name));
97
+
98
+ for (const entry of files) {
99
+ if (entry.name === "SKILL.md") {
100
+ discovered.push(path.join(currentPath, entry.name));
101
+ }
102
+ }
103
+
104
+ for (const entry of [...visibleDirectories, ...hiddenDirectories]) {
105
+ await walk(path.join(currentPath, entry.name));
106
+ }
107
+ }
108
+
109
+ await walk(rootPath);
110
+ return discovered;
111
+ }
112
+
113
+ private parseSkillFile(raw: string, parentDirName: string): ParsedSkillFile {
114
+ const lines = raw.split(/\r?\n/);
115
+ const frontmatter = this.parseFrontmatter(lines);
116
+ if (!frontmatter) {
117
+ return { valid: false, reason: "SKILL.md must start with YAML frontmatter" };
118
+ }
119
+
120
+ if (!Object.hasOwn(frontmatter.data, "name")) {
121
+ return {
122
+ valid: false,
123
+ reason: "SKILL.md frontmatter must include required field 'name'",
124
+ };
125
+ }
126
+
127
+ if (!Object.hasOwn(frontmatter.data, "description")) {
128
+ return {
129
+ valid: false,
130
+ reason: "SKILL.md frontmatter must include required field 'description'",
131
+ };
132
+ }
133
+
134
+ const bodyLines = lines.slice(frontmatter.bodyStartLine);
135
+ const firstHeading = bodyLines.find((line) => line.trim().startsWith("# "));
136
+ const rawName = (frontmatter.data.name ?? "").trim();
137
+ const rawDescription = frontmatter.data.description ?? "";
138
+ const metadataWarnings: string[] = [];
139
+
140
+ if (
141
+ rawName.length < 1 ||
142
+ rawName.length > 64 ||
143
+ !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(rawName)
144
+ ) {
145
+ metadataWarnings.push(
146
+ "name should be 1-64 chars, lowercase letters/numbers/hyphens only, with no leading/trailing hyphen or consecutive '--'",
147
+ );
148
+ }
149
+
150
+ if (rawName !== parentDirName) {
151
+ metadataWarnings.push(
152
+ `name should match parent directory name '${parentDirName}'`,
153
+ );
154
+ }
155
+
156
+ if (rawDescription.trim().length === 0) {
157
+ metadataWarnings.push("description should be non-empty");
158
+ }
159
+
160
+ if (rawDescription.length > 1024) {
161
+ metadataWarnings.push("description should be at most 1024 characters");
162
+ }
163
+
164
+ const title = firstHeading?.trim().slice(2).trim() || rawName || "Untitled skill";
165
+
166
+ return {
167
+ valid: true,
168
+ name: rawName,
169
+ title,
170
+ description: rawDescription.trim(),
171
+ metadataWarnings,
172
+ };
173
+ }
174
+
175
+ private dedupeCandidates(
176
+ candidates: Array<LeafRecord & { dedupeKey: string }>,
177
+ invalidLeafs: InvalidLeafRecord[],
178
+ ): LeafRecord[] {
179
+ const keptByKey = new Map<string, LeafRecord>();
180
+ for (const candidate of candidates) {
181
+ if (keptByKey.has(candidate.dedupeKey)) {
182
+ const kept = keptByKey.get(candidate.dedupeKey)!;
183
+ invalidLeafs.push({
184
+ path: candidate.relativePath,
185
+ reason: `Duplicate skill content skipped because ${kept.relativePath} was discovered first`,
186
+ });
187
+ continue;
188
+ }
189
+ const { dedupeKey: _dedupeKey, ...leaf } = candidate;
190
+ keptByKey.set(candidate.dedupeKey, leaf);
191
+ }
192
+
193
+ return [...keptByKey.values()];
194
+ }
195
+
196
+ private parseFrontmatter(
197
+ lines: string[],
198
+ ): { data: Record<string, string>; bodyStartLine: number } | undefined {
199
+ if (lines[0]?.trim() !== "---") {
200
+ return undefined;
201
+ }
202
+
203
+ const data: Record<string, string> = {};
204
+ let index = 1;
205
+
206
+ while (index < lines.length) {
207
+ const line = lines[index] ?? "";
208
+ if (line.trim() === "---") {
209
+ return { data, bodyStartLine: index + 1 };
210
+ }
211
+
212
+ const pair = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
213
+ if (!pair) {
214
+ index += 1;
215
+ continue;
216
+ }
217
+
218
+ const key = pair[1];
219
+ const rest = pair[2];
220
+ if (!key || rest === undefined) {
221
+ index += 1;
222
+ continue;
223
+ }
224
+
225
+ if (rest === "|" || rest === ">") {
226
+ const blockLines: string[] = [];
227
+ index += 1;
228
+ while (index < lines.length) {
229
+ const blockLine = lines[index] ?? "";
230
+ if (blockLine.length === 0) {
231
+ blockLines.push("");
232
+ index += 1;
233
+ continue;
234
+ }
235
+ if (!blockLine.startsWith(" ")) {
236
+ break;
237
+ }
238
+ blockLines.push(blockLine.slice(2));
239
+ index += 1;
240
+ }
241
+ data[key] = blockLines.join("\n").trim();
242
+ continue;
243
+ }
244
+
245
+ data[key] = rest.trim();
246
+ index += 1;
247
+ }
248
+
249
+ return undefined;
250
+ }
251
+ }