Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 20 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,27 +117,30 @@ Because Cursor does not publish a standalone usage CLI contract, usage is a
read-only best-effort integration and reports an explicit error when the login
or quota response is unavailable.

## Gemini subscription (Antigravity CLI)
## Gemini subscription

Sign in once with the official Antigravity CLI (`agy`) using the Google account
that owns the Gemini subscription, then run it through Prism:
Sign in to Antigravity with each Google subscription account and import the
active login into Prism. Repeat the first two commands for every account, then
run the official Gemini CLI through the shared pool:

```sh
agy
agy -p /usage --output-format json
prism gemini auth import
prism gemini auth list
prism gemini usage
prism gemini -p 'Reply with exactly GEMINI_OK.'
```

`prism gemini` uses the signed-in `agy` profile directly. Prism never reads or
passes `GEMINI_API_KEY`/`GOOGLE_API_KEY`, and AI Studio API-key authentication is
intentionally unsupported because it can incur usage-based charges.
Before every Gemini run and usage check, Prism also forces
`userSettings.useG1Credits: false` in Antigravity's shared configuration so
purchased or promotional AI credits cannot be consumed after the subscription
quota is exhausted.
Prism rotates across registered subscription accounts unless `--account`
selects one:

`prism usage` and `prism gemini usage` show the Antigravity five-hour and weekly
subscription windows. Use `/usage` inside `agy` for the same live quota panel.
```sh
prism gemini --account work-admin -p 'Reply with exactly GEMINI_OK.'
```

AI Studio API keys are intentionally unsupported because they can incur
usage-based charges. `prism usage` and `prism gemini usage` show every
registered subscription account.

