Skip to content
60 changes: 60 additions & 0 deletions cmd/entire/cli/agent/antigravity/antigravity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Package antigravity implements the Agent interface for Antigravity (Google's agentic coding CLI).
package antigravity

import (
"context"
"errors"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/agent/types"
)

//nolint:gochecknoinits // Agent self-registration is the intended pattern
func init() {
agent.Register(agent.AgentNameAntigravity, NewAntigravityAgent)
}

// AntigravityAgent implements the Agent interface for Antigravity.
//
//nolint:revive // AntigravityAgent is clearer than Agent in this context
type AntigravityAgent struct {
CommandRunner agent.TextCommandRunner
}

// NewAntigravityAgent creates a new AntigravityAgent instance.
func NewAntigravityAgent() agent.Agent {
return &AntigravityAgent{}
}

// --- Identity ---

func (a *AntigravityAgent) Name() types.AgentName { return agent.AgentNameAntigravity }
func (a *AntigravityAgent) Type() types.AgentType { return agent.AgentTypeAntigravity }
func (a *AntigravityAgent) Description() string {
return "Antigravity CLI - Google's agentic coding CLI (Gemini CLI successor)"
}
func (a *AntigravityAgent) IsPreview() bool { return true }

// DetectPresence reports whether Entire's Antigravity hooks are configured for
// this workspace. Antigravity 2.0 stores runtime data user-scope in
// ~/.gemini/antigravity-cli/, so the only meaningful workspace-level signal is
// whether our entry exists in .agents/hooks.json.
func (a *AntigravityAgent) DetectPresence(ctx context.Context) (bool, error) {
return a.AreHooksInstalled(ctx), nil
}

func (a *AntigravityAgent) ProtectedDirs() []string { return []string{".agents", ".gemini"} }

// --- Legacy methods ---
// Antigravity supplies transcriptPath directly in every hook payload; no session discovery needed.

func (a *AntigravityAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID }
func (a *AntigravityAgent) GetSessionDir(_ string) (string, error) { return "", nil }
func (a *AntigravityAgent) ResolveSessionFile(_, _ string) string { return "" }
func (a *AntigravityAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) {
return nil, errors.New("antigravity: legacy ReadSession not supported; use transcriptPath from hook stdin")
}
func (a *AntigravityAgent) WriteSession(_ context.Context, _ *agent.AgentSession) error { return nil }
func (a *AntigravityAgent) FormatResumeCommand(sessionID string) string {
return "agy --conversation " + sessionID
}
66 changes: 66 additions & 0 deletions cmd/entire/cli/agent/antigravity/antigravity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package antigravity

import (
"context"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

func TestAgent_ImplementsAgentAndHookSupport(t *testing.T) {
t.Parallel()
var _ agent.Agent = (*AntigravityAgent)(nil)
var _ agent.HookSupport = (*AntigravityAgent)(nil)
}

func TestAgent_NameAndType(t *testing.T) {
t.Parallel()
a := &AntigravityAgent{}
if a.Name() != agent.AgentNameAntigravity {
t.Errorf("Name() = %q", a.Name())
}
if a.Type() != agent.AgentTypeAntigravity {
t.Errorf("Type() = %q", a.Type())
}
}

func TestAgent_Registered(t *testing.T) {
t.Parallel()
_, err := agent.Get(agent.AgentNameAntigravity)
if err != nil {
t.Fatalf("agent not registered: %v", err)
}
}

func TestDetectPresence(t *testing.T) {
t.Run("no hooks installed", func(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

ag := &AntigravityAgent{}
present, err := ag.DetectPresence(context.Background())
if err != nil {
t.Fatalf("DetectPresence() error = %v", err)
}
if present {
t.Error("DetectPresence() = true, want false")
}
})

t.Run("hooks installed", func(t *testing.T) {
tempDir := t.TempDir()
t.Chdir(tempDir)

ag := &AntigravityAgent{}
if _, err := ag.InstallHooks(context.Background(), false, false); err != nil {
t.Fatalf("InstallHooks: %v", err)
}
present, err := ag.DetectPresence(context.Background())
if err != nil {
t.Fatalf("DetectPresence() error = %v", err)
}
if !present {
t.Error("DetectPresence() = false, want true after InstallHooks")
}
})
}
14 changes: 14 additions & 0 deletions cmd/entire/cli/agent/antigravity/discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package antigravity

import (
"context"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

// DiscoverReviewSkills is a stub until the Antigravity on-disk extension layout
// is verified. Returns (nil, nil) so the picker relies on the per-agent
// install hint for Phase 1.
func (a *AntigravityAgent) DiscoverReviewSkills(_ context.Context) ([]agent.DiscoveredSkill, error) {
return nil, nil
}
22 changes: 22 additions & 0 deletions cmd/entire/cli/agent/antigravity/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package antigravity

import (
"context"
"fmt"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

// GenerateText submits a non-interactive prompt to the Antigravity CLI. The
// binary is `agy`; -p is the short alias for --print (single-prompt mode).
func (a *AntigravityAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) {
args := []string{"-p", " "}
if model != "" {
args = append(args, "--model", model)
}
result, err := agent.RunIsolatedTextGeneratorCLI(ctx, a.CommandRunner, "agy", "antigravity", args, prompt)
Comment thread
peyton-alt marked this conversation as resolved.
if err != nil {
return "", fmt.Errorf("antigravity text generation failed: %w", err)
}
return result, nil
}
213 changes: 213 additions & 0 deletions cmd/entire/cli/agent/antigravity/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package antigravity

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/paths"
)

// Ensure AntigravityAgent implements HookSupport
var _ agent.HookSupport = (*AntigravityAgent)(nil)

// AgentsHooksFileName is the hooks file used by Antigravity.
const AgentsHooksFileName = "hooks.json"

// entireHookPrefixes are command prefixes that identify Entire hooks.
var entireHookPrefixes = []string{
"entire hooks antigravity ",
"go run ",
}

// InstallHooks installs Antigravity hooks in .agents/hooks.json.
// If localDev is true, hooks point to the local development build.
// If force is true, removes existing Entire hooks before installing.
// Returns the number of hooks installed.
func (a *AntigravityAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when WorktreeRoot() fails (tests run outside git repos)
if err != nil {
return 0, fmt.Errorf("failed to get current directory: %w", err)
}
}

hooksPath := filepath.Join(repoRoot, ".agents", AgentsHooksFileName)

// Read and parse existing hooks file, preserving unknown keys
rawFile := make(map[string]json.RawMessage)
existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path
if readErr == nil {
if err := json.Unmarshal(existingData, &rawFile); err != nil {
return 0, fmt.Errorf("failed to parse existing hooks.json: %w", err)
}
}

// Build the candidate Entire hook config
var cmdPrefix string
if localDev {
cmdPrefix = `go run "$(git rev-parse --show-toplevel)"/cmd/entire/main.go hooks antigravity `
} else {
cmdPrefix = "entire hooks antigravity "
}

candidate := buildEntireHookConfig(cmdPrefix, localDev)

// Idempotency check: compare candidate against existing "entire" entry by
// re-marshaling both to compact JSON for a stable comparison.
if !force {
if existing, ok := rawFile["entire"]; ok {
var existingCfg HookConfig
if err := json.Unmarshal(existing, &existingCfg); err == nil {
existingBytes, err1 := jsonutil.MarshalWithNoHTMLEscape(existingCfg)
candidateBytes, err2 := jsonutil.MarshalWithNoHTMLEscape(candidate)
if err1 == nil && err2 == nil && bytes.Equal(existingBytes, candidateBytes) {
return 0, nil
}
}
}
}

// Marshal and insert the "entire" entry (replacing any prior value)
candidateBytes, err := jsonutil.MarshalWithNoHTMLEscape(candidate)
if err != nil {
return 0, fmt.Errorf("failed to marshal hook config: %w", err)
}
rawFile["entire"] = candidateBytes
Comment thread
peyton-alt marked this conversation as resolved.

if err := writeHooksFile(rawFile, hooksPath); err != nil {
return 0, err
}

// 5 hooks: pre-tool-use, post-tool-use, pre-invocation, post-invocation, stop
return 5, nil
}

// UninstallHooks removes the Entire hook entry from .agents/hooks.json.
func (a *AntigravityAgent) UninstallHooks(ctx context.Context) error {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "." // Fallback to CWD if not in a git repo
}

hooksPath := filepath.Join(repoRoot, ".agents", AgentsHooksFileName)
data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path
if err != nil {
return nil //nolint:nilerr // No hooks file means nothing to uninstall
}

var rawFile map[string]json.RawMessage
if err := json.Unmarshal(data, &rawFile); err != nil {
return fmt.Errorf("failed to parse hooks.json: %w", err)
}

delete(rawFile, "entire")

return writeHooksFile(rawFile, hooksPath)
Comment thread
peyton-alt marked this conversation as resolved.
}

// AreHooksInstalled checks if Entire hooks are installed.
func (a *AntigravityAgent) AreHooksInstalled(ctx context.Context) bool {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
repoRoot = "." // Fallback to CWD if not in a git repo
}

hooksPath := filepath.Join(repoRoot, ".agents", AgentsHooksFileName)
data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path
if err != nil {
return false
}

var f HooksFile
if err := json.Unmarshal(data, &f); err != nil {
return false
}

cfg, ok := f["entire"]
if !ok {
return false
}

// Check at least one of our hook commands is present
return hasEntireHookInToolHandlers(cfg.PreToolUse) ||
hasEntireHookInToolHandlers(cfg.PostToolUse) ||
hasEntireHookInSimpleHandlers(cfg.PreInvocation) ||
hasEntireHookInSimpleHandlers(cfg.PostInvocation) ||
hasEntireHookInSimpleHandlers(cfg.Stop)
}

