@nseng-ai/areg 0.1.1

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 (45) hide show
  1. package/package.json +29 -0
  2. package/src/cli.ts +109 -0
  3. package/src/context.ts +59 -0
  4. package/src/fake-gateways.ts +1009 -0
  5. package/src/gateways/command-constants.ts +1 -0
  6. package/src/gateways/errors.ts +5 -0
  7. package/src/gateways/fs-utils.ts +26 -0
  8. package/src/gateways/github-gateway.ts +111 -0
  9. package/src/gateways/host-gateway.ts +35 -0
  10. package/src/gateways/mutation-policy.ts +94 -0
  11. package/src/gateways/npx-skills-gateway.ts +53 -0
  12. package/src/gateways/project-fs.ts +320 -0
  13. package/src/gateways/project-gateway.ts +801 -0
  14. package/src/gateways/prompt-gateway.ts +21 -0
  15. package/src/gateways/skill-kind-classification.ts +39 -0
  16. package/src/gateways/skillx-workspace-gateway.ts +220 -0
  17. package/src/gateways.ts +361 -0
  18. package/src/index.ts +48 -0
  19. package/src/operations/check.ts +559 -0
  20. package/src/operations/doctor-skills-report.ts +109 -0
  21. package/src/operations/doctor-skills-severity.ts +9 -0
  22. package/src/operations/doctor-skills.ts +471 -0
  23. package/src/operations/file-state.ts +77 -0
  24. package/src/operations/frontmatter.ts +151 -0
  25. package/src/operations/init.ts +548 -0
  26. package/src/operations/lockfile.ts +107 -0
  27. package/src/operations/managed-markdown-block.ts +47 -0
  28. package/src/operations/pi-replacement.ts +33 -0
  29. package/src/operations/pi-settings.ts +60 -0
  30. package/src/operations/project-agents.ts +115 -0
  31. package/src/operations/project-inspection.ts +165 -0
  32. package/src/operations/project-mutations.ts +383 -0
  33. package/src/operations/project-resolution.ts +53 -0
  34. package/src/operations/skill-find.ts +295 -0
  35. package/src/operations/skill-kind-apply-plan.ts +531 -0
  36. package/src/operations/skill-kind-frontmatter.ts +55 -0
  37. package/src/operations/skill-kind-inference.ts +391 -0
  38. package/src/operations/skill-kind.ts +525 -0
  39. package/src/operations/skill-mirror-conventions.ts +126 -0
  40. package/src/operations/skillx.ts +305 -0
  41. package/src/operations/toml-section.ts +53 -0
  42. package/src/operations/update-skills.ts +325 -0
  43. package/src/real-gateways.ts +6 -0
  44. package/src/skill-lookup.ts +254 -0
  45. package/src/sort.ts +7 -0
