@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,531 @@
1
+ import { err, type Result } from "@nseng-ai/foundation/result";
2
+
3
+ import type {
4
+ AregSkillKindDeletePlan,
5
+ AregSkillKindDeleteSymlinkPlan,
6
+ AregSkillKindRemoveEmptyDirPlan,
7
+ AregSkillKindSkillInspection,
8
+ AregSkillKindTextWritePlan,
9
+ } from "../gateways.ts";
10
+ import { replacementAdvice, verifyPiReplacement } from "./pi-replacement.ts";
11
+ import {
12
+ agentsSkillMirrorRelativePath,
13
+ claudeSkillMirrorRelativePath,
14
+ expectedAgentsSkillSymlinkTarget,
15
+ expectedClaudeSkillSymlinkTarget,
16
+ } from "./skill-mirror-conventions.ts";
17
+ import { parsePiSettings, type PiSettingsData } from "./pi-settings.ts";
18
+ import {
19
+ PROJECT_FILE_MUTATION_OPERATION_TYPES,
20
+ PROJECT_MUTATION_OPERATION_TYPES,
21
+ type ProjectMutationOperationStatusRecord,
22
+ } from "./project-mutations.ts";
23
+ import { planFrontmatterOperation } from "./skill-kind-frontmatter.ts";
24
+ import {
25
+ isLegacyBareOpenaiPolicyContent,
26
+ isManagedOpenaiPolicyContent,
27
+ KIND_PROPERTIES,
28
+ MANAGED_OPENAI_POLICY,
29
+ type SkillInvocationKind,
30
+ type SkillKindProjectInspection,
31
+ validateInspectableSkill,
32
+ } from "./skill-kind-inference.ts";
33
+
34
+ export const APPLY_OPERATION_TYPES = [...PROJECT_FILE_MUTATION_OPERATION_TYPES, "skip"] as const;
35
+ export const APPLY_STATUS_OPERATION_TYPES = [...PROJECT_MUTATION_OPERATION_TYPES, "skip"] as const;
36
+
37
+ export type ApplyOperationType = (typeof APPLY_OPERATION_TYPES)[number];
38
+
39
+ export interface PlannedApplyOperationBase {
40
+ type: ApplyOperationType;
41
+ relativePath: string;
42
+ description: string;
43
+ }
44
+
45
+ export interface PlannedWriteOperation extends PlannedApplyOperationBase {
46
+ type: "write";
47
+ content: string;
48
+ shouldCreateParent: boolean;
49
+ }
50
+
51
+ export interface PlannedSkipOperation extends PlannedApplyOperationBase {
52
+ type: "skip";
53
+ reason: string;
54
+ }
55
+
56
+ export interface PlannedDeleteOperation extends PlannedApplyOperationBase {
57
+ type: "delete";
58
+ }
59
+
60
+ export interface PlannedRemoveEmptyDirOperation extends PlannedApplyOperationBase {
61
+ type: "remove-empty-dir";
62
+ }
63
+
64
+ export interface PlannedDeleteSymlinkOperation extends PlannedApplyOperationBase {
65
+ type: "delete-symlink";
66
+ expectedTarget: string;
67
+ }
68
+
69
+ export type PlannedApplyOperation =
70
+ | PlannedWriteOperation
71
+ | PlannedSkipOperation
72
+ | PlannedDeleteOperation
73
+ | PlannedDeleteSymlinkOperation
74
+ | PlannedRemoveEmptyDirOperation;
75
+
76
+ export interface SkillKindApplyPlan {
77
+ skill: string;
78
+ kind: SkillInvocationKind;
79
+ operations: readonly PlannedApplyOperation[];
80
+ }
81
+
82
+ export interface SkillKindApplyOperationResult {
83
+ type: ApplyOperationType;
84
+ path: string;
85
+ reason?: string;
86
+ isApplied: boolean;
87
+ }
88
+
89
+ export interface SkillKindApplySkipStatusResult {
90
+ type: "skip";
91
+ path: string;
92
+ description: string;
93
+ status: "skipped";
94
+ error?: unknown;
95
+ }
96
+
97
+ export type SkillKindApplyOperationStatusResult =
98
+ | ProjectMutationOperationStatusRecord
99
+ | SkillKindApplySkipStatusResult;
100
+
101
+ export function buildSkillKindApplyPlan(
102
+ inspection: SkillKindProjectInspection,
103
+ skillName: string,
104
+ kind: SkillInvocationKind,
105
+ ): Result<SkillKindApplyPlan> {
106
+ const skill = inspection.skills.find((candidate) => candidate.name === skillName);
107
+ if (skill === undefined)
108
+ return err({ code: "skill_not_found", message: `Managed skill not found: ${skillName}` });
109
+ const readiness = validateInspectableSkill(skill);
110
+ if (!readiness.ok) return readiness;
111
+ if (skill.skillMd.type !== "file")
112
+ return err({
113
+ code: "skill_not_found",
114
+ message: `${skill.baseRelativePath}/SKILL.md does not exist`,
115
+ });
116
+ if (kind === "command-backed") {
117
+ const replacement = verifyPiReplacement(skill.name, inspection.replacement);
118
+ if (!replacement.verified)
119
+ return err({
120
+ code: "skill_not_found",
121
+ message: replacementAdvice(skill.name, replacement.surface),
122
+ });
123
+ }
124
+ if (kind === "unlisted") {
125
+ if (skill.sourceType !== "repo")
126
+ return err({
127
+ code: "unlisted_requires_first_party",
128
+ message: `Skill '${skill.name}' is not first-party (${skill.baseRelativePath}); unlisted only applies to skills/<name>/ sources.`,
129
+ });
130
+ const replacement = verifyPiReplacement(skill.name, inspection.replacement);
131
+ if (replacement.surface !== undefined)
132
+ return err({
133
+ code: "unlisted_registry_surface_present",
134
+ message: `Skill '${skill.name}' still has a COMMAND_BACKED_SKILL_REGISTRY entry (/${replacement.surface}); remove the registry entry first, then apply unlisted.`,
135
+ });
136
+ }
137
+ const piSettings = parsePiSettings(inspection.piDir, inspection.piSettings);
138
+ if (!piSettings.ok) return piSettings;
139
+ const frontmatter = planFrontmatterOperation(skill.baseRelativePath, skill.skillMd.text, kind);
140
+ if (!frontmatter.ok) return frontmatter;
141
+ const sidecar = planSidecarOperations(skill, kind);
142
+ if (!sidecar.ok) return sidecar;
143
+ const pi = planPiSettingsOperation(skill.name, kind, piSettings.value);
144
+ if (!pi.ok) return pi;
145
+ const mirrors = planMirrorOperations(skill, kind);
146
+ if (!mirrors.ok) return mirrors;
147
+ return {
148
+ ok: true,
149
+ value: {
150
+ skill: skill.name,
151
+ kind,
152
+ operations: [frontmatter.value, ...sidecar.value, pi.value, ...mirrors.value],
153
+ },
154
+ };
155
+ }
156
+
157
+ export function inspectionAfterPlannedApply(
158
+ inspection: SkillKindProjectInspection,
159
+ plan: SkillKindApplyPlan,
160
+ ): SkillKindProjectInspection {
161
+ const piSettingsWrite = plan.operations.find(
162
+ (operation): operation is PlannedWriteOperation =>
163
+ operation.type === "write" && operation.relativePath === ".pi/settings.json",
164
+ );
165
+ return {
166
+ ...inspection,
167
+ piSettings:
168
+ piSettingsWrite === undefined
169
+ ? inspection.piSettings
170
+ : { type: "file", text: piSettingsWrite.content },
171
+ skills: inspection.skills.map((skill) =>
172
+ skill.name === plan.skill ? skillAfterPlannedApply(skill, plan) : skill,
173
+ ),
174
+ };
175
+ }
176
+
177
+ export function skillAfterPlannedApply(
178
+ skill: AregSkillKindSkillInspection,
179
+ plan: SkillKindApplyPlan,
180
+ ): AregSkillKindSkillInspection {
181
+ let skillMd = skill.skillMd;
182
+ let openaiPolicy = skill.openaiPolicy;
183
+ let agentsPath = skill.agentsPath;
184
+ let claudePath = skill.claudePath;
185
+ for (const operation of plan.operations) {
186
+ if (
187
+ operation.type === "write" &&
188
+ operation.relativePath === `${skill.baseRelativePath}/SKILL.md`
189
+ )
190
+ skillMd = { type: "file", text: operation.content };
191
+ if (
192
+ operation.type === "write" &&
193
+ operation.relativePath === `${skill.baseRelativePath}/agents/openai.yaml`
194
+ )
195
+ openaiPolicy = { type: "file", text: operation.content };
196
+ if (
197
+ operation.type === "delete" &&
198
+ operation.relativePath === `${skill.baseRelativePath}/agents/openai.yaml`
199
+ )
200
+ openaiPolicy = { type: "missing" };
201
+ if (
202
+ operation.type === "delete-symlink" &&
203
+ operation.relativePath === agentsSkillMirrorRelativePath(skill.name)
204
+ )
205
+ agentsPath = { type: "missing" };
206
+ if (
207
+ operation.type === "delete-symlink" &&
208
+ operation.relativePath === claudeSkillMirrorRelativePath(skill.name)
209
+ )
210
+ claudePath = { type: "missing" };
211
+ }
212
+ return { ...skill, skillMd, openaiPolicy, agentsPath, claudePath };
213
+ }
214
+
215
+ /**
216
+ * Plans skill mirror symlink removals. Only the unlisted kind manages mirrors,
217
+ * and only in the delete direction: areg never creates mirror symlinks, so
218
+ * reverting unlisted back to a listed kind requires re-running the
219
+ * skill-management install flow (areg check stays red until the mirrors are
220
+ * reinstalled).
221
+ */
222
+ export function planMirrorOperations(
223
+ skill: AregSkillKindSkillInspection,
224
+ kind: SkillInvocationKind,
225
+ ): Result<readonly PlannedApplyOperation[]> {
226
+ if (kind !== "unlisted") return { ok: true, value: [] };
227
+ const mirrors = [
228
+ {
229
+ relativePath: claudeSkillMirrorRelativePath(skill.name),
230
+ description: "Claude skill mirror symlink",
231
+ expectedTarget: expectedClaudeSkillSymlinkTarget(skill.name),
232
+ state: skill.claudePath,
233
+ },
234
+ {
235
+ relativePath: agentsSkillMirrorRelativePath(skill.name),
236
+ description: "agents skill mirror symlink",
237
+ expectedTarget: expectedAgentsSkillSymlinkTarget(skill.name),
238
+ state: skill.agentsPath,
239
+ },
240
+ ];
241
+ const operations: PlannedApplyOperation[] = [];
242
+ for (const mirror of mirrors) {
243
+ if (mirror.state.type === "missing") {
244
+ operations.push({
245
+ type: "skip",
246
+ relativePath: mirror.relativePath,
247
+ description: mirror.description,
248
+ reason: `${mirror.relativePath} absent`,
249
+ });
250
+ continue;
251
+ }
252
+ if (mirror.state.type !== "symlink")
253
+ return err({
254
+ code: "mirror_not_symlink",
255
+ message: `${mirror.relativePath} exists but is not a symlink; resolve it manually before applying unlisted.`,
256
+ });
257
+ if (mirror.state.target !== mirror.expectedTarget)
258
+ return err({
259
+ code: "mirror_wrong_target",
260
+ message: `${mirror.relativePath} points to ${mirror.state.target}, expected ${mirror.expectedTarget}; resolve it manually before applying unlisted.`,
261
+ });
262
+ operations.push({
263
+ type: "delete-symlink",
264
+ relativePath: mirror.relativePath,
265
+ description: mirror.description,
266
+ expectedTarget: mirror.expectedTarget,
267
+ });
268
+ }
269
+ return { ok: true, value: operations };
270
+ }
271
+
272
+ export function planSidecarOperations(
273
+ skill: AregSkillKindSkillInspection,
274
+ kind: SkillInvocationKind,
275
+ ): Result<readonly PlannedApplyOperation[]> {
276
+ const relativePath = `${skill.baseRelativePath}/agents/openai.yaml`;
277
+ const agentsDir = `${skill.baseRelativePath}/agents`;
278
+ const shouldExist = KIND_PROPERTIES[kind].hasCodexSidecar;
279
+ if (shouldExist) {
280
+ if (skill.openaiPolicy.type === "symlink")
281
+ return err({
282
+ code: "path_symlink",
283
+ message: `${relativePath} is a symlink; refusing to manage it.`,
284
+ });
285
+ if (skill.openaiPolicy.type === "file") {
286
+ if (isManagedOpenaiPolicyContent(skill.openaiPolicy.text))
287
+ return {
288
+ ok: true,
289
+ value: [
290
+ {
291
+ type: "skip",
292
+ relativePath,
293
+ description: "Codex openai.yaml",
294
+ reason: "Codex openai.yaml already current",
295
+ },
296
+ ],
297
+ };
298
+ if (isLegacyBareOpenaiPolicyContent(skill.openaiPolicy.text))
299
+ return {
300
+ ok: true,
301
+ value: [
302
+ {
303
+ type: "write",
304
+ relativePath,
305
+ description: "Codex openai.yaml",
306
+ content: MANAGED_OPENAI_POLICY,
307
+ shouldCreateParent: true,
308
+ },
309
+ ],
310
+ };
311
+ return err({
312
+ code: "non_managed_openai_policy",
313
+ message: `${relativePath} exists with non-managed content; resolve it manually before applying ${kind}.`,
314
+ });
315
+ }
316
+ if (skill.openaiPolicy.type !== "missing")
317
+ return err({ code: "path_not_file", message: `${relativePath} exists but is not a file.` });
318
+ return {
319
+ ok: true,
320
+ value: [
321
+ {
322
+ type: "write",
323
+ relativePath,
324
+ description: "Codex openai.yaml",
325
+ content: MANAGED_OPENAI_POLICY,
326
+ shouldCreateParent: true,
327
+ },
328
+ ],
329
+ };
330
+ }
331
+ if (skill.openaiPolicy.type === "missing")
332
+ return {
333
+ ok: true,
334
+ value: [
335
+ {
336
+ type: "skip",
337
+ relativePath,
338
+ description: "Codex openai.yaml",
339
+ reason: "Codex openai.yaml absent",
340
+ },
341
+ ],
342
+ };
343
+ if (skill.openaiPolicy.type === "symlink")
344
+ return err({
345
+ code: "path_symlink",
346
+ message: `${relativePath} is a symlink; refusing to delete it.`,
347
+ });
348
+ if (skill.openaiPolicy.type !== "file")
349
+ return err({ code: "path_not_file", message: `${relativePath} exists but is not a file.` });
350
+ if (!isManagedOpenaiPolicyContent(skill.openaiPolicy.text))
351
+ return err({
352
+ code: "non_managed_openai_policy",
353
+ message: `${relativePath} exists with non-managed content; resolve it manually before applying ${kind}.`,
354
+ });
355
+ return {
356
+ ok: true,
357
+ value: [
358
+ { type: "delete", relativePath, description: "Codex openai.yaml" },
359
+ {
360
+ type: "remove-empty-dir",
361
+ relativePath: agentsDir,
362
+ description: "empty skill agents directory",
363
+ },
364
+ ],
365
+ };
366
+ }
367
+
368
+ export function planPiSettingsOperation(
369
+ skillName: string,
370
+ kind: SkillInvocationKind,
371
+ settings: PiSettingsData,
372
+ ): Result<PlannedApplyOperation> {
373
+ const relativePath = ".pi/settings.json";
374
+ const entry = `-skills/${skillName}`;
375
+ const shouldExclude = KIND_PROPERTIES[kind].isPiExcluded;
376
+ const currentExclusions = settings.exclusions;
377
+ const hasEntry = currentExclusions.includes(entry);
378
+ if (shouldExclude && hasEntry)
379
+ return {
380
+ ok: true,
381
+ value: {
382
+ type: "skip",
383
+ relativePath,
384
+ description: "Pi settings",
385
+ reason: `${entry} already present`,
386
+ },
387
+ };
388
+ if (!shouldExclude && !hasEntry)
389
+ return {
390
+ ok: true,
391
+ value: { type: "skip", relativePath, description: "Pi settings", reason: `${entry} absent` },
392
+ };
393
+ const nextData: Record<string, unknown> = settings.data === undefined ? {} : { ...settings.data };
394
+ const nextSkills = shouldExclude
395
+ ? [...currentExclusions, entry]
396
+ : currentExclusions.filter((candidate) => candidate !== entry);
397
+ nextData.skills = nextSkills;
398
+ return {
399
+ ok: true,
400
+ value: {
401
+ type: "write",
402
+ relativePath,
403
+ description: "Pi settings",
404
+ content: `${JSON.stringify(nextData, null, 2)}\n`,
405
+ shouldCreateParent: settings.text === undefined,
406
+ },
407
+ };
408
+ }
409
+
410
+ export function hasDeletionPrompt(plan: SkillKindApplyPlan): boolean {
411
+ return plan.operations.some(isDeletionOperation);
412
+ }
413
+
414
+ export function deletionPrompt(plan: SkillKindApplyPlan): string {
415
+ const paths = plan.operations
416
+ .filter(isDeletionOperation)
417
+ .map((operation) => `- ${operation.relativePath}`)
418
+ .join("\n");
419
+ return `Apply ${plan.kind} to ${plan.skill} will delete managed artifacts:\n${paths}\nContinue?`;
420
+ }
421
+
422
+ function isDeletionOperation(operation: PlannedApplyOperation): boolean {
423
+ return (
424
+ operation.type === "delete" ||
425
+ operation.type === "delete-symlink" ||
426
+ operation.type === "remove-empty-dir"
427
+ );
428
+ }
429
+
430
+ export function plannedWrites(plan: SkillKindApplyPlan): readonly AregSkillKindTextWritePlan[] {
431
+ return plan.operations.flatMap((operation) =>
432
+ operation.type === "write"
433
+ ? [
434
+ {
435
+ relativePath: operation.relativePath,
436
+ content: operation.content,
437
+ description: operation.description,
438
+ createParent: operation.shouldCreateParent,
439
+ },
440
+ ]
441
+ : [],
442
+ );
443
+ }
444
+
445
+ export function plannedDeletes(plan: SkillKindApplyPlan): readonly AregSkillKindDeletePlan[] {
446
+ return plan.operations.flatMap((operation) =>
447
+ operation.type === "delete"
448
+ ? [{ relativePath: operation.relativePath, description: operation.description }]
449
+ : [],
450
+ );
451
+ }
452
+
453
+ export function plannedDeleteSymlinks(
454
+ plan: SkillKindApplyPlan,
455
+ ): readonly AregSkillKindDeleteSymlinkPlan[] {
456
+ return plan.operations.flatMap((operation) =>
457
+ operation.type === "delete-symlink"
458
+ ? [{ relativePath: operation.relativePath, description: operation.description }]
459
+ : [],
460
+ );
461
+ }
462
+
463
+ export function plannedRemoveEmptyDirs(
464
+ plan: SkillKindApplyPlan,
465
+ ): readonly AregSkillKindRemoveEmptyDirPlan[] {
466
+ return plan.operations.flatMap((operation) =>
467
+ operation.type === "remove-empty-dir"
468
+ ? [{ relativePath: operation.relativePath, description: operation.description }]
469
+ : [],
470
+ );
471
+ }
472
+
473
+ export function toApplyResult(
474
+ operation: PlannedApplyOperation,
475
+ hasAppliedOperation: boolean,
476
+ hasRemovedEmptyDir: boolean,
477
+ ): SkillKindApplyOperationResult {
478
+ return {
479
+ type: operation.type,
480
+ path: operation.relativePath,
481
+ ...(operation.type === "skip" ? { reason: operation.reason } : {}),
482
+ isApplied:
483
+ operation.type === "skip"
484
+ ? false
485
+ : operation.type === "remove-empty-dir"
486
+ ? hasRemovedEmptyDir
487
+ : hasAppliedOperation,
488
+ };
489
+ }
490
+
491
+ export function operationStatusesForPlans(
492
+ plans: readonly SkillKindApplyPlan[],
493
+ operationStatuses: readonly ProjectMutationOperationStatusRecord[],
494
+ ): readonly { skill: string; operations: readonly SkillKindApplyOperationStatusResult[] }[] {
495
+ const consumedStatusIndexes = new Set<number>();
496
+ return plans.map((plan) => ({
497
+ skill: plan.skill,
498
+ operations: plan.operations.map((operation) => {
499
+ if (operation.type === "skip")
500
+ return {
501
+ type: operation.type,
502
+ path: operation.relativePath,
503
+ description: operation.description,
504
+ status: "skipped" as const,
505
+ };
506
+ const statusIndex = operationStatuses.findIndex(
507
+ (status, index) =>
508
+ !consumedStatusIndexes.has(index) &&
509
+ status.type === operation.type &&
510
+ status.path === operation.relativePath &&
511
+ status.description === operation.description,
512
+ );
513
+ if (statusIndex === -1)
514
+ return {
515
+ type: operation.type,
516
+ path: operation.relativePath,
517
+ description: operation.description,
518
+ status: "not-attempted" as const,
519
+ };
520
+ consumedStatusIndexes.add(statusIndex);
521
+ return (
522
+ operationStatuses[statusIndex] ?? {
523
+ type: operation.type,
524
+ path: operation.relativePath,
525
+ description: operation.description,
526
+ status: "not-attempted" as const,
527
+ }
528
+ );
529
+ }),
530
+ }));
531
+ }
@@ -0,0 +1,55 @@
1
+ import type { Result } from "@nseng-ai/foundation/result";
2
+
3
+ import { transformSkillFrontmatter } from "./frontmatter.ts";
4
+ import {
5
+ DISABLE_MODEL_INVOCATION_KEY,
6
+ KIND_PROPERTIES,
7
+ USER_INVOCABLE_KEY,
8
+ type SkillInvocationKind,
9
+ } from "./skill-kind-inference.ts";
10
+ import type { PlannedApplyOperation } from "./skill-kind-apply-plan.ts";
11
+
12
+ export function planFrontmatterOperation(
13
+ baseRelativePath: string,
14
+ text: string,
15
+ kind: SkillInvocationKind,
16
+ ): Result<PlannedApplyOperation> {
17
+ const relativePath = `${baseRelativePath}/SKILL.md`;
18
+ // Inference parses frontmatter into normalized key/value facts, while apply planning
19
+ // rewrites source text and must preserve delimiter bounds, line endings, unrelated
20
+ // keys, and body text. Keep the parse and rewrite paths separate even though both
21
+ // validate frontmatter before changing managed keys.
22
+ const transformed = transformSkillFrontmatter(text, relativePath, desiredFrontmatter(kind));
23
+ if (!transformed.ok) return transformed;
24
+ if (transformed.value === text)
25
+ return {
26
+ ok: true,
27
+ value: {
28
+ type: "skip",
29
+ relativePath,
30
+ description: "SKILL.md",
31
+ reason: "SKILL.md frontmatter already current",
32
+ },
33
+ };
34
+ return {
35
+ ok: true,
36
+ value: {
37
+ type: "write",
38
+ relativePath,
39
+ description: "SKILL.md",
40
+ content: transformed.value,
41
+ shouldCreateParent: false,
42
+ },
43
+ };
44
+ }
45
+
46
+ export function desiredFrontmatter(
47
+ kind: SkillInvocationKind,
48
+ ): Readonly<Record<string, string | undefined>> {
49
+ return {
50
+ [DISABLE_MODEL_INVOCATION_KEY]: KIND_PROPERTIES[kind].shouldDisableModelInvocation
51
+ ? "true"
52
+ : undefined,
53
+ [USER_INVOCABLE_KEY]: kind === "ambient-only" ? "false" : undefined,
54
+ };
55
+ }