authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-05 20:54:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 20:54:49-07:00
logab67795dde448a3608d8689f7ad1975bb977e5a9
tree2d9a4c31b05c392a86d08be7cdc394326c3a14f2
parentc8872fd22aadb8d155f1bedc10576d8314f93e56
signature Signed by PGP key B5690EEEBB952194

fix(antigravity): load user skills from ~/.gemini for every project (#10257)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

7 files changed, 220 insertions(+), 17 deletions(-)

apps/server/src/provider/Drivers/AntigravityDriver.ts+5-2
......@@ -1,4 +1,5 @@
11import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
23import * as Crypto from "effect/Crypto";
34import * as Deferred from "effect/Deferred";
45import * as Effect from "effect/Effect";
......@@ -49,7 +50,7 @@ import {
4950} from "../ProviderDriver.ts";
5051import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
5152import { withInstanceIdentity } from "./instanceIdentity.ts";
52import { discoverAntigravitySkills } from "./AntigravitySkills.ts";
53import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts";
5354
5455const DRIVER = ProviderDriverKind.make("antigravity");
5556const decodeSettings = Schema.decodeSync(AntigravitySettings);
......@@ -91,6 +92,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
9192 };
9293 const authConfigIssue = antigravityAuthConfigIssue(auth);
9394 const processEnvironment = mergeProviderInstanceEnvironment(environment);
95 const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment);
9496 const profileDirectory = resolveAntigravityProfileDirectory(
9597 serverConfig.stateDir,
9698 instanceId,
......@@ -146,6 +148,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
146148 profileDirectory,
147149 baseEnv: processEnvironment,
148150 auth,
151 userHome,
149152 }).pipe(
150153 Effect.provideService(FileSystem.FileSystem, fileSystem),
151154 Effect.provideService(Path.Path, path),
......@@ -376,7 +379,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
376379 snapshotForCwd: (cwd) =>
377380 !enabled
378381 ? provider.snapshot.getSnapshot
379 : discoverAntigravitySkills({ cwd, profileDirectory }).pipe(
382 : discoverAntigravitySkills({ cwd, userHome }).pipe(
380383 Effect.provideService(FileSystem.FileSystem, fileSystem),
381384 Effect.provideService(Path.Path, path),
382385 Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)),
apps/server/src/provider/Drivers/AntigravitySkills.test.ts+62-8
......@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
44import * as FileSystem from "effect/FileSystem";
55import * as Path from "effect/Path";
66
7import { discoverAntigravitySkills } from "./AntigravitySkills.ts";
7import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts";
88import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
99
1010const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) {
......@@ -24,20 +24,57 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () {
2424 });
2525 return {
2626 cwd: path.join(temporaryDirectory, "workspace"),
27 profileDirectory: path.join(temporaryDirectory, "profile"),
27 userHome: path.join(temporaryDirectory, "home"),
2828 };
2929});
3030
3131it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
32 it.effect("does not read user skills from a nested project or from ~/.agents", () =>
33 Effect.gen(function* () {
34 const path = yield* Path.Path;
35 const input = yield* makeWorkspace();
36 const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") };
37 const skillPath = yield* writeSkill(
38 path.join(input.userHome, ".gemini", "config", "skills", "review"),
39 "---\nname: review\ndescription: Review changes.\n---\n",
40 );
41 yield* writeSkill(
42 path.join(input.userHome, ".agents", "skills", "ignored"),
43 "---\nname: ignored\n---\n",
44 );
45
46 assert.deepEqual(yield* discoverAntigravitySkills(nested), [
47 {
48 name: "review",
49 description: "Review changes.",
50 path: skillPath,
51 scope: "user",
52 enabled: true,
53 },
54 ]);
55 // A project rooted at the home directory sees ~/.agents/skills as its own.
56 assert.deepEqual(
57 (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [
58 skill.name,
59 skill.scope,
60 ]),
61 [
62 ["ignored", "project"],
63 ["review", "user"],
64 ],
65 );
66 }),
67 );
68
3269 it.effect("reads skill names, descriptions and paths from the current native roots", () =>
3370 Effect.gen(function* () {
3471 const path = yield* Path.Path;
3572 const input = yield* makeWorkspace();
3673 const roots = [
37 { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" },
74 { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" },
3875 { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" },
3976 {
40 directory: path.join(input.profileDirectory, "antigravity-cli", "skills"),
77 directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"),
4178 scope: "user",
4279 },
4380 { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" },
......@@ -91,9 +128,9 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
91128 const path = yield* Path.Path;
92129 const input = yield* makeWorkspace();
93130 const roots = [
94 path.join(input.profileDirectory, "config", "skills"),
131 path.join(input.userHome, ".gemini", "config", "skills"),
95132 path.join(input.cwd, ".gemini", "skills"),
96 path.join(input.profileDirectory, "antigravity-cli", "skills"),
133 path.join(input.userHome, ".gemini", "antigravity-cli", "skills"),
97134 path.join(input.cwd, ".agents", "skills"),
98135 path.join(input.cwd, ".agent", "skills"),
99136 ];
......@@ -218,7 +255,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
218255 const input = yield* makeWorkspace();
219256 const root = path.join(input.cwd, ".agents", "skills");
220257 yield* writeSkill(
221 path.join(input.profileDirectory, "config", "skills", "review"),
258 path.join(input.userHome, ".gemini", "config", "skills", "review"),
222259 "---\nname: [invalid\n---\n",
223260 );
224261 const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"];
......@@ -247,7 +284,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
247284 const fileSystem = yield* FileSystem.FileSystem;
248285 const path = yield* Path.Path;
249286 const input = yield* makeWorkspace();
250 const sourceDirectory = path.join(input.profileDirectory, "shared-review");
287 const sourceDirectory = path.join(input.userHome, "shared-review");
251288 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");
252289 const root = path.join(input.cwd, ".agents", "skills");
253290 const linkedDirectory = path.join(root, "review");
......@@ -304,3 +341,20 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
304341 }),
305342 );
306343});
344
345it("resolves the home the agent expands ~ against", () => {
346 assert.equal(
347 resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }),
348 "/home/user",
349 );
350 assert.equal(
351 resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }),
352 "C:\\Users\\user",
353 );
354 assert.equal(
355 resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }),
356 "D:\\Users\\alice",
357 );
358 assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b ");
359 assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true);
360});
apps/server/src/provider/Drivers/AntigravitySkills.ts+48-6
......@@ -1,3 +1,5 @@
1import * as NodeOS from "node:os";
2
13import type { ServerProviderSkill } from "@t3tools/contracts";
24import * as Effect from "effect/Effect";
35import * as FileSystem from "effect/FileSystem";
......@@ -7,6 +9,45 @@ import * as Schema from "effect/Schema";
79import * as Stream from "effect/Stream";
810import { parse as parseYamlDocument } from "yaml";
911
12/**
13 * The home directory the agent expands `~` against, matching Python's
14 * `os.path.expanduser` in the launch environment T3 hands the process:
15 * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME`
16 * elsewhere. Values are used verbatim; a path may contain spaces.
17 */
18export function resolveAntigravityUserHome(
19 platform: NodeJS.Platform,
20 environment: NodeJS.ProcessEnv,
21): string {
22 if (platform === "win32") {
23 if (environment.USERPROFILE) return environment.USERPROFILE;
24 if (environment.HOMEDRIVE && environment.HOMEPATH) {
25 return `${environment.HOMEDRIVE}${environment.HOMEPATH}`;
26 }
27 return NodeOS.homedir();
28 }
29 return environment.HOME || NodeOS.homedir();
30}
31
32/**
33 * The agent's two user-global skill directories under a Gemini home, in
34 * native precedence order: `config/skills` is shared with the Antigravity IDE
35 * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs
36 * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a
37 * private profile, so the profile links these back to the user's `~/.gemini`.
38 * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a
39 * project directory.
40 */
41export function antigravityUserSkillDirectories(
42 path: Path.Path,
43 geminiHome: string,
44): readonly [configSkills: string, cliSkills: string] {
45 return [
46 path.join(geminiHome, "config", "skills"),
47 path.join(geminiHome, "antigravity-cli", "skills"),
48 ];
49}
50
1051const MAX_SKILL_BYTES = 1_000_000;
1152const MAX_SCAN_BYTES = 8_000_000;
1253const MAX_SCAN_ENTRIES = 10_000;
......@@ -118,7 +159,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* (
118159 */
119160export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: {
120161 readonly cwd: string;
121 readonly profileDirectory: string;
162 readonly userHome: string;
122163}): Effect.fn.Return<
123164 ReadonlyArray<ServerProviderSkill>,
124165 AntigravitySkillsProbeError,
......@@ -126,13 +167,14 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(
126167> {
127168 const fileSystem = yield* FileSystem.FileSystem;
128169 const path = yield* Path.Path;
170 const [configSkills, cliSkills] = antigravityUserSkillDirectories(
171 path,
172 path.join(input.userHome, ".gemini"),
173 );
129174 const roots = [
130 { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" },
175 { directory: configSkills, scope: "user" },
131176 { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" },
132 {
133 directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"),
134 scope: "user",
135 },
177 { directory: cliSkills, scope: "user" },
136178 { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" },
137179 { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" },
138180 ];
apps/server/src/provider/antigravityAuthSupport.test.ts+37
......@@ -15,6 +15,7 @@ import * as Ndjson from "effect/unstable/encoding/Ndjson";
1515import * as ChildProcess from "effect/unstable/process/ChildProcess";
1616import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1717import * as AcpErrors from "effect-acp/errors";
18import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1819
1920import {
2021 ANTIGRAVITY_AUTH_BROWSER_MARKER,
......@@ -510,6 +511,42 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
510511 }),
511512 );
512513
514 it.effect.skipIf(!symlinksSupported)(
515 "links the user's global skill directories into the profile without touching real content",
516 () =>
517 Effect.gen(function* () {
518 const fs = yield* FileSystem.FileSystem;
519 const path = yield* Path.Path;
520 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
521 const userHome = path.join(temporaryDirectory, "home");
522 const profileDirectory = path.join(temporaryDirectory, "profile");
523 const configSkills = path.join(userHome, ".gemini", "config", "skills");
524 const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills");
525 yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true });
526
527 yield* prepareAntigravityProfile({ profileDirectory, userHome });
528 const configLink = path.join(profileDirectory, "config", "skills");
529 const cliLink = path.join(profileDirectory, "antigravity-cli", "skills");
530 expect(yield* fs.readLink(configLink)).toBe(configSkills);
531 expect(yield* fs.readLink(cliLink)).toBe(cliSkills);
532 expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true);
533 // Only the skill directories are shared; the rest of the profile stays private.
534 expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe(
535 false,
536 );
537
538 // A stale link is repointed; a real directory the user placed there is kept.
539 yield* fs.remove(cliLink);
540 yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink);
541 yield* fs.remove(configLink);
542 yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true });
543 yield* prepareAntigravityProfile({ profileDirectory, userHome });
544 expect(yield* fs.readLink(cliLink)).toBe(cliSkills);
545 expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true);
546 expect((yield* fs.stat(configLink)).type).toBe("Directory");
547 }),
548 );
549
513550 it.effect("rewrites the GCP block on every launch and never stores the API key", () =>
514551 Effect.gen(function* () {
515552 const fs = yield* FileSystem.FileSystem;
apps/server/src/provider/antigravityAuthSupport.ts+61
......@@ -1,4 +1,6 @@
11import * as NodeCrypto from "node:crypto";
2// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation.
3import * as NodeFSP from "node:fs/promises";
24// @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service.
35import * as NodePath from "node:path";
46
......@@ -16,6 +18,10 @@ import * as AcpErrors from "effect-acp/errors";
1618
1719import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
1820import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts";
21import {
22 antigravityUserSkillDirectories,
23 resolveAntigravityUserHome,
24} from "./Drivers/AntigravitySkills.ts";
1925
2026export const ANTIGRAVITY_AUTH_STDOUT_PREFIX =
2127 "Open the following link to authenticate the ACP server: ";
......@@ -219,6 +225,56 @@ function antigravityEnvironment(
219225 };
220226}
221227
228/**
229 * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points
230 * at the private profile. Link the two skill directories back to the user's
231 * real `~/.gemini` so global skills load, while MCP servers, hooks, and
232 * credentials stay isolated. Best effort: a link that cannot be made only
233 * costs global skills, never the session. A real directory at the link path
234 * is the user's own content and is left alone.
235 */
236const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: {
237 readonly profileDirectory: string;
238 readonly userHome: string;
239 readonly platform: NodeJS.Platform;
240}): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> {
241 const fs = yield* FileSystem.FileSystem;
242 const path = yield* Path.Path;
243 const links = antigravityUserSkillDirectories(path, input.profileDirectory);
244 const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini"));
245 for (const [link, target] of [
246 [links[0], targets[0]],
247 [links[1], targets[1]],
248 ] as const) {
249 yield* Effect.gen(function* () {
250 const existing = yield* fs.readLink(link).pipe(
251 Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)),
252 Effect.catch((error) =>
253 error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error),
254 ),
255 );
256 if (existing === target) return;
257 if (existing !== undefined) {
258 yield* fs.remove(link);
259 }
260 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
261 yield* Effect.tryPromise(() =>
262 NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"),
263 );
264 }).pipe(
265 // A non-symlink at the link path fails `readLink`; anything else is a
266 // filesystem refusal. Both leave the profile usable.
267 Effect.catch((error) =>
268 Effect.logWarning("Antigravity user skills are not linked into the profile.", {
269 link,
270 target,
271 error,
272 }),
273 ),
274 );
275 }
276});
277
222278/** Prepares a private profile without reading or copying Google credentials. */
223279export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: {
224280 readonly profileDirectory: string;
......@@ -226,12 +282,16 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(
226282 readonly runtimeExecutablePath?: string;
227283 readonly platform?: NodeJS.Platform;
228284 readonly auth?: AntigravityAuthConfig;
285 /** Home the agent expands `~` against. Defaults to the launch environment's. */
286 readonly userHome?: string;
229287}) {
230288 const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH;
231289 const fs = yield* FileSystem.FileSystem;
232290 const path = yield* Path.Path;
233291 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
234292 const platform = input.platform ?? (yield* HostProcessPlatform);
293 const userHome =
294 input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env);
235295 const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath);
236296 const helperExecutable =
237297 platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;
......@@ -326,6 +386,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(
326386 authSupportError("The Antigravity profile settings could not be written."),
327387 ),
328388 );
389 yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform });
329390 return profile;
330391});
331392
docs/internals/providers.md+3-1
......@@ -25,7 +25,9 @@ See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts).
2525Antigravity separates account profiles per instance while sharing installed executables across the
2626environment. It forces file-based credential storage because the native macOS keychain entry would
2727otherwise be shared across instances. The launch environment removes ambient Google credentials,
28so an instance cannot silently use another account or billing project.
28so an instance cannot silently use another account or billing project. The agent also resolves
29its user-global skill directories under that profile, so the profile links those two directories
30back to the user's real `~/.gemini`; MCP servers, hooks, and rules there stay out of the profile.
2931See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts).
3032
3133The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives
docs/user/providers-antigravity.md+4
......@@ -88,6 +88,10 @@ legacy `.agent/skills` directory. Among these project locations, the first copy
8888wins in this order: `.gemini/skills`, `.agents/skills`, `.agent/skills`. See
8989[commands and skills](./composer.md#commands-and-skills) for invoking them.
9090
91Skills for every project go in `~/.gemini/config/skills` or
92`~/.gemini/antigravity-cli/skills`. Antigravity does not read `~/.agents/skills`,
93so a skill there only appears when the project itself is your home directory.
94
9195Antigravity accepts images, PDFs, text files, and supported audio formats directly.
9296Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and
939750 MiB total attachments per message. Unsupported formats are rejected. These