@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,525 @@
1
+ import {
2
+ failure,
3
+ negative,
4
+ ok,
5
+ requireInteractiveOrUsageError,
6
+ type ClinkrExit,
7
+ ClinkrGroup,
8
+ type RenderCapabilities,
9
+ } from "@nseng-ai/clinkr";
10
+ import { renderTextTable, type TextTableColumn } from "@nseng-ai/foundation/text-table";
11
+ import { z } from "zod";
12
+
13
+ import type { AregCliContext } from "../context.ts";
14
+ import { isPathStateError } from "./file-state.ts";
15
+ import { inspectSkillKindProject } from "./project-inspection.ts";
16
+ import { inspectResolvedProjectGitRoot } from "./project-resolution.ts";
17
+ import {
18
+ applyProjectMutationPlan,
19
+ PROJECT_MUTATION_OPERATION_STATUSES,
20
+ } from "./project-mutations.ts";
21
+ import {
22
+ APPLY_OPERATION_TYPES,
23
+ APPLY_STATUS_OPERATION_TYPES,
24
+ buildSkillKindApplyPlan,
25
+ deletionPrompt,
26
+ hasDeletionPrompt,
27
+ inspectionAfterPlannedApply,
28
+ operationStatusesForPlans,
29
+ plannedDeletes,
30
+ plannedDeleteSymlinks,
31
+ plannedRemoveEmptyDirs,
32
+ plannedWrites,
33
+ toApplyResult,
34
+ type SkillKindApplyPlan,
35
+ } from "./skill-kind-apply-plan.ts";
36
+ import {
37
+ renderSkillFind,
38
+ runSkillFind,
39
+ skillFindRequestSchema,
40
+ skillFindResultSchema,
41
+ } from "./skill-find.ts";
42
+ import {
43
+ buildSkillKindRecords,
44
+ INFERRED_SKILL_INVOCATION_KINDS,
45
+ MODEL_INVOCATION_STATUSES,
46
+ NATIVE_DIRECT_STATUSES,
47
+ PI_EXTENSION_STATUSES,
48
+ SKILL_INVOCATION_KINDS,
49
+ type SkillKindProjectInspection,
50
+ type SkillKindRecord,
51
+ } from "./skill-kind-inference.ts";
52
+
53
+ interface ResolvedProjectInspection {
54
+ projectDir: string;
55
+ inspection: SkillKindProjectInspection;
56
+ }
57
+
58
+ const skillKindArtifactFactsSchema = z.object({
59
+ disableModelInvocation: z.boolean(),
60
+ codexSidecar: z.boolean(),
61
+ userInvocableKeyPresent: z.boolean(),
62
+ userInvocableFalse: z.boolean(),
63
+ piExcluded: z.boolean(),
64
+ agentsMirror: z.boolean(),
65
+ claudeMirror: z.boolean(),
66
+ });
67
+
68
+ const skillKindReplacementSchema = z.object({
69
+ verified: z.boolean(),
70
+ surface: z.string().optional(),
71
+ label: z.string(),
72
+ evidence: z.string().optional(),
73
+ advice: z.string().optional(),
74
+ });
75
+
76
+ const skillKindRecordSchema = z.object({
77
+ skill: z.string(),
78
+ kind: z.enum(INFERRED_SKILL_INVOCATION_KINDS),
79
+ modelInvocation: z.enum(MODEL_INVOCATION_STATUSES),
80
+ nativeDirect: z.enum(NATIVE_DIRECT_STATUSES),
81
+ piExtension: z.enum(PI_EXTENSION_STATUSES),
82
+ artifacts: skillKindArtifactFactsSchema,
83
+ replacement: skillKindReplacementSchema,
84
+ notes: z.array(z.string()),
85
+ });
86
+
87
+ const skillKindApplyOperationResultSchema = z.object({
88
+ type: z.enum(APPLY_OPERATION_TYPES),
89
+ path: z.string(),
90
+ reason: z.string().optional(),
91
+ isApplied: z.boolean(),
92
+ });
93
+
94
+ const skillKindApplyOperationStatusSchema = z.object({
95
+ type: z.enum(APPLY_STATUS_OPERATION_TYPES),
96
+ path: z.string(),
97
+ description: z.string(),
98
+ status: z.enum(PROJECT_MUTATION_OPERATION_STATUSES),
99
+ error: z.unknown().optional(),
100
+ });
101
+
102
+ const skillKindApplySkillResultSchema = z.object({
103
+ skill: z.string(),
104
+ operations: z.array(
105
+ z.union([skillKindApplyOperationResultSchema, skillKindApplyOperationStatusSchema]),
106
+ ),
107
+ });
108
+
109
+ export const skillKindListRequestSchema = z.object({
110
+ path: z
111
+ .string()
112
+ .default(".")
113
+ .describe("Project directory or subdirectory to inspect (default: current directory)."),
114
+ });
115
+
116
+ export const skillKindShowRequestSchema = z.object({
117
+ path: z
118
+ .string()
119
+ .default(".")
120
+ .describe("Project directory or subdirectory to inspect (default: current directory)."),
121
+ skill: z.string().describe("Installed skill name or path-like skill spec."),
122
+ });
123
+
124
+ export const skillKindApplyRequestSchema = z.object({
125
+ path: z
126
+ .string()
127
+ .default(".")
128
+ .describe("Project directory or subdirectory to mutate (default: current directory)."),
129
+ dryRun: z.boolean().default(false).describe("Show planned edits without writing files."),
130
+ yes: z.boolean().default(false).describe("Approve deletion prompts for managed artifacts."),
131
+ kind: z.enum(SKILL_INVOCATION_KINDS).describe("Desired skill invocation kind."),
132
+ skills: z.array(z.string()).min(1).describe("Installed skill names or path-like skill specs."),
133
+ });
134
+
135
+ export const skillKindListResultSchema = z.object({
136
+ projectDir: z.string(),
137
+ skills: z.array(skillKindRecordSchema),
138
+ });
139
+
140
+ export const skillKindShowResultSchema = z.object({
141
+ projectDir: z.string(),
142
+ skill: skillKindRecordSchema,
143
+ });
144
+
145
+ export const skillKindApplyResultSchema = z.object({
146
+ projectDir: z.string(),
147
+ kind: z.enum(SKILL_INVOCATION_KINDS),
148
+ dryRun: z.boolean(),
149
+ skills: z.array(skillKindApplySkillResultSchema),
150
+ mutationFailed: z.boolean().optional(),
151
+ operations: z.array(skillKindApplyOperationStatusSchema).optional(),
152
+ });
153
+
154
+ export type SkillKindListRequest = z.infer<typeof skillKindListRequestSchema>;
155
+ export type SkillKindShowRequest = z.infer<typeof skillKindShowRequestSchema>;
156
+ export type SkillKindApplyRequest = z.infer<typeof skillKindApplyRequestSchema>;
157
+ export type SkillKindRecordResult = z.infer<typeof skillKindRecordSchema>;
158
+ export type SkillKindListResult = z.infer<typeof skillKindListResultSchema>;
159
+ export type SkillKindShowResult = z.infer<typeof skillKindShowResultSchema>;
160
+ export type SkillKindApplyResult = z.infer<typeof skillKindApplyResultSchema>;
161
+
162
+ export function buildSkillGroup(): ClinkrGroup<AregCliContext> {
163
+ const skillGroup = new ClinkrGroup<AregCliContext>({
164
+ name: "skill",
165
+ description: "Inspect and reconcile installed skill invocation metadata.",
166
+ });
167
+ skillGroup.command({
168
+ name: "find",
169
+ description: "Find a managed skill by exact name.",
170
+ schema: skillFindRequestSchema,
171
+ positionals: { skill: { position: 0 } },
172
+ resultSchema: skillFindResultSchema,
173
+ handler: runSkillFind,
174
+ renderHuman: renderSkillFind,
175
+ });
176
+ skillGroup.command({
177
+ name: "list",
178
+ description: "List installed skill invocation status.",
179
+ schema: skillKindListRequestSchema,
180
+ resultSchema: skillKindListResultSchema,
181
+ handler: runSkillKindList,
182
+ renderHuman: renderSkillKindList,
183
+ });
184
+ skillGroup.command({
185
+ name: "show",
186
+ description: "Show invocation status for one installed skill.",
187
+ schema: skillKindShowRequestSchema,
188
+ positionals: { skill: { position: 0 } },
189
+ resultSchema: skillKindShowResultSchema,
190
+ handler: runSkillKindShow,
191
+ renderHuman: renderSkillKindShow,
192
+ });
193
+ skillGroup.command({
194
+ name: "apply",
195
+ description:
196
+ "Apply the managed artifacts for a skill invocation kind. This reconciles managed artifacts to the requested kind. The unlisted kind additionally deletes the .agents/skills and .claude/skills mirror symlinks so the skill disappears from every harness typeahead. It is not a historical undo system; use git to roll back exact previous file contents.",
197
+ schema: skillKindApplyRequestSchema,
198
+ positionals: { kind: { position: 0 }, skills: { position: 1 } },
199
+ options: { yes: { short: "-y" } },
200
+ resultSchema: skillKindApplyResultSchema,
201
+ handler: runSkillKindApply,
202
+ renderHuman: renderSkillKindApply,
203
+ });
204
+ return skillGroup;
205
+ }
206
+
207
+ export async function runSkillKindList(
208
+ ctx: AregCliContext,
209
+ request: SkillKindListRequest,
210
+ ): Promise<ClinkrExit<SkillKindListResult>> {
211
+ const resolved = await inspectResolvedProject(ctx, request.path);
212
+ if (resolved.type === "error") return failure("project-inspection-failed", resolved.message);
213
+ const records = buildSkillKindRecords(resolved.value.inspection);
214
+ if (!records.ok)
215
+ return skillKindRecordsFailure(records.error, {
216
+ projectDir: resolved.value.projectDir,
217
+ skills: [],
218
+ });
219
+ return ok({
220
+ projectDir: resolved.value.projectDir,
221
+ skills: records.value.map(toSkillKindRecordResult),
222
+ });
223
+ }
224
+
225
+ export async function runSkillKindShow(
226
+ ctx: AregCliContext,
227
+ request: SkillKindShowRequest,
228
+ ): Promise<ClinkrExit<SkillKindShowResult>> {
229
+ const resolved = await inspectResolvedProject(ctx, request.path);
230
+ if (resolved.type === "error") return failure("project-inspection-failed", resolved.message);
231
+ const resolvedSkill = await ctx.project.resolveSkillKindSpec({
232
+ projectDir: resolved.value.projectDir,
233
+ spec: request.skill,
234
+ cwd: ctx.cwd,
235
+ env: ctx.env,
236
+ });
237
+ if (resolvedSkill.type === "error")
238
+ return failure("skill-resolution-failed", resolvedSkill.error.message);
239
+ const records = buildSkillKindRecords(resolved.value.inspection);
240
+ if (!records.ok)
241
+ return skillKindRecordsFailure(
242
+ records.error,
243
+ emptyShowResult(resolved.value.projectDir, resolvedSkill.skillName),
244
+ );
245
+ const record = records.value.find((candidate) => candidate.skill === resolvedSkill.skillName);
246
+ if (record === undefined) {
247
+ return negative(`Managed skill not found: ${request.skill}`, {
248
+ data: emptyShowResult(resolved.value.projectDir, resolvedSkill.skillName),
249
+ });
250
+ }
251
+ return ok({ projectDir: resolved.value.projectDir, skill: toSkillKindRecordResult(record) });
252
+ }
253
+
254
+ export async function runSkillKindApply(
255
+ ctx: AregCliContext,
256
+ request: SkillKindApplyRequest,
257
+ ): Promise<ClinkrExit<SkillKindApplyResult>> {
258
+ const resolved = await inspectResolvedProject(ctx, request.path);
259
+ if (resolved.type === "error") return failure("project-inspection-failed", resolved.message);
260
+ const projectDir = resolved.value.projectDir;
261
+ const plans: SkillKindApplyPlan[] = [];
262
+ let planningInspection = resolved.value.inspection;
263
+ for (const spec of request.skills) {
264
+ const resolvedSkill = await ctx.project.resolveSkillKindSpec({
265
+ projectDir,
266
+ spec,
267
+ cwd: ctx.cwd,
268
+ env: ctx.env,
269
+ });
270
+ if (resolvedSkill.type === "error")
271
+ return failure("skill-resolution-failed", resolvedSkill.error.message);
272
+ const plan = buildSkillKindApplyPlan(planningInspection, resolvedSkill.skillName, request.kind);
273
+ if (!plan.ok) return failure("skill-plan-failed", plan.error.message);
274
+ plans.push(plan.value);
275
+ planningInspection = inspectionAfterPlannedApply(planningInspection, plan.value);
276
+ }
277
+ if (request.dryRun) {
278
+ return ok({
279
+ projectDir: projectDir,
280
+ kind: request.kind,
281
+ dryRun: request.dryRun,
282
+ skills: plans.map((plan) => ({
283
+ skill: plan.skill,
284
+ operations: plan.operations.map((operation) => toApplyResult(operation, false, false)),
285
+ })),
286
+ });
287
+ }
288
+ if (!request.yes) {
289
+ for (const plan of plans) {
290
+ if (!hasDeletionPrompt(plan)) continue;
291
+ const gate = requireInteractiveOrUsageError(ctx.interaction, {
292
+ message: "Deleting managed skill artifacts requires --yes when non-interactive.",
293
+ missingFlag: "--yes",
294
+ howToSupply: "Pass --yes (or -y) to apply deletion prompts without prompting.",
295
+ });
296
+ if (gate) return gate;
297
+ const confirmed = await ctx.interaction.confirm({
298
+ message: deletionPrompt(plan),
299
+ defaultAnswer: "no",
300
+ });
301
+ if (confirmed.type === "aborted") return failure("aborted", "Aborted!");
302
+ if (confirmed.type === "declined")
303
+ return ok(
304
+ {
305
+ projectDir: projectDir,
306
+ kind: request.kind,
307
+ dryRun: request.dryRun,
308
+ skills: [],
309
+ },
310
+ { human: `Declined to apply ${request.kind} to ${plan.skill}.` },
311
+ );
312
+ }
313
+ }
314
+ const applyResult = await applyProjectMutationPlan({
315
+ ctx,
316
+ projectDir,
317
+ policy: "skill-kind",
318
+ writes: plans.flatMap(plannedWrites),
319
+ deletes: plans.flatMap(plannedDeletes),
320
+ deleteSymlinks: plans.flatMap(plannedDeleteSymlinks),
321
+ removeEmptyDirs: plans.flatMap(plannedRemoveEmptyDirs),
322
+ });
323
+ if (!applyResult.ok) {
324
+ return failure("skill-kind-apply-failed", applyResult.error.message, {
325
+ projectDir: projectDir,
326
+ kind: request.kind,
327
+ dryRun: false,
328
+ mutationFailed: true,
329
+ operations: [...applyResult.operationStatuses],
330
+ skills: operationStatusesForPlans(plans, applyResult.operationStatuses).map((skill) => ({
331
+ skill: skill.skill,
332
+ operations: [...skill.operations],
333
+ })),
334
+ });
335
+ }
336
+ return ok({
337
+ projectDir: projectDir,
338
+ kind: request.kind,
339
+ dryRun: request.dryRun,
340
+ skills: plans.map((plan) => ({
341
+ skill: plan.skill,
342
+ operations: plan.operations.map((operation) =>
343
+ toApplyResult(
344
+ operation,
345
+ true,
346
+ applyResult.appliedPaths.removedEmptyDir.includes(operation.relativePath),
347
+ ),
348
+ ),
349
+ })),
350
+ });
351
+ }
352
+
353
+ function skillKindRecordsFailure<T>(
354
+ error: { code: string; message: string },
355
+ negativeData: T,
356
+ ): ClinkrExit<T> {
357
+ if (isPathStateError(error)) return negative(error.message, { data: negativeData });
358
+ return failure("skill-records-invalid", error.message);
359
+ }
360
+
361
+ export function renderSkillKindList(
362
+ result: SkillKindListResult,
363
+ caps: RenderCapabilities = { canEmitAnsi: false },
364
+ ): string {
365
+ if (result.skills.length === 0) return "No managed skills found.";
366
+ const includeNotes = result.skills.some((record) => record.notes.length > 0);
367
+ const columns: TextTableColumn[] = [
368
+ { header: "SKILL", style: "bold-cyan" },
369
+ { header: "KIND" },
370
+ { header: "MODEL" },
371
+ { header: "NATIVE" },
372
+ { header: "PI" },
373
+ ];
374
+ if (includeNotes) columns.push({ header: "NOTES", style: "dim" });
375
+ return renderTextTable({
376
+ columns,
377
+ rows: result.skills.map((record) => {
378
+ const base = [
379
+ record.skill,
380
+ record.kind,
381
+ record.modelInvocation,
382
+ record.nativeDirect,
383
+ record.piExtension,
384
+ ];
385
+ if (includeNotes) base.push(record.notes.join("; "));
386
+ return base;
387
+ }),
388
+ canEmitAnsi: caps.canEmitAnsi,
389
+ shouldDrawRule: true,
390
+ headerStyle: "bold-cyan",
391
+ });
392
+ }
393
+
394
+ export function renderSkillKindShow(result: SkillKindShowResult): string {
395
+ const record = result.skill;
396
+ const lines = [
397
+ `Skill: ${record.skill}`,
398
+ `Kind: ${record.kind}`,
399
+ `model-invocation: ${record.modelInvocation}`,
400
+ `native-direct: ${record.nativeDirect}`,
401
+ `pi-extension: ${record.piExtension}`,
402
+ "Artifacts:",
403
+ `- disable-model-invocation: ${presence(record.artifacts.disableModelInvocation)}`,
404
+ `- agents/openai.yaml: ${presence(record.artifacts.codexSidecar)}`,
405
+ `- user-invocable:false: ${presence(record.artifacts.userInvocableFalse)}`,
406
+ `- Pi skill exclusion: ${presence(record.artifacts.piExcluded)}`,
407
+ `- .agents/skills mirror: ${presence(record.artifacts.agentsMirror)}`,
408
+ `- .claude/skills mirror: ${presence(record.artifacts.claudeMirror)}`,
409
+ `- Pi replacement: ${record.replacement.label}`,
410
+ ];
411
+ if (record.notes.length > 0) {
412
+ lines.push("Notes:");
413
+ for (const note of record.notes) lines.push(`- ${note}`);
414
+ }
415
+ return lines.join("\n");
416
+ }
417
+
418
+ export function renderSkillKindApply(result: SkillKindApplyResult): string {
419
+ const lines: string[] = [];
420
+ for (const skill of result.skills) {
421
+ lines.push(`Applying ${result.kind} to ${skill.skill}...`);
422
+ for (const operation of skill.operations) {
423
+ const rendered = renderApplyOperation(operation, result.dryRun);
424
+ if (rendered !== undefined) lines.push(rendered);
425
+ }
426
+ }
427
+ return lines.join("\n");
428
+ }
429
+
430
+ async function inspectResolvedProject(
431
+ ctx: AregCliContext,
432
+ requestPath: string,
433
+ ): Promise<
434
+ | { type: "ok"; value: ResolvedProjectInspection }
435
+ | { type: "error"; message: string; projectDir: string }
436
+ > {
437
+ const resolved = await inspectResolvedProjectGitRoot(ctx, requestPath, inspectSkillKindProject);
438
+ if (resolved.type === "error") return resolved;
439
+ if (resolved.projectDir === resolved.targetInspection.projectDir) {
440
+ return {
441
+ type: "ok",
442
+ value: {
443
+ projectDir: resolved.targetInspection.projectDir,
444
+ inspection: resolved.targetInspection,
445
+ },
446
+ };
447
+ }
448
+ const rootInspection = await inspectSkillKindProject(ctx, resolved.projectDir);
449
+ return { type: "ok", value: { projectDir: resolved.projectDir, inspection: rootInspection } };
450
+ }
451
+
452
+ function toSkillKindRecordResult(record: SkillKindRecord): SkillKindRecordResult {
453
+ return {
454
+ skill: record.skill,
455
+ kind: record.kind,
456
+ modelInvocation: record.modelInvocation,
457
+ nativeDirect: record.nativeDirect,
458
+ piExtension: record.piExtension,
459
+ artifacts: {
460
+ disableModelInvocation: record.artifacts.isModelInvocationDisabled,
461
+ codexSidecar: record.artifacts.hasCodexSidecar,
462
+ userInvocableKeyPresent: record.artifacts.hasUserInvocableKey,
463
+ userInvocableFalse: record.artifacts.isUserInvocableFalse,
464
+ piExcluded: record.artifacts.isPiExcluded,
465
+ agentsMirror: record.artifacts.hasAgentsMirror,
466
+ claudeMirror: record.artifacts.hasClaudeMirror,
467
+ },
468
+ replacement: {
469
+ verified: record.replacement.verified,
470
+ surface: record.replacement.surface,
471
+ label: record.replacement.label,
472
+ evidence: record.replacement.evidence,
473
+ advice: record.replacement.advice,
474
+ },
475
+ notes: [...record.notes],
476
+ };
477
+ }
478
+
479
+ function renderApplyOperation(
480
+ operation: SkillKindApplyResult["skills"][number]["operations"][number],
481
+ dryRun: boolean,
482
+ ): string | undefined {
483
+ if (!("isApplied" in operation)) return undefined;
484
+ switch (operation.type) {
485
+ case "write":
486
+ return `${dryRun ? "Would write" : "Wrote"} ${operation.path}`;
487
+ case "skip":
488
+ return `${dryRun ? "Would skip" : "Skipped"} ${operation.path}: ${operation.reason ?? "already current"}`;
489
+ case "delete":
490
+ return `${dryRun ? "Would delete" : "Deleted"} ${operation.path}`;
491
+ case "delete-symlink":
492
+ return `${dryRun ? "Would delete symlink" : "Deleted symlink"} ${operation.path}`;
493
+ case "remove-empty-dir":
494
+ if (dryRun) return `Would remove ${operation.path} if empty`;
495
+ return operation.isApplied ? `Removed ${operation.path}` : undefined;
496
+ }
497
+ }
498
+
499
+ function presence(value: boolean): "present" | "absent" {
500
+ return value ? "present" : "absent";
501
+ }
502
+
503
+ function emptyShowResult(projectDir: string, skillName: string): SkillKindShowResult {
504
+ return {
505
+ projectDir: projectDir,
506
+ skill: {
507
+ skill: skillName,
508
+ kind: "inconsistent",
509
+ modelInvocation: "enabled",
510
+ nativeDirect: "enabled",
511
+ piExtension: "n/a",
512
+ artifacts: {
513
+ disableModelInvocation: false,
514
+ codexSidecar: false,
515
+ userInvocableKeyPresent: false,
516
+ userInvocableFalse: false,
517
+ piExcluded: false,
518
+ agentsMirror: false,
519
+ claudeMirror: false,
520
+ },
521
+ replacement: { verified: false, label: "replacement-missing" },
522
+ notes: [],
523
+ },
524
+ };
525
+ }
@@ -0,0 +1,126 @@
1
+ import type { AregErrorInfo, AregPathState } from "../gateways.ts";
2
+
3
+ export function expectedAgentsSkillSymlinkTarget(skillName: string): string {
4
+ return `../../skills/${skillName}`;
5
+ }
6
+
7
+ export function expectedClaudeSkillSymlinkTarget(skillName: string): string {
8
+ return `../../.agents/skills/${skillName}`;
9
+ }
10
+
11
+ export function isAgentsSkillMirror(pathState: AregPathState, skillName: string): boolean {
12
+ return (
13
+ pathState.type === "symlink" && pathState.target === expectedAgentsSkillSymlinkTarget(skillName)
14
+ );
15
+ }
16
+
17
+ export function isClaudeSkillMirror(pathState: AregPathState, skillName: string): boolean {
18
+ return (
19
+ pathState.type === "symlink" && pathState.target === expectedClaudeSkillSymlinkTarget(skillName)
20
+ );
21
+ }
22
+
23
+ export function agentsSkillMirrorRelativePath(skillName: string): string {
24
+ return `.agents/skills/${skillName}`;
25
+ }
26
+
27
+ export function claudeSkillMirrorRelativePath(skillName: string): string {
28
+ return `.claude/skills/${skillName}`;
29
+ }
30
+
31
+ export type SkillMirrorKind = "agents" | "claude";
32
+
33
+ export interface SkillMirrorRelativePathInfo {
34
+ mirrorKind: SkillMirrorKind;
35
+ skillName: string;
36
+ expectedTarget: string;
37
+ }
38
+
39
+ /**
40
+ * Returns parsed mirror information for an exact areg-managed skill mirror
41
+ * location, or undefined when the path is not a valid mirror path.
42
+ */
43
+ export function parseSkillMirrorRelativePath(
44
+ relativePath: string,
45
+ ): SkillMirrorRelativePathInfo | undefined {
46
+ const parts = relativePath.split("/");
47
+ if (parts.length !== 3 || parts[1] !== "skills") return undefined;
48
+ const skillName = parts[2];
49
+ if (skillName === undefined || !isPlainSkillNameSegment(skillName)) return undefined;
50
+ if (parts[0] === ".agents")
51
+ return {
52
+ mirrorKind: "agents",
53
+ skillName,
54
+ expectedTarget: expectedAgentsSkillSymlinkTarget(skillName),
55
+ };
56
+ if (parts[0] === ".claude")
57
+ return {
58
+ mirrorKind: "claude",
59
+ skillName,
60
+ expectedTarget: expectedClaudeSkillSymlinkTarget(skillName),
61
+ };
62
+ return undefined;
63
+ }
64
+
65
+ /**
66
+ * Returns true when the relative path is exactly an areg-managed skill mirror
67
+ * location: `.agents/skills/<name>` or `.claude/skills/<name>` with a plain
68
+ * single-segment skill name.
69
+ */
70
+ export function isSkillMirrorRelativePath(relativePath: string): boolean {
71
+ return expectedMirrorTarget(relativePath) !== undefined;
72
+ }
73
+
74
+ /**
75
+ * Returns the convention symlink target for a skill mirror relative path, or
76
+ * undefined when the path is not a valid skill mirror location.
77
+ */
78
+ export function expectedMirrorTarget(relativePath: string): string | undefined {
79
+ return parseSkillMirrorRelativePath(relativePath)?.expectedTarget;
80
+ }
81
+
82
+ /**
83
+ * Classifies a skill-mirror symlink deletion target against the delete contract,
84
+ * returning the matching error or undefined when the target is a deletable
85
+ * convention symlink. An undefined `state` is treated as missing so fixture-backed
86
+ * callers share the same cascade as filesystem inspection.
87
+ */
88
+ export function classifySkillMirrorSymlinkState(
89
+ relativePath: string,
90
+ state: AregPathState | undefined,
91
+ description: string,
92
+ target: string,
93
+ ): AregErrorInfo | undefined {
94
+ const expectedTarget = expectedMirrorTarget(relativePath);
95
+ if (expectedTarget === undefined)
96
+ return {
97
+ code: "skill-kind-delete-symlink-refused",
98
+ message: `Refusing to delete ${description}: ${relativePath} is not a managed skill mirror path.`,
99
+ };
100
+ if (state === undefined || state.type === "missing")
101
+ return {
102
+ code: "skill-kind-delete-symlink-missing",
103
+ message: `${description} at ${target} does not exist.`,
104
+ };
105
+ if (state.type !== "symlink")
106
+ return {
107
+ code: "skill-kind-delete-symlink-not-symlink",
108
+ message: `${description} at ${target} is not a symlink; refusing to delete it.`,
109
+ };
110
+ if (state.target !== expectedTarget)
111
+ return {
112
+ code: "skill-kind-delete-symlink-wrong-target",
113
+ message: `${description} at ${target} points to ${state.target}, expected ${expectedTarget}; refusing to delete it.`,
114
+ };
115
+ return undefined;
116
+ }
117
+
118
+ function isPlainSkillNameSegment(segment: string): boolean {
119
+ return (
120
+ segment.length > 0 &&
121
+ segment !== "." &&
122
+ segment !== ".." &&
123
+ !segment.includes("/") &&
124
+ !segment.includes("\\")
125
+ );
126
+ }