// buildEntireHookConfig constructs the HookConfig for the "entire" entry.
func buildEntireHookConfig(cmdPrefix string, localDev bool) HookConfig {
makeCmd := func(verb string) string {
cmd := cmdPrefix + verb
if !localDev {
cmd = agent.WrapProductionSilentHookCommand(cmd)
}
return cmd
}

return HookConfig{
PreToolUse: []ToolHandler{
{
Matcher: "*",
Hooks: []HookCommand{{Type: "command", Command: makeCmd("pre-tool-use")}},
},
},
PostToolUse: []ToolHandler{
{
Matcher: "*",
Hooks: []HookCommand{{Type: "command", Command: makeCmd("post-tool-use")}},
},
},
PreInvocation: []SimpleHandler{{Type: "command", Command: makeCmd("pre-invocation")}},
PostInvocation: []SimpleHandler{{Type: "command", Command: makeCmd("post-invocation")}},
Stop: []SimpleHandler{{Type: "command", Command: makeCmd("stop")}},
}
}

// writeHooksFile marshals rawFile and writes it to hooksPath, creating
// parent directories as needed.
func writeHooksFile(rawFile map[string]json.RawMessage, hooksPath string) error {
if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil {
return fmt.Errorf("failed to create .agents directory: %w", err)
}

output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal hooks.json: %w", err)
}

if err := os.WriteFile(hooksPath, output, 0o600); err != nil {
return fmt.Errorf("failed to write hooks.json: %w", err)
}
return nil
}

// hasEntireHookInToolHandlers checks if any ToolHandler entry is an Entire hook.
func hasEntireHookInToolHandlers(handlers []ToolHandler) bool {
for _, th := range handlers {
for _, hc := range th.Hooks {
if agent.IsManagedHookCommand(hc.Command, entireHookPrefixes) {
return true
}
}
}
return false
}

// hasEntireHookInSimpleHandlers checks if any SimpleHandler entry is an Entire hook.
func hasEntireHookInSimpleHandlers(handlers []SimpleHandler) bool {
for _, sh := range handlers {
if agent.IsManagedHookCommand(sh.Command, entireHookPrefixes) {
return true
}
}
return false
}
Loading
Loading