The default model is `gemini-3.7-flash-low`. For harder software-engineering or
multi-step tool-use tasks, select `gemini-3.1-pro-high` explicitly:
Expand All @@ -146,6 +149,10 @@ multi-step tool-use tasks, select `gemini-3.1-pro-high` explicitly:
prism gemini --model gemini-3.1-pro-high -p 'Review this repository.'
```

`prism gemini auth login` remains available for organization-managed Gemini
Code Assist OAuth accounts. Imported Antigravity logins and Code Assist accounts
are stored separately and selected by the same rotation mechanism.

## Anthropic

Register each Claude subscription account separately and show its current quota:
Expand Down
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ go 1.24.0

require (
github.com/circlesac/credentials/go v1.2.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/term v0.34.0
)

require golang.org/x/sys v0.35.0 // indirect
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
golang.org/x/sys v0.35.0 // indirect
)
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
github.com/circlesac/credentials/go v1.2.2 h1:sDEjuf0s8y9F37VCkweHC2J8BHDDQsHa59E9XGz0b9c=
github.com/circlesac/credentials/go v1.2.2/go.mod h1:AfWGehoQtkKnIsAp5OrVV0jXes40dCDEjvfETadeYUQ=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
195 changes: 59 additions & 136 deletions internal/cli/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -29,7 +28,6 @@ const defaultGeminiModel = "gemini-3.7-flash-low"
type geminiCLIExecutable struct {
path string
prefix []string
direct bool
}

type geminiBridge struct {
Expand All @@ -46,32 +44,23 @@ func isGeminiCLIInvocation(args []string) bool {
return true
}
switch args[0] {
case "auth", "login", "add", "list", "remove":
case "auth", "login", "add", "list", "remove", "usage":
return false
default:
return true
}
}

func runGeminiCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
if len(args) > 0 && args[0] == "usage" {
if len(args) != 1 {
return errors.New("usage: prism gemini usage")
}
return runGeminiUsage(ctx, stdout, stderr)
}
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") {
printGeminiHelp(stdout)
return nil
}
if executable, executableErr := findGeminiCLIExecutable(); executableErr == nil && executable.direct {
return runAntigravity(ctx, executable, withDefaultGeminiModel(args), os.Stdin, stdout, stderr)
}
account, passthrough, err := parseGeminiOptions(args)
options, account, passthrough, err := parseGeminiOptions(args)
if err != nil {
return err
}
client, err := prismClient(ctx, commonOptions{})
client, err := prismClient(ctx, options)
if err != nil {
return err
}
Expand Down Expand Up @@ -103,7 +92,7 @@ func selectGeminiAccount(selector string, accounts []api.Credential) (string, er
return matches[0], nil
}
if len(accounts) == 0 {
return "", errors.New("no Gemini subscription accounts are registered; run 'prism gemini auth login'")
return "", errors.New("no Gemini subscription accounts are registered; run 'prism gemini auth import'")
}
return rotateProviderAccount("gemini", accounts)
}
Expand All @@ -122,9 +111,6 @@ func runGemini(
if err != nil {
return err
}
if executable.direct {
return runAntigravity(ctx, executable, args, stdin, stdout, stderr)
}

bridge, err := startGeminiBridge(prismURL, prismCredential, account, stderr)
if err != nil {
Expand Down Expand Up @@ -157,100 +143,7 @@ func runGemini(
}
return nil
}

func runAntigravity(ctx context.Context, executable geminiCLIExecutable, args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
if err := disableAntigravityCreditOverages(); err != nil {
return err
}
commandArgs := append(append([]string{}, executable.prefix...), args...)
command := exec.CommandContext(ctx, executable.path, commandArgs...)
command.Stdin = stdin
command.Stdout = stdout
command.Stderr = stderr
command.Env = subscriptionGeminiEnvironment(os.Environ())
if err := command.Run(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return fmt.Errorf("Antigravity CLI exited with status %d", exitError.ExitCode())
}
return fmt.Errorf("could not run Antigravity CLI: %w", err)
}
return nil
}

var antigravityConfigPath = defaultAntigravityConfigPath

func defaultAntigravityConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".gemini", "config", "config.json")
}

func disableAntigravityCreditOverages() error {
path := antigravityConfigPath()
if path == "" {
return errors.New("could not locate Antigravity shared settings")
}
settings := map[string]any{}
contents, err := os.ReadFile(path)
if err == nil {
if len(strings.TrimSpace(string(contents))) != 0 {
if err := json.Unmarshal(contents, &settings); err != nil {
return errors.New("Antigravity shared settings are invalid; fix config.json before using Prism Gemini")
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return errors.New("could not read Antigravity shared settings")
}
userSettings, ok := settings["userSettings"].(map[string]any)
if !ok {
if settings["userSettings"] != nil {
return errors.New("Antigravity shared userSettings are invalid; fix config.json before using Prism Gemini")
}
userSettings = map[string]any{}
settings["userSettings"] = userSettings
}
if value, ok := userSettings["useG1Credits"].(bool); ok && !value {
return nil
}
userSettings["useG1Credits"] = false
encoded, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return errors.New("could not encode Antigravity CLI settings")
}
encoded = append(encoded, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return errors.New("could not create Antigravity shared settings directory")
}
temporary, err := os.CreateTemp(filepath.Dir(path), ".settings-*")
if err != nil {
return errors.New("could not write Antigravity CLI settings")
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return errors.New("could not protect Antigravity CLI settings")
}
if _, err := temporary.Write(encoded); err != nil {
temporary.Close()
return errors.New("could not write Antigravity CLI settings")
}
if err := temporary.Close(); err != nil {
return errors.New("could not close Antigravity CLI settings")
}
if err := os.Rename(temporaryPath, path); err != nil {
return errors.New("could not activate Antigravity shared settings")
}
return nil
}

func findGeminiCLI() (geminiCLIExecutable, error) {
if path, err := exec.LookPath("agy"); err == nil {
return geminiCLIExecutable{path: path, direct: true}, nil
}
if path, err := exec.LookPath("gemini"); err == nil {
return geminiCLIExecutable{path: path}, nil
}
Expand All @@ -259,26 +152,56 @@ func findGeminiCLI() (geminiCLIExecutable, error) {
}
return geminiCLIExecutable{}, errors.New("Gemini CLI is not installed and npx is not on PATH")
}

func subscriptionGeminiEnvironment(environment []string) []string {
filtered := make([]string, 0, len(environment))
for _, entry := range environment {
name, _, _ := strings.Cut(entry, "=")
switch strings.ToUpper(name) {
case "GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GEMINI_BASE_URL", "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_VERTEX_BASE_URL":
continue
func parseGeminiOptions(args []string) (commonOptions, string, []string, error) {
var options commonOptions
var account string
var passthrough []string
for index := 0; index < len(args); index++ {
argument := args[index]
switch {
case argument == "--":
return options, account, append(passthrough, args[index:]...), nil
case argument == "--profile":
if options.profileSet {
return commonOptions{}, "", nil, errors.New("--profile may be specified only once")
}
index++
if index >= len(args) || strings.TrimSpace(args[index]) == "" || args[index] == "--" {
return commonOptions{}, "", nil, errors.New("--profile requires a value")
}
options.profile = strings.TrimSpace(args[index])
options.profileSet = true
case strings.HasPrefix(argument, "--profile="):
if options.profileSet {
return commonOptions{}, "", nil, errors.New("--profile may be specified only once")
}
options.profile = strings.TrimSpace(strings.TrimPrefix(argument, "--profile="))
if options.profile == "" {
return commonOptions{}, "", nil, errors.New("--profile requires a value")
}
options.profileSet = true
case argument == "--account":
if account != "" {
return commonOptions{}, "", nil, errors.New("--account may be specified only once")
}
index++
if index >= len(args) || strings.TrimSpace(args[index]) == "" || args[index] == "--" {
return commonOptions{}, "", nil, errors.New("--account requires a value")
}
account = strings.TrimSpace(args[index])
case strings.HasPrefix(argument, "--account="):
if account != "" {
return commonOptions{}, "", nil, errors.New("--account may be specified only once")
}
account = strings.TrimSpace(strings.TrimPrefix(argument, "--account="))
if account == "" {
return commonOptions{}, "", nil, errors.New("--account requires a value")
}
default:
passthrough = append(passthrough, argument)
}
filtered = append(filtered, entry)
}
return filtered
}

func parseGeminiOptions(args []string) (string, []string, error) {
account, passthrough, err := parseClaudeOptions(args)
if err != nil {
return "", nil, err
}
return account, passthrough, nil
return options, account, passthrough, nil
Comment on lines +159 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: parseGeminiOptions consumes every --profile and --account token before --, regardless of whether it is intended for the Gemini CLI. For example, a prompt or another CLI argument whose value is --profile will be interpreted as Prism's profile flag and removed from the child process arguments, causing valid Gemini CLI invocations to fail or behave differently. Restrict Prism option parsing to the owned option position or require -- before passthrough arguments. [logic error]

Severity Level: Major ⚠️
- ❌ Gemini prompt invocations collide with Prism-owned flags.
- ⚠️ Official CLI arguments can be silently removed.

Use CodeAnt Skill Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/cli/gemini.go
**Line:** 159:204
**Comment:**
	*Logic Error: `parseGeminiOptions` consumes every `--profile` and `--account` token before `--`, regardless of whether it is intended for the Gemini CLI. For example, a prompt or another CLI argument whose value is `--profile` will be interpreted as Prism's profile flag and removed from the child process arguments, causing valid Gemini CLI invocations to fail or behave differently. Restrict Prism option parsing to the owned option position or require `--` before passthrough arguments.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

func withDefaultGeminiModel(args []string) []string {
Expand Down Expand Up @@ -394,14 +317,14 @@ func geminiEnvironment(environment []string, baseURL string, customHeaders strin

func printGeminiHelp(output io.Writer) {
_, _ = fmt.Fprintln(output, `Usage:
prism gemini auth login|list|remove
prism gemini [--account <alias-or-id>] [Gemini CLI arguments...]
prism gemini auth import|login|list|remove
prism gemini [--profile <name>] [--account <alias-or-id>] [Gemini CLI arguments...]

Runs Antigravity CLI (agy) with the signed-in Google Gemini subscription.
AI Studio API keys are intentionally unsupported to prevent usage-based charges.
Prism forces useG1Credits=false before every run to prevent paid overages.
Use 'prism gemini usage' or 'prism usage' to show the subscription quota.
The default model is gemini-3.7-flash-low; use --model gemini-3.1-pro-high for
Runs the official Gemini CLI through Prism's registered subscription accounts.
Accounts rotate automatically unless --account selects one. AI Studio API keys
are intentionally unsupported to prevent usage-based charges. Use
'prism gemini usage' or 'prism usage' to show every registered account.
The default model is gemini-3.7-flash-low; use --model gemini-3.1-pro-high for
hard software-engineering and multi-step tool-use work.
Run 'gemini --help' for Gemini CLI options.`)
}
Loading
Loading