@@ -0,0 +1,471 @@
1
+ import { failure, negative, ok, type ClinkrExit } from "@nseng-ai/clinkr";
2
+ import { commandBackedSkillSurface } from "@nseng-ai/command-backed-skill-registry";
3
+ import { optionalEntries, optionalEntry } from "@nseng-ai/foundation/primitives";
4
+ import { z } from "zod";
5
+
6
+ import type { AregCliContext } from "../context.ts";
7
+ import type {
8
+ AregCheckSkillInspection,
9
+ AregPathState,
10
+ AregPiSkillInventoryInspection,
11
+ AregProjectBaseInspection,
12
+ AregReplacementInspection,
13
+ AregSkillKindSkillInspection,
14
+ AregSkillNameInventory,
15
+ AregTextFileState,
16
+ } from "../gateways.ts";
17
+ import { uniqueSortedStrings } from "../sort.ts";
18
+ import { renderDoctorSkills } from "./doctor-skills-report.ts";
19
+ import {
20
+ DOCTOR_SKILL_SEVERITY_RANK,
21
+ doctorSkillFindingSeverities,
22
+ type DoctorSkillFindingSeverity,
23
+ } from "./doctor-skills-severity.ts";
24
+ import { parsePiSettings } from "./pi-settings.ts";
25
+ import { collectCheckSkillInspections, collectSkillKindInspections } from "./project-inspection.ts";
26
+ import { buildSkillKindRecords, type SkillKindRecord } from "./skill-kind-inference.ts";
27
+ import { isAgentsSkillMirror, isClaudeSkillMirror } from "./skill-mirror-conventions.ts";
28
+
29
+ const doctorStatusValues = ["ok", "warning", "error"] as const;
30
+
31
+ export type { DoctorSkillFindingSeverity } from "./doctor-skills-severity.ts";
32
+ export type DoctorSkillsStatus = (typeof doctorStatusValues)[number];
33
+
34
+ export interface DoctorSkillFinding {
35
+ code: string;
36
+ severity: DoctorSkillFindingSeverity;
37
+ message: string;
38
+ remediation: string;
39
+ skill?: string;
40
+ path?: string;
41
+ surface?: string;
42
+ evidence?: Record<string, string | boolean | number | string[]>;
43
+ }
44
+
45
+ export interface DoctorSkillsResult {
46
+ projectDir: string;
47
+ summary: {
48
+ status: DoctorSkillsStatus;
49
+ findingCounts: Record<DoctorSkillFindingSeverity, number>;
50
+ };
51
+ findings: readonly DoctorSkillFinding[];
52
+ }
53
+
54
+ export interface DoctorSkillsInspection {
55
+ projectDir: string;
56
+ base: AregProjectBaseInspection;
57
+ skillInventory: AregSkillNameInventory;
58
+ piSkillInventory: AregPiSkillInventoryInspection;
59
+ replacement: AregReplacementInspection;
60
+ piDir: AregPathState;
61
+ piSettings: AregTextFileState;
62
+ checkSkills: readonly AregCheckSkillInspection[];
63
+ skillKindSkills: readonly AregSkillKindSkillInspection[];
64
+ }
65
+
66
+ const doctorSkillFindingSchema: z.ZodType<DoctorSkillFinding> = z
67
+ .object({
68
+ code: z.string(),
69
+ severity: z.enum(doctorSkillFindingSeverities),
70
+ message: z.string(),
71
+ remediation: z.string(),
72
+ skill: z.string().optional(),
73
+ path: z.string().optional(),
74
+ surface: z.string().optional(),
75
+ evidence: z
76
+ .record(z.string(), z.union([z.string(), z.boolean(), z.number(), z.array(z.string())]))
77
+ .optional(),
78
+ })
79
+ .transform(
80
+ (finding): DoctorSkillFinding => ({
81
+ code: finding.code,
82
+ severity: finding.severity,
83
+ message: finding.message,
84
+ remediation: finding.remediation,
85
+ ...optionalEntries({
86
+ skill: finding.skill,
87
+ path: finding.path,
88
+ surface: finding.surface,
89
+ evidence: finding.evidence,
90
+ }),
91
+ }),
92
+ );
93
+
94
+ export const doctorSkillsRequestSchema = z.object({
95
+ path: z
96
+ .string()
97
+ .default(".")
98
+ .describe("Project directory or subdirectory to inspect (default: current directory)."),
99
+ });
100
+
101
+ export const doctorSkillsResultSchema = z.object({
102
+ projectDir: z.string(),
103
+ summary: z.object({
104
+ status: z.enum(doctorStatusValues),
105
+ findingCounts: z.object({
106
+ error: z.number(),
107
+ warning: z.number(),
108
+ info: z.number(),
109
+ }),
110
+ }),
111
+ findings: z.array(doctorSkillFindingSchema),
112
+ });
113
+
114
+ export type DoctorSkillsRequest = z.infer<typeof doctorSkillsRequestSchema>;
115
+
116
+ export async function runDoctorSkills(
117
+ ctx: AregCliContext,
118
+ request: DoctorSkillsRequest,
119
+ ): Promise<ClinkrExit<DoctorSkillsResult>> {
120
+ const inspection = await inspectDoctorSkillsProject(ctx, request.path);
121
+ if (inspection.type === "error") return failure("project-inspection-failed", inspection.message);
122
+ const result = buildDoctorSkillsResult(inspection.value);
123
+ if (result.findings.length === 0) return ok(result);
124
+ return negative("Skill registry drift found.", {
125
+ data: result,
126
+ human: renderDoctorSkills(result),
127
+ });
128
+ }
129
+
130
+ export function buildDoctorSkillsResult(inspection: DoctorSkillsInspection): DoctorSkillsResult {
131
+ const findings = buildDoctorSkillFindings(inspection);
132
+ const findingCounts = countFindings(findings);
133
+ return {
134
+ projectDir: inspection.projectDir,
135
+ summary: {
136
+ status: statusForCounts(findingCounts),
137
+ findingCounts,
138
+ },
139
+ findings,
140
+ };
141
+ }
142
+
143
+ export function buildDoctorSkillFindings(
144
+ inspection: DoctorSkillsInspection,
145
+ ): readonly DoctorSkillFinding[] {
146
+ const piSettings = parsePiSettings(inspection.piDir, inspection.piSettings);
147
+ if (!piSettings.ok) {
148
+ return [
149
+ {
150
+ code: "pi-settings-unreadable",
151
+ severity: "error",
152
+ message: piSettings.error.message,
153
+ remediation:
154
+ "Fix .pi/settings.json so skill inventory and replacement diagnostics can run.",
155
+ path: ".pi/settings.json",
156
+ },
157
+ ];
158
+ }
159
+ const skillKindRecords = buildSkillKindRecords({
160
+ projectDir: inspection.projectDir,
161
+ projectPathState: inspection.base.projectPathState,
162
+ piDir: inspection.piDir,
163
+ piSettings: inspection.piSettings,
164
+ replacement: inspection.replacement,
165
+ skills: inspection.skillKindSkills,
166
+ });
167
+ const records = skillKindRecords.ok ? skillKindRecords.value : [];
168
+ const findings: DoctorSkillFinding[] = [];
169
+ findings.push(...filesystemFindings(inspection));
170
+ findings.push(...piInventoryFindings(inspection));
171
+ findings.push(...replacementFindings(records, piSettings.value.exclusions));
172
+ if (!skillKindRecords.ok) {
173
+ findings.push({
174
+ code: "skill-kind-inspection-invalid",
175
+ severity: "error",
176
+ message: skillKindRecords.error.message,
177
+ remediation:
178
+ "Fix the managed skill metadata so command-backed replacement diagnostics can inspect it.",
179
+ });
180
+ }
181
+ return sortFindings(findings);
182
+ }
183
+
184
+ async function inspectDoctorSkillsProject(
185
+ ctx: AregCliContext,
186
+ requestPath: string,
187
+ ): Promise<{ type: "ok"; value: DoctorSkillsInspection } | { type: "error"; message: string }> {
188
+ const base = await ctx.project.inspectProjectBase({
189
+ cwd: ctx.cwd,
190
+ projectPath: requestPath,
191
+ env: ctx.env,
192
+ });
193
+ if (base.projectPathState.type === "missing")
194
+ return { type: "error", message: `Target ${base.projectDir} does not exist.` };
195
+ if (base.projectPathState.type !== "directory")
196
+ return { type: "error", message: `${base.projectDir} is not a directory.` };
197
+ const repoRoot = await ctx.git.optionalRepoRoot({ cwd: base.projectDir });
198
+ if (repoRoot.type === "error") return { type: "error", message: repoRoot.error.message };
199
+ if (repoRoot.type === "missing")
200
+ return { type: "error", message: `No Git root found containing ${base.projectDir}.` };
201
+ const projectDir = repoRoot.value;
202
+ const rootBase =
203
+ projectDir === base.projectDir
204
+ ? base
205
+ : await ctx.project.inspectProjectBase({ cwd: projectDir, projectPath: ".", env: ctx.env });
206
+ const piArtifacts = await ctx.project.inspectPiArtifacts({ projectDir, env: ctx.env });
207
+ const skillInventory = await ctx.project.inspectSkillNameInventory({ projectDir, env: ctx.env });
208
+ const piSkillInventory = await ctx.project.inspectPiSkillInventory({ projectDir, env: ctx.env });
209
+ const skillNames = allKnownSkillNames(skillInventory, { includeSkillKindNames: true });
210
+ const checkSkills = await collectCheckSkillInspections(ctx, projectDir, skillNames);
211
+ const skillKindSkills = await collectSkillKindInspections(ctx, projectDir, skillNames);
212
+ return {
213
+ type: "ok",
214
+ value: {
215
+ projectDir,
216
+ base: rootBase,
217
+ skillInventory,
218
+ piSkillInventory,
219
+ replacement: piArtifacts.replacement,
220
+ piDir: piArtifacts.piDir,
221
+ piSettings: piArtifacts.piSettings,
222
+ checkSkills,
223
+ skillKindSkills,
224
+ },
225
+ };
226
+ }
227
+
228
+ function filesystemFindings(inspection: DoctorSkillsInspection): readonly DoctorSkillFinding[] {
229
+ const findings: DoctorSkillFinding[] = [];
230
+ const managed = new Set(inspection.skillInventory.skillKindNames);
231
+ const skillNames = allKnownSkillNames(inspection.skillInventory);
232
+ const checkSkillsByName = new Map(inspection.checkSkills.map((skill) => [skill.name, skill]));
233
+ for (const skillName of skillNames) {
234
+ const roots = rootsForSkill(inspection.skillInventory, skillName);
235
+ const independentRoots = independentRootsForSkill(
236
+ skillName,
237
+ roots,
238
+ checkSkillsByName.get(skillName),
239
+ );
240
+ if (independentRoots.length > 1) {
241
+ findings.push({
242
+ code: "skill-root-shadowed",
243
+ severity: "warning",
244
+ skill: skillName,
245
+ message: `${skillName} appears in multiple canonical skill roots: ${independentRoots.join(", ")}.`,
246
+ remediation:
247
+ "Keep one canonical skill root; use areg-managed symlink mirrors for agent-specific skill roots.",
248
+ evidence: { roots, independentRoots: [...independentRoots] },
249
+ });
250
+ }
251
+ if (!managed.has(skillName)) {
252
+ findings.push({
253
+ code: "skill-root-unmanaged",
254
+ severity: "warning",
255
+ skill: skillName,
256
+ message: `${skillName} exists on disk but is absent from areg's managed skill-kind inventory.`,
257
+ remediation:
258
+ "Run areg skill list/show to confirm the root is intentionally unmanaged, then move or reconcile the skill root.",
259
+ evidence: { roots },
260
+ });
261
+ }
262
+ }
263
+ for (const skill of inspection.skillKindSkills) {
264
+ if (skill.skillDir.type !== "symlink") continue;
265
+ findings.push({
266
+ code: "skill-root-symlink-refused",
267
+ severity: "error",
268
+ skill: skill.name,
269
+ path: skill.baseRelativePath,
270
+ message: `${skill.baseRelativePath} is a symlink; diagnostics refuse to treat it as a canonical skill root.`,
271
+ remediation: "Replace the symlink with a real skill directory or remove the shadowing root.",
272
+ evidence: { target: skill.skillDir.target },
273
+ });
274
+ }
275
+ for (const skill of inspection.checkSkills) {
276
+ if (skill.skillsPath.type !== "missing" && skill.localSkillMd.type !== "file") {
277
+ findings.push(missingSkillMdFinding(skill.name, `skills/${skill.name}/SKILL.md`));
278
+ }
279
+ if (skill.agentsPath.type !== "missing" && skill.remoteSkillMd.type !== "file") {
280
+ findings.push(missingSkillMdFinding(skill.name, `.agents/skills/${skill.name}/SKILL.md`));
281
+ }
282
+ }
283
+ return findings;
284
+ }
285
+
286
+ function piInventoryFindings(inspection: DoctorSkillsInspection): readonly DoctorSkillFinding[] {
287
+ const findings: DoctorSkillFinding[] = [];
288
+ const expected = new Set(inspection.skillInventory.skillKindNames);
289
+ const exposed = new Set(inspection.piSkillInventory.skillNames);
290
+ for (const skill of [...expected].sort()) {
291
+ if (exposed.has(skill)) continue;
292
+ findings.push({
293
+ code: "pi-inventory-missing-skill",
294
+ severity: "warning",
295
+ skill,
296
+ message: `Areg sees ${skill}, but Pi's model-facing skill inventory ${inventoryQualifier(inspection.piSkillInventory)} does not expose it.`,
297
+ remediation:
298
+ "Refresh Pi startup skill inventory or add/verify a replacement command with areg skill apply command-backed if the skill is command-backed.",
299
+ evidence: {
300
+ piInventoryApproximation: inspection.piSkillInventory.isApproximation,
301
+ piInventorySource: inspection.piSkillInventory.source,
302
+ exposedSkillNames: [...inspection.piSkillInventory.skillNames],
303
+ },
304
+ });
305
+ }
306
+ for (const skill of [...exposed].sort()) {
307
+ if (expected.has(skill)) continue;
308
+ findings.push({
309
+ code: "pi-inventory-extra-skill",
310
+ severity: "info",
311
+ skill,
312
+ message: `Pi's model-facing skill inventory ${inventoryQualifier(inspection.piSkillInventory)} exposes ${skill}, but areg does not manage it.`,
313
+ remediation:
314
+ "Remove stale Pi skill inventory entries or add the skill to an areg-managed root.",
315
+ evidence: { piInventorySource: inspection.piSkillInventory.source },
316
+ });
317
+ }
318
+ return findings;
319
+ }
320
+
321
+ function replacementFindings(
322
+ records: readonly SkillKindRecord[],
323
+ exclusions: readonly string[],
324
+ ): readonly DoctorSkillFinding[] {
325
+ const findings: DoctorSkillFinding[] = [];
326
+ const excludedSkills = new Set(
327
+ exclusions
328
+ .filter((entry) => entry.startsWith("-skills/"))
329
+ .map((entry) => entry.slice("-skills/".length)),
330
+ );
331
+ for (const record of records) {
332
+ // Unlisted skills are deliberately excluded without a Pi replacement; their
333
+ // drift states (mirrors present, missing sidecar, ...) infer other kinds and
334
+ // still reach this finding.
335
+ if (record.kind === "unlisted") continue;
336
+ if (record.artifacts.isPiExcluded && !record.replacement.verified) {
337
+ findings.push(
338
+ replacementFinding(record.skill, record.replacement.surface, {
339
+ code: "excluded-skill-without-replacement",
340
+ severity: "error",
341
+ message: `${record.skill} is excluded in .pi/settings.json without a verified replacement surface.`,
342
+ remediation:
343
+ "Add the replacement Pi command surface and keep the exclusion, or remove the exclusion.",
344
+ }),
345
+ );
346
+ }
347
+ if (record.replacement.verified && !record.artifacts.isPiExcluded) {
348
+ findings.push(
349
+ replacementFinding(record.skill, record.replacement.surface, {
350
+ code: "command-backed-skill-not-excluded",
351
+ severity: "warning",
352
+ message: `${record.skill} has a verified replacement command but is not excluded from Pi skill discovery.`,
353
+ remediation:
354
+ "Run areg skill apply command-backed <skill> if this skill is command-backed, or remove the stale replacement surface expectation.",
355
+ }),
356
+ );
357
+ }
358
+ }
359
+ for (const skill of [...excludedSkills].sort()) {
360
+ if (records.some((record) => record.skill === skill)) continue;
361
+ const surface = commandBackedSkillSurface(skill);
362
+ findings.push(
363
+ replacementFinding(skill, surface, {
364
+ code: "excluded-skill-without-replacement",
365
+ severity: "error",
366
+ message: `${skill} is excluded in .pi/settings.json but was not found in areg's skill inventory.`,
367
+ remediation: "Remove the stale exclusion or restore the skill and its replacement command.",
368
+ }),
369
+ );
370
+ }
371
+ return findings;
372
+ }
373
+
374
+ function replacementFinding(
375
+ skill: string,
376
+ surface: string | undefined,
377
+ finding: Pick<DoctorSkillFinding, "code" | "severity" | "message" | "remediation">,
378
+ ): DoctorSkillFinding {
379
+ return {
380
+ ...finding,
381
+ skill,
382
+ ...optionalEntry("surface", surface),
383
+ };
384
+ }
385
+
386
+ type SkillRoot = "skills" | ".agents/skills" | ".claude/skills";
387
+
388
+ function rootsForSkill(inventory: AregSkillNameInventory, skillName: string): SkillRoot[] {
389
+ const roots: SkillRoot[] = [];
390
+ if (inventory.skillsDirectoryNames.includes(skillName)) roots.push("skills");
391
+ if (inventory.agentsSkillNames.includes(skillName)) roots.push(".agents/skills");
392
+ if (inventory.claudeSkillNames.includes(skillName)) roots.push(".claude/skills");
393
+ return roots;
394
+ }
395
+
396
+ function allKnownSkillNames(
397
+ inventory: AregSkillNameInventory,
398
+ options: { includeSkillKindNames?: boolean } = {},
399
+ ): readonly string[] {
400
+ return uniqueSortedStrings([
401
+ ...inventory.skillsDirectoryNames,
402
+ ...inventory.agentsSkillNames,
403
+ ...inventory.claudeSkillNames,
404
+ ...(options.includeSkillKindNames === true ? inventory.skillKindNames : []),
405
+ ]);
406
+ }
407
+
408
+ function independentRootsForSkill(
409
+ skillName: string,
410
+ roots: readonly SkillRoot[],
411
+ checkSkill: AregCheckSkillInspection | undefined,
412
+ ): readonly SkillRoot[] {
413
+ return roots.filter((root) => !isAregManagedMirrorRoot(skillName, root, checkSkill));
414
+ }
415
+
416
+ function isAregManagedMirrorRoot(
417
+ skillName: string,
418
+ root: SkillRoot,
419
+ checkSkill: AregCheckSkillInspection | undefined,
420
+ ): boolean {
421
+ if (checkSkill === undefined) return false;
422
+ switch (root) {
423
+ case "skills":
424
+ return false;
425
+ case ".agents/skills":
426
+ return isAgentsSkillMirror(checkSkill.agentsPath, skillName);
427
+ case ".claude/skills":
428
+ return isClaudeSkillMirror(checkSkill.claudePath, skillName);
429
+ }
430
+ }
431
+
432
+ function missingSkillMdFinding(skill: string, path: string): DoctorSkillFinding {
433
+ return {
434
+ code: "skill-root-missing-skill-md",
435
+ severity: "error",
436
+ skill,
437
+ path,
438
+ message: `${path} is missing or unreadable.`,
439
+ remediation: "Add a readable SKILL.md or remove the directory from the skill root.",
440
+ };
441
+ }
442
+
443
+ function countFindings(
444
+ findings: readonly DoctorSkillFinding[],
445
+ ): Record<DoctorSkillFindingSeverity, number> {
446
+ return {
447
+ error: findings.filter((finding) => finding.severity === "error").length,
448
+ warning: findings.filter((finding) => finding.severity === "warning").length,
449
+ info: findings.filter((finding) => finding.severity === "info").length,
450
+ };
451
+ }
452
+
453
+ function statusForCounts(counts: Record<DoctorSkillFindingSeverity, number>): DoctorSkillsStatus {
454
+ if (counts.error > 0) return "error";
455
+ if (counts.warning > 0) return "warning";
456
+ return "ok";
457
+ }
458
+
459
+ function inventoryQualifier(inventory: AregPiSkillInventoryInspection): string {
460
+ return inventory.isApproximation ? "approximation" : "snapshot";
461
+ }
462
+
463
+ function sortFindings(findings: readonly DoctorSkillFinding[]): readonly DoctorSkillFinding[] {
464
+ return [...findings].sort(
465
+ (left, right) =>
466
+ DOCTOR_SKILL_SEVERITY_RANK[left.severity] - DOCTOR_SKILL_SEVERITY_RANK[right.severity] ||
467
+ left.code.localeCompare(right.code) ||
468
+ (left.skill ?? "").localeCompare(right.skill ?? "") ||
469
+ (left.path ?? left.surface ?? "").localeCompare(right.path ?? right.surface ?? ""),
470
+ );
471
+ }
@@ -0,0 +1,77 @@
1
+ import { optionalEntry } from "@nseng-ai/foundation/primitives";
2
+ import { resultErr, type Result } from "@nseng-ai/foundation/result";
3
+
4
+ import type { AregPathState, AregTextFileState } from "../gateways.ts";
5
+
6
+ type NonUsableTextFileState = Exclude<AregTextFileState, { type: "file" } | { type: "missing" }>;
7
+ type NonUsableDirectoryState = Exclude<AregPathState, { type: "directory" } | { type: "missing" }>;
8
+
9
+ export function isPathStateError(error: { code: string }): boolean {
10
+ return (
11
+ error.code === "path_symlink" ||
12
+ error.code === "path_not_file" ||
13
+ error.code === "path_not_directory"
14
+ );
15
+ }
16
+
17
+ export function validateOptionalDirectoryState(options: {
18
+ pathLabel: string;
19
+ state: AregPathState;
20
+ action: string;
21
+ symlinkSubject?: string;
22
+ }): Result<undefined> {
23
+ if (options.state.type === "missing" || options.state.type === "directory")
24
+ return { ok: true, value: undefined };
25
+ return rejectDirectoryState({
26
+ pathLabel: options.pathLabel,
27
+ state: options.state,
28
+ action: options.action,
29
+ ...optionalEntry("symlinkSubject", options.symlinkSubject),
30
+ });
31
+ }
32
+
33
+ export function rejectTextState<T>(options: {
34
+ pathLabel: string;
35
+ state: NonUsableTextFileState;
36
+ action: string;
37
+ description?: string;
38
+ unreadableMode?: "failed-read" | "not-file";
39
+ }): Result<T> {
40
+ if (options.state.type === "symlink") {
41
+ const subject =
42
+ options.description === undefined
43
+ ? options.pathLabel
44
+ : `${options.description} at ${options.pathLabel}`;
45
+ return resultErr({
46
+ code: "path_symlink",
47
+ message: `${subject} is a symlink; refusing to ${options.action}.`,
48
+ });
49
+ }
50
+ if (options.state.type === "unreadable" && options.unreadableMode !== "not-file") {
51
+ return resultErr({
52
+ code: "path_read_failed",
53
+ message: `Failed to read ${options.pathLabel}: ${options.state.message}`,
54
+ });
55
+ }
56
+ return resultErr({
57
+ code: "path_not_file",
58
+ message: `${options.pathLabel} exists but is not a file.`,
59
+ });
60
+ }
61
+
62
+ function rejectDirectoryState<T>(options: {
63
+ pathLabel: string;
64
+ state: NonUsableDirectoryState;
65
+ action: string;
66
+ symlinkSubject?: string;
67
+ }): Result<T> {
68
+ if (options.state.type === "symlink")
69
+ return resultErr({
70
+ code: "path_symlink",
71
+ message: `${options.symlinkSubject ?? options.pathLabel} is a symlink; refusing to ${options.action}.`,
72
+ });
73
+ return resultErr({
74
+ code: "path_not_directory",
75
+ message: `${options.pathLabel} exists but is not a directory.`,
76
+ });
77
+ }
@@ -0,0 +1,151 @@
1
+ import {
2
+ splitMarkdownFrontmatter,
3
+ stripLineEnding,
4
+ } from "@nseng-ai/foundation/markdown-frontmatter";
5
+ import { err, type Result } from "@nseng-ai/foundation/result";
6
+
7
+ const FRONTMATTER_KEY_RE = /^(?<key>[A-Za-z0-9_-]+):(?<value>.*)$/u;
8
+
9
+ export interface SkillFrontmatterData {
10
+ fields: Readonly<Record<string, string>>;
11
+ keys: ReadonlySet<string>;
12
+ }
13
+
14
+ export type SkillFrontmatterParseResult = Result<SkillFrontmatterData>;
15
+
16
+ export type SkillFrontmatterTopLevelLineParseResult =
17
+ | { type: "key"; key: string; value: string }
18
+ | { type: "not_top_level" }
19
+ | { type: "invalid"; line: string };
20
+
21
+ export function parseSkillFrontmatterBlock(text: string): SkillFrontmatterParseResult {
22
+ const split = splitMarkdownFrontmatter(text);
23
+ if (split.type === "not_found")
24
+ return err({
25
+ code: "frontmatter_missing_opening_delimiter",
26
+ message: "missing opening frontmatter delimiter '---'",
27
+ });
28
+ if (split.type === "missing_closing_fence")
29
+ return err({
30
+ code: "frontmatter_missing_closing_delimiter",
31
+ message: "missing closing frontmatter delimiter '---'",
32
+ });
33
+
34
+ const lines = split.block.frontmatterLinesWithEndings.map(stripLineEnding);
35
+ const fields: Record<string, string> = {};
36
+ const keys = new Set<string>();
37
+ let currentKey: string | undefined;
38
+ let currentValues: string[] = [];
39
+ function flushCurrent(): void {
40
+ if (currentKey === undefined) return;
41
+ let rawValue = currentValues
42
+ .filter((value) => value.length > 0)
43
+ .join(" ")
44
+ .trim();
45
+ if (
46
+ rawValue.length >= 2 &&
47
+ rawValue[0] === rawValue.at(-1) &&
48
+ (rawValue[0] === '"' || rawValue[0] === "'")
49
+ )
50
+ rawValue = rawValue.slice(1, -1);
51
+ fields[currentKey] = rawValue;
52
+ }
53
+ for (const line of lines) {
54
+ const stripped = line.trim();
55
+ if (stripped.length === 0) continue;
56
+ if (stripped.startsWith("#")) continue;
57
+ const parsedLine = parseSkillFrontmatterTopLevelLine(line);
58
+ if (parsedLine.type === "key") {
59
+ flushCurrent();
60
+ if (keys.has(parsedLine.key))
61
+ return err({
62
+ code: "frontmatter_duplicate_key",
63
+ message: `duplicate frontmatter key: ${JSON.stringify(parsedLine.key)}`,
64
+ });
65
+ currentKey = parsedLine.key;
66
+ keys.add(currentKey);
67
+ currentValues = [];
68
+ const inlineValue = parsedLine.value.trim();
69
+ if (inlineValue.length > 0) currentValues.push(inlineValue);
70
+ continue;
71
+ }
72
+ if (parsedLine.type === "invalid")
73
+ return err({
74
+ code: "frontmatter_invalid_line",
75
+ message: `invalid frontmatter line: ${JSON.stringify(parsedLine.line)}`,
76
+ });
77
+ if (currentKey === undefined)
78
+ return err({
79
+ code: "frontmatter_invalid_line",
80
+ message: `invalid frontmatter line: ${JSON.stringify(line)}`,
81
+ });
82
+ currentValues.push(line.trim());
83
+ }
84
+ flushCurrent();
85
+ return { ok: true, value: { fields, keys } };
86
+ }
87
+
88
+ export function parseSkillFrontmatterTopLevelLine(
89
+ line: string,
90
+ ): SkillFrontmatterTopLevelLineParseResult {
91
+ const content = stripLineEnding(line);
92
+ if (content.trim().length === 0) return { type: "not_top_level" };
93
+ if (content.trim().startsWith("#")) return { type: "not_top_level" };
94
+ if (content.startsWith(" ") || content.startsWith("\t")) return { type: "not_top_level" };
95
+ const match = FRONTMATTER_KEY_RE.exec(content);
96
+ if (match?.groups === undefined) return { type: "invalid", line: content };
97
+ return { type: "key", key: match.groups.key ?? "", value: match.groups.value ?? "" };
98
+ }
99
+
100
+ export function isSkillFrontmatterTopLevelKey(line: string, key: string): boolean {
101
+ const parsed = parseSkillFrontmatterTopLevelLine(line);
102
+ return parsed.type === "key" && parsed.key === key;
103
+ }
104
+
105
+ export function transformSkillFrontmatter(
106
+ text: string,
107
+ pathLabel: string,
108
+ desired: Readonly<Record<string, string | undefined>>,
109
+ ): Result<string> {
110
+ const parsed = parseSkillFrontmatterBlock(text);
111
+ if (!parsed.ok)
112
+ return {
113
+ ok: false,
114
+ error: { ...parsed.error, message: `${pathLabel} ${parsed.error.message}` },
115
+ };
116
+
117
+ const split = splitMarkdownFrontmatter(text);
118
+ if (split.type !== "found")
119
+ return err({
120
+ code: "frontmatter_invalid_bounds",
121
+ message: `${pathLabel} invalid frontmatter bounds`,
122
+ });
123
+
124
+ const lines = [...split.block.linesWithEndings];
125
+ const nameIndex = lines.findIndex(
126
+ (line, index) =>
127
+ index > 0 && index < split.block.closingIndex && isSkillFrontmatterTopLevelKey(line, "name"),
128
+ );
129
+ if (nameIndex === -1)
130
+ return err({
131
+ code: "frontmatter_missing_name",
132
+ message: `${pathLabel} missing name field in frontmatter`,
133
+ });
134
+
135
+ const managedKeys = Object.keys(desired);
136
+ const kept = lines.filter(
137
+ (line, index) =>
138
+ index <= 0 ||
139
+ index >= split.block.closingIndex ||
140
+ !managedKeys.some((key) => isSkillFrontmatterTopLevelKey(line, key)),
141
+ );
142
+ const keptNameIndex = kept.findIndex(
143
+ (line, index) => index > 0 && isSkillFrontmatterTopLevelKey(line, "name"),
144
+ );
145
+ const additions = managedKeys.flatMap((key) => {
146
+ const value = desired[key];
147
+ return value === undefined ? [] : [`${key}: ${value}${split.block.lineEnding}`];
148
+ });
149
+ kept.splice(keptNameIndex + 1, 0, ...additions);
150
+ return { ok: true, value: kept.join("") };
151
+ }