1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Sink from "effect/Sink";
4import * as Stream from "effect/Stream";
5import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
6
7import { discoverGrokSkills } from "./skills.ts";
8
9const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills });
10
11const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array<string | undefined>) =>
12 ChildProcessSpawner.make((command) => {
13 spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);
14 return Effect.succeed(
15 ChildProcessSpawner.makeHandle({
16 pid: ChildProcessSpawner.ProcessId(1),
17 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
18 isRunning: Effect.succeed(false),
19 kill: () => Effect.void,
20 unref: Effect.succeed(Effect.void),
21 stdin: Sink.drain,
22 stdout: Stream.encodeText(Stream.make(stdout)),
23 stderr: Stream.empty,
24 all: Stream.empty,
25 getInputFd: () => Sink.drain,
26 getOutputFd: () => Stream.empty,
27 }),
28 );
29 });
30
31describe("discoverGrokSkills", () => {
32 it.effect("maps inspect entries onto provider skills, sorted by name", () =>
33 Effect.gen(function* () {
34 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
35
36 expect(skills).toEqual([
37 {
38 name: "deploy",
39 description: "Deploy the app.",
40 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
41 scope: "plugin",
42 enabled: true,
43 },
44 {
45 name: "writing-docs",
46 description: "Write user docs.",
47 path: "/home/dev/.grok/skills/writing-docs/SKILL.md",
48 scope: "user",
49 enabled: true,
50 },
51 ]);
52 }).pipe(
53 Effect.provideService(
54 ChildProcessSpawner.ChildProcessSpawner,
55 makeInspectSpawner(
56 inspectPayload([
57 {
58 name: "writing-docs",
59 description: "Write user docs.",
60 source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" },
61 userInvocable: true,
62 },
63 {
64 name: "deploy",
65 description: "Deploy the app.",
66 source: {
67 type: "plugin",
68 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
69 },
70 userInvocable: true,
71 },
72 ]),
73 ),
74 ),
75 ),
76 );
77
78 it.effect("disables skills the CLI marks as not user-invocable", () =>
79 Effect.gen(function* () {
80 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
81
82 expect(skills).toEqual([
83 {
84 name: "internal-helper",
85 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
86 scope: "bundled",
87 enabled: false,
88 },
89 ]);
90 }).pipe(
91 Effect.provideService(
92 ChildProcessSpawner.ChildProcessSpawner,
93 makeInspectSpawner(
94 inspectPayload([
95 {
96 name: "internal-helper",
97 source: {
98 type: "bundled",
99 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
100 },
101 userInvocable: false,
102 },
103 ]),
104 ),
105 ),
106 ),
107 );
108
109 it.effect("skips entries without a name or a filesystem path", () =>
110 Effect.gen(function* () {
111 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {});
112 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
113 }).pipe(
114 Effect.provideService(
115 ChildProcessSpawner.ChildProcessSpawner,
116 makeInspectSpawner(
117 inspectPayload([
118 { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } },
119 { name: "no-path", source: { type: "user" } },
120 { name: "no-source" },
121 "not-an-object",
122 { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } },
123 ]),
124 ),
125 ),
126 ),
127 );
128
129 it.effect("rejects malformed or unexpected output as a decode failure", () =>
130 Effect.gen(function* () {
131 for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) {
132 const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe(
133 Effect.flip,
134 Effect.provideService(
135 ChildProcessSpawner.ChildProcessSpawner,
136 makeInspectSpawner(stdout),
137 ),
138 );
139 expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" });
140 }
141 }),
142 );
143
144 it.effect("spawns in the configured cwd and rejects a failed probe", () => {
145 const spawnCwds: Array<string | undefined> = [];
146 const stdout = inspectPayload([
147 {
148 name: "kept",
149 source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" },
150 },
151 ]);
152
153 return Effect.gen(function* () {
154 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe(
155 Effect.provideService(
156 ChildProcessSpawner.ChildProcessSpawner,
157 makeInspectSpawner(stdout, 0, spawnCwds),
158 ),
159 );
160
161 expect(spawnCwds).toEqual(["/workspaces/demo"]);
162 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
163
164 const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe(
165 Effect.result,
166 Effect.provideService(
167 ChildProcessSpawner.ChildProcessSpawner,
168 makeInspectSpawner(stdout, 1),
169 ),
170 );
171 expect(failed._tag).toBe("Failure");
172 });
173 });
174});