1import type { ServerProviderSkill } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import type * as PlatformError from "effect/PlatformError";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import { parse as parseYamlDocument } from "yaml";
9
10/**
11 * The home directory the agent expands `~` against, matching Python's
12 * `os.path.expanduser` in the launch environment T3 hands the process:
13 * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME`
14 * elsewhere. Values are used verbatim; a path may contain spaces.
15 */
16export function resolveAntigravityUserHome(
17 platform: NodeJS.Platform,
18 environment: NodeJS.ProcessEnv,
19 home: string,
20): string {
21 if (platform === "win32") {
22 if (environment.USERPROFILE) return environment.USERPROFILE;
23 if (environment.HOMEDRIVE && environment.HOMEPATH) {
24 return `${environment.HOMEDRIVE}${environment.HOMEPATH}`;
25 }
26 return home;
27 }
28 return environment.HOME || home;
29}
30
31/**
32 * The agent's two user-global skill directories under a Gemini home, in
33 * native precedence order: `config/skills` is shared with the Antigravity IDE
34 * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs
35 * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a
36 * private profile, so the profile links these back to the user's `~/.gemini`.
37 * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a
38 * project directory.
39 */
40export function antigravityUserSkillDirectories(
41 path: Path.Path,
42 geminiHome: string,
43): readonly [configSkills: string, cliSkills: string] {
44 return [
45 path.join(geminiHome, "config", "skills"),
46 path.join(geminiHome, "antigravity-cli", "skills"),
47 ];
48}
49
50const MAX_SKILL_BYTES = 1_000_000;
51const MAX_SCAN_BYTES = 8_000_000;
52const MAX_SCAN_ENTRIES = 10_000;
53
54const SkillFrontmatter = Schema.Struct({
55 name: Schema.optional(Schema.NullOr(Schema.String)),
56 description: Schema.optional(Schema.NullOr(Schema.String)),
57});
58const decodeSkillFrontmatter = Schema.decodeUnknownSync(SkillFrontmatter);
59
60export class AntigravitySkillsProbeError extends Schema.TaggedError<AntigravitySkillsProbeError>()(
61 "AntigravitySkillsProbeError",
62 {
63 reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]),
64 path: Schema.String,
65 cause: Schema.optional(Schema.Defect()),
66 },
67) {
68 override get message(): string {
69 return this.reason === "scan-budget-exhausted"
70 ? `Antigravity skill discovery exceeded its scan limit at '${this.path}'.`
71 : `Antigravity could not read skills at '${this.path}'.`;
72 }
73}
74
75interface ScanBudget {
76 remainingBytes: number;
77 remainingEntries: number;
78}
79
80const readIfPresent = <A, R>(
81 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
82 path: string,
83) =>
84 effect.pipe(
85 Effect.catchTags({
86 PlatformError: (cause) =>
87 cause.reason._tag === "NotFound"
88 ? Effect.undefined
89 : Effect.fail(
90 new AntigravitySkillsProbeError({ reason: "filesystem-error", path, cause }),
91 ),
92 }),
93 );
94
95function parseSkillFrontmatter(contents: string, fileName: string) {
96 const start = contents.indexOf("---");
97 if (start === -1) return undefined;
98 const end = contents.indexOf("---", start + 3);
99 if (end === -1) return undefined;
100 try {
101 const frontmatter = decodeSkillFrontmatter(
102 parseYamlDocument(contents.slice(start + 3, end).trim()) ?? {},
103 );
104 const name = frontmatter.name || fileName.slice(0, -3);
105 const description = frontmatter.description?.trim();
106 // Native names are not trimmed. Do not rename one to fit the picker contract.
107 if (!name || name !== name.trim()) return undefined;
108 return { name, ...(description ? { description } : {}) };
109 } catch {
110 return undefined;
111 }
112}
113
114/** The native loader orders child paths with Go's URL.EscapedPath encoding. */
115function skillPathSortKey(entry: string) {
116 return encodeURI(entry).replace(
117 /[!'()*?#]/g,
118 (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
119 );
120}
121
122/** Read only regular skill files, with a byte limit that applies during the read. */
123const readSkill = Effect.fn("readAntigravitySkill")(function* (
124 skillPath: string,
125 budget: ScanBudget,
126) {
127 const fileSystem = yield* FileSystem.FileSystem;
128 const info = yield* readIfPresent(fileSystem.stat(skillPath), skillPath);
129 if (info?.type !== "File") return undefined;
130
131 const byteLimit = Math.min(MAX_SKILL_BYTES, budget.remainingBytes);
132 if (info.size > BigInt(byteLimit)) {
133 return yield* new AntigravitySkillsProbeError({
134 reason: "scan-budget-exhausted",
135 path: skillPath,
136 });
137 }
138 const chunks = yield* readIfPresent(
139 fileSystem.stream(skillPath, { bytesToRead: byteLimit + 1 }).pipe(Stream.runCollect),
140 skillPath,
141 );
142 if (chunks === undefined) return undefined;
143 const bytes = Buffer.concat(chunks);
144 if (bytes.byteLength > byteLimit) {
145 return yield* new AntigravitySkillsProbeError({
146 reason: "scan-budget-exhausted",
147 path: skillPath,
148 });
149 }
150 budget.remainingBytes -= bytes.byteLength;
151 return bytes.toString("utf8");
152});
153
154/**
155 * Match the official ACP's explicit skill roots. The first valid same-name skill
156 * wins. Each root loads its own SKILL.md or those in its immediate subdirectories.
157 * Read failures remain typed so workspace snapshots do not cache partial results.
158 */
159export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: {
160 readonly cwd: string;
161 readonly userHome: string;
162}): Effect.fn.Return<
163 ReadonlyArray<ServerProviderSkill>,
164 AntigravitySkillsProbeError,
165 FileSystem.FileSystem | Path.Path
166> {
167 const fileSystem = yield* FileSystem.FileSystem;
168 const path = yield* Path.Path;
169 const [configSkills, cliSkills] = antigravityUserSkillDirectories(
170 path,
171 path.join(input.userHome, ".gemini"),
172 );
173 const roots = [
174 { directory: configSkills, scope: "user" },
175 { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" },
176 { directory: cliSkills, scope: "user" },
177 { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" },
178 { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" },
179 ];
180 const budget: ScanBudget = {
181 remainingBytes: MAX_SCAN_BYTES,
182 remainingEntries: MAX_SCAN_ENTRIES,
183 };
184 const skillsByName = new Map<string, ServerProviderSkill>();
185
186 const scanDirectory = Effect.fn("scanAntigravitySkillDirectory")(function* (
187 directory: string,
188 scope: string,
189 scanChildren: boolean,
190 ): Effect.fn.Return<void, AntigravitySkillsProbeError, FileSystem.FileSystem> {
191 const info = yield* readIfPresent(fileSystem.stat(directory), directory);
192 if (info?.type !== "Directory") return;
193 const entries = yield* readIfPresent(fileSystem.readDirectory(directory), directory);
194 if (entries === undefined) return;
195 if (entries.length > budget.remainingEntries) {
196 return yield* new AntigravitySkillsProbeError({
197 reason: "scan-budget-exhausted",
198 path: directory,
199 });
200 }
201 budget.remainingEntries -= entries.length;
202
203 const sortedEntries = entries.toSorted();
204 const skillFileName = sortedEntries.find((entry) => entry.toLowerCase() === "skill.md");
205 if (skillFileName !== undefined) {
206 if (!skillFileName.endsWith(".md")) return;
207 const skillPath = path.join(directory, skillFileName);
208 const contents = yield* readSkill(skillPath, budget);
209 if (contents === undefined) return;
210 const skill = parseSkillFrontmatter(contents, skillFileName);
211 if (!skill || skillsByName.has(skill.name)) return;
212 skillsByName.set(skill.name, {
213 ...skill,
214 path: skillPath,
215 scope,
216 enabled: true,
217 });
218 return;
219 }
220 if (scanChildren) {
221 const children = sortedEntries
222 .map((entry) => ({ entry, sortKey: skillPathSortKey(entry) }))
223 .sort((left, right) =>
224 left.sortKey < right.sortKey ? -1 : left.sortKey > right.sortKey ? 1 : 0,
225 );
226 for (const { entry } of children) {
227 yield* scanDirectory(path.join(directory, entry), scope, false);
228 }
229 }
230 });
231
232 for (const root of roots) {
233 yield* scanDirectory(root.directory, root.scope, true);
234 }
235 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
236});