Skip to content

Commit 92f5552

Browse files
feat: add Google Antigravity as a skill host (#50)
Antigravity reads global skills from ~/.gemini/config/skills (and project skills from <workspace>/.agents/skills). Upstream praxis wrote to none of these dirs, so no skills ever surfaced in Antigravity — unlike Codex, which is covered by the existing ~/.agents/skills adapter. Add a fourth harness that sources skills into ~/.gemini/config/skills, detected via the `agy` / `antigravity-ide` binaries or the ~/.gemini/antigravity-ide app-data dir. Detection deliberately avoids a bare ~/.gemini (Gemini CLI's signal, which Antigravity also creates) so the two hosts are never conflated. Agents are gated off (skills only), same posture as Codex, pending verification of Antigravity's subagent loader. Verified end to end against a real Antigravity install: refresh-skills lands 29 skill packages and the Antigravity IDE/agent enumerates them; with the change reverted, ~/.gemini/config/skills stays empty and the agent reports no skills. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0f27e6e commit 92f5552

2 files changed

Lines changed: 79 additions & 7 deletions

File tree

internal/harness/harness.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
// Package harness detects which AI host CLIs/IDEs are present locally and
2-
// reports where each one looks for skill files. The 3 harnesses listed
2+
// reports where each one looks for skill files. The harnesses listed
33
// here all support the Agent Skills open standard at user scope, so
44
// `praxis skill install` writes the same SKILL.md to each detected one.
55
//
6+
// Google Antigravity and Gemini CLI both root their config under
7+
// ~/.gemini, but at different skill dirs: Gemini CLI reads ~/.gemini/skills
8+
// while Antigravity reads ~/.gemini/config/skills (its config root, marked
9+
// by ~/.gemini/config/.migrated). They are detected by distinct signals so
10+
// a bare ~/.gemini does not misattribute one for the other.
11+
//
612
// Cursor is intentionally NOT included: it has no user-scope skills
713
// directory (only project-scope under .cursor/skills/), so it requires
814
// per-repo handling and is deferred to a later release.
@@ -18,7 +24,7 @@ import (
1824

1925
// Harness is one supported AI host.
2026
type Harness struct {
21-
Name string // canonical id: "claude-code", "codex", "gemini-cli"
27+
Name string // canonical id: "claude-code", "codex", "gemini-cli", "antigravity"
2228
DisplayName string // human label
2329
Detected bool // present on this machine
2430
BinaryPath string // resolved binary path if found in $PATH
@@ -33,12 +39,13 @@ func All() []Harness {
3339
detectClaudeCode(home),
3440
detectCodex(home),
3541
detectGeminiCLI(home),
42+
detectAntigravity(home),
3643
}
3744
}
3845

3946
// Detected returns only the harnesses present on this machine.
4047
func Detected() []Harness {
41-
out := make([]Harness, 0, 3)
48+
out := make([]Harness, 0, 4)
4249
for _, h := range All() {
4350
if h.Detected {
4451
out = append(out, h)
@@ -114,6 +121,43 @@ func detectGeminiCLI(home string) Harness {
114121
return h
115122
}
116123

124+
func detectAntigravity(home string) Harness {
125+
h := Harness{
126+
Name: "antigravity",
127+
DisplayName: "Google Antigravity",
128+
// Antigravity's canonical config root is ~/.gemini/config (it writes
129+
// a migration marker at ~/.gemini/config/.migrated on first run).
130+
// Global user-scope skills live under that config dir, and per the
131+
// official docs each is a folder containing SKILL.md.
132+
SkillDir: filepath.Join(home, ".gemini", "config", "skills"),
133+
// AgentDir is set for struct consistency but unused: Antigravity
134+
// agent install is gated off (see agentinstall.supportsAgentInstall),
135+
// pending verification of its subagent loader path — same posture as
136+
// Codex. Only the skill dir above is written today.
137+
AgentDir: filepath.Join(home, ".gemini", "config", "agents"),
138+
}
139+
// Detect via the Antigravity binaries (CLI `agy`, IDE `antigravity-ide`)
140+
// or its app-data dirs. Deliberately NOT keyed off a bare ~/.gemini —
141+
// that is shared with Gemini CLI; the antigravity-ide subdir / app-data
142+
// dir disambiguates so the two hosts are never conflated.
143+
for _, bin := range []string{"agy", "antigravity-ide"} {
144+
if p, err := exec.LookPath(bin); err == nil {
145+
h.Detected = true
146+
h.BinaryPath = p
147+
break
148+
}
149+
}
150+
for _, dir := range []string{
151+
filepath.Join(home, ".gemini", "antigravity-ide"),
152+
filepath.Join(home, ".antigravity-ide"),
153+
} {
154+
if _, err := os.Stat(dir); err == nil {
155+
h.Detected = true
156+
}
157+
}
158+
return h
159+
}
160+
117161
// ProjectScoped returns a copy of h whose SkillDir and AgentDir are
118162
// rebased from the user's home directory onto projectDir. For example a
119163
// Claude Code SkillDir of ~/.claude/skills becomes

internal/harness/harness_test.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ func withIsolatedHome(t *testing.T) string {
1818
return home
1919
}
2020

21-
func TestAll_ReturnsThreeHarnesses(t *testing.T) {
21+
func TestAll_ReturnsAllHarnesses(t *testing.T) {
2222
withIsolatedHome(t)
2323
got := All()
24-
if len(got) != 3 {
25-
t.Errorf("len(All()) = %d, want 3", len(got))
24+
if len(got) != 4 {
25+
t.Errorf("len(All()) = %d, want 4", len(got))
2626
}
27-
wantNames := map[string]bool{"claude-code": true, "codex": true, "gemini-cli": true}
27+
wantNames := map[string]bool{"claude-code": true, "codex": true, "gemini-cli": true, "antigravity": true}
2828
for _, h := range got {
2929
if !wantNames[h.Name] {
3030
t.Errorf("unexpected harness %q in All()", h.Name)
@@ -101,6 +101,32 @@ func TestDetect_GeminiCLI_HomeDir(t *testing.T) {
101101
}
102102
}
103103

104+
func TestDetect_Antigravity_AppDataDir(t *testing.T) {
105+
home := withIsolatedHome(t)
106+
// The antigravity-ide app-data subdir under ~/.gemini is the distinct
107+
// signal that disambiguates Antigravity from Gemini CLI.
108+
if err := os.MkdirAll(filepath.Join(home, ".gemini", "antigravity-ide"), 0700); err != nil {
109+
t.Fatal(err)
110+
}
111+
h, _ := ByName("antigravity")
112+
if !h.Detected {
113+
t.Errorf("Antigravity not detected when ~/.gemini/antigravity-ide exists: %+v", h)
114+
}
115+
}
116+
117+
func TestDetect_Antigravity_NotOnBareGemini(t *testing.T) {
118+
home := withIsolatedHome(t)
119+
// A bare ~/.gemini (Gemini CLI's signal) must NOT trip Antigravity —
120+
// the two share the ~/.gemini root and must stay disambiguated.
121+
if err := os.MkdirAll(filepath.Join(home, ".gemini"), 0700); err != nil {
122+
t.Fatal(err)
123+
}
124+
h, _ := ByName("antigravity")
125+
if h.Detected {
126+
t.Errorf("Antigravity must not be detected on a bare ~/.gemini: %+v", h)
127+
}
128+
}
129+
104130
func TestSkillDir_PerHarness(t *testing.T) {
105131
home := withIsolatedHome(t)
106132
tests := []struct {
@@ -110,6 +136,7 @@ func TestSkillDir_PerHarness(t *testing.T) {
110136
{"claude-code", filepath.Join(home, ".claude", "skills")},
111137
{"codex", filepath.Join(home, ".agents", "skills")},
112138
{"gemini-cli", filepath.Join(home, ".gemini", "skills")},
139+
{"antigravity", filepath.Join(home, ".gemini", "config", "skills")},
113140
}
114141
for _, tt := range tests {
115142
t.Run(tt.name, func(t *testing.T) {
@@ -249,6 +276,7 @@ func TestAllHarnessesHaveAgentDir(t *testing.T) {
249276
"claude-code": filepath.Join(home, ".claude", "agents"),
250277
"codex": filepath.Join(home, ".codex", "agents"),
251278
"gemini-cli": filepath.Join(home, ".gemini", "agents"),
279+
"antigravity": filepath.Join(home, ".gemini", "config", "agents"),
252280
}
253281
for _, h := range All() {
254282
got, ok := want[h.Name]

0 commit comments

Comments
 (0)