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
2 changes: 2 additions & 0 deletions src/goodeye_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from goodeye_cli.commands import login as login_cmd
from goodeye_cli.commands import logout as logout_cmd
from goodeye_cli.commands import me as me_cmds
from goodeye_cli.commands import referrals as referrals_cmds
from goodeye_cli.commands import register as register_cmd
from goodeye_cli.commands import teams as teams_cmds
from goodeye_cli.commands import templates as templates_cmds
Expand Down Expand Up @@ -65,6 +66,7 @@
help="Deploy and run image generators.",
)
app.add_typer(invitations_cmds.app, name="invitations", help="Manage invitations.")
app.add_typer(referrals_cmds.app, name="referrals", help="View and redeem referral codes.")


def _version_callback(value: bool) -> None:
Expand Down
10 changes: 10 additions & 0 deletions src/goodeye_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
InvitationDeclineResult,
InvitationList,
MeResponse,
RedeemResponse,
ReferralStatusResponse,
RenameHandleResult,
SafetyCheckResult,
TeamCreated,
Expand Down Expand Up @@ -233,6 +235,14 @@ def get_usage(self) -> UsageResponse:
response = self._request("GET", "/v1/me/usage")
return UsageResponse.model_validate(response.json())

def get_referral_status(self) -> ReferralStatusResponse:
response = self._request("GET", "/v1/referrals/me")
return ReferralStatusResponse.model_validate(response.json())

def redeem_referral_code(self, code: str) -> RedeemResponse:
response = self._request("POST", "/v1/referrals/redeem", json_body={"code": code})
return RedeemResponse.model_validate(response.json())

def claim_handle(self, handle: str) -> ClaimHandleResult:
response = self._request("PATCH", "/v1/me", json_body={"handle": handle})
return ClaimHandleResult.model_validate(response.json())
Expand Down
16 changes: 15 additions & 1 deletion src/goodeye_cli/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from goodeye_cli.auth_flows import device_code_login
from goodeye_cli.client import GoodeyeClient
from goodeye_cli.commands.referrals import _maybe_redeem_referral
from goodeye_cli.config import get_server, save_client_config, save_credentials


Expand All @@ -22,6 +23,11 @@ def login(
"Use `goodeye login-verify --email <email> --code <code>` to finish."
),
),
referral_code: str | None = typer.Option(
None,
"--referral-code",
help="Referral code to claim a bonus after signing in.",
),
) -> None:
"""Sign in to Goodeye on this machine.

Expand All @@ -38,7 +44,8 @@ def login(
console.print("Check your email for next steps.")
console.print(
f"[dim]Non-interactive login started. Finish with: "
f"goodeye login-verify --email {email} --code <code>[/dim]"
f"goodeye login-verify --email {email} --code <code>"
f" (add --referral-code <code> to claim a referral bonus)[/dim]"
)
return

Expand All @@ -57,6 +64,7 @@ def login(

path = save_credentials({"api_key": api_key, "server": server})
console.print(f"[green]Signed in.[/green] Credentials saved to {path}")
_maybe_redeem_referral(server, api_key, referral_code, console)


def login_verify(
Expand All @@ -72,6 +80,11 @@ def login_verify(
"-c",
help="One-time code sent to your email.",
),
referral_code: str | None = typer.Option(
None,
"--referral-code",
help="Referral code to claim a bonus after signing in.",
),
) -> None:
"""Complete non-interactive email-code login and save local credentials."""
console = Console()
Expand All @@ -80,3 +93,4 @@ def login_verify(
result = client.login_verify(email, code)
path = save_credentials({"api_key": result.api_key, "server": server})
console.print(f"[green]Signed in.[/green] Credentials saved to {path}")
_maybe_redeem_referral(server, result.api_key, referral_code, console)
121 changes: 121 additions & 0 deletions src/goodeye_cli/commands/referrals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""`goodeye referrals ...` subcommand group.

Covers viewing your referral code and redeeming a referral from another user.
"""

from __future__ import annotations

import json as _json

import typer
from rich.console import Console

from goodeye_cli.client import GoodeyeClient
from goodeye_cli.config import get_api_key, get_server
from goodeye_cli.errors import AuthRequired, GoodeyeError

app = typer.Typer(help="View and redeem referral codes.", no_args_is_help=True)


def _maybe_redeem_referral(
server: str, api_key: str, referral_code: str | None, console: Console
) -> None:
"""Redeem a referral code after authentication, if one was supplied.

Non-fatal: a redeem failure prints a warning but does not abort the
sign-in flow. The caller should invoke this only after credentials are
already saved.
"""
if not referral_code:
return
try:
with GoodeyeClient(server, api_key=api_key) as client:
result = client.redeem_referral_code(referral_code)
except GoodeyeError as err:
console.print(f"[yellow]Referral code not applied:[/yellow] {err.message}")
if err.hint:
console.print(f"[dim]{err.hint}[/dim]")
return
console.print(
f"[green]Referral bonus applied:[/green] ${result.credits_granted_usd} in credits."
)


def _require_client() -> GoodeyeClient:
api_key = get_api_key()
if not api_key:
raise AuthRequired(
slug="auth_required",
message="Authentication required.",
hint="Run `goodeye login` or set GOODEYE_API_KEY.",
)
return GoodeyeClient(get_server(), api_key=api_key)


@app.command("status")
def status(
json_output: bool = typer.Option(False, "--json", help="Print results as JSON."),
) -> None:
"""Show your referral code and how many people have used it.

Prints your personal referral code, instructions for sharing it, how many
people have redeemed it, how many have activated their account, and how much
you have earned so far.
"""
console = Console()
with _require_client() as client:
result = client.get_referral_status()

if json_output:
payload = {
"code": result.code,
"instructions": result.instructions,
"redeemed_count": result.redeemed_count,
"activated_count": result.activated_count,
"credits_earned_usd": result.credits_earned_usd,
"slots_remaining": result.slots_remaining,
}
typer.echo(_json.dumps(payload))
return

console.print(f"Your referral code: [bold]{result.code}[/bold]")
console.print(f"Instructions: {result.instructions}")
console.print(f"Redeemed: {result.redeemed_count}")
console.print(f"Activated: {result.activated_count}")
console.print(f"Credits earned: ${result.credits_earned_usd}")
console.print(f"Slots remaining: {result.slots_remaining}")


@app.command("redeem")
def redeem(
code: str = typer.Argument(..., help="Referral code to redeem."),
json_output: bool = typer.Option(False, "--json", help="Print results as JSON."),
) -> None:
"""Redeem a referral code from another Goodeye user.

Applies the referral to your account. Your bonus credits land as soon as you
redeem a valid code. The referrer's reward is separate: it is paid later, once
your account is activated.
"""
console = Console()
with _require_client() as client:
result = client.redeem_referral_code(code)

if json_output:
payload = {
"status": result.status,
"credits_granted_usd": result.credits_granted_usd,
"expires_at": result.expires_at.isoformat(),
"referrer_handle": result.referrer_handle,
}
typer.echo(_json.dumps(payload))
return

console.print("[green]Referral redeemed[/green]")
console.print(f"Credits granted: ${result.credits_granted_usd}")
if result.referrer_handle:
console.print(f"Referred by: @{result.referrer_handle}")
console.print(f"Expires at: {result.expires_at.isoformat()}")


__all__ = ["_maybe_redeem_referral", "app", "redeem", "status"]
10 changes: 9 additions & 1 deletion src/goodeye_cli/commands/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rich.console import Console

from goodeye_cli.client import GoodeyeClient
from goodeye_cli.commands.referrals import _maybe_redeem_referral
from goodeye_cli.config import get_server, save_credentials


Expand Down Expand Up @@ -33,7 +34,8 @@ def register(
console.print("Check your email for next steps.")
console.print(
f"[dim]Non-interactive registration started. Finish with: "
f"goodeye register-verify --email {email} --code <code>[/dim]"
f"goodeye register-verify --email {email} --code <code>"
f" (add --referral-code <code> to claim a referral bonus)[/dim]"
)


Expand All @@ -50,6 +52,11 @@ def register_verify(
"-c",
help="One-time code sent to your email.",
),
referral_code: str | None = typer.Option(
None,
"--referral-code",
help="Referral code to claim a bonus after registering.",
),
) -> None:
"""Complete non-interactive registration and save local credentials."""
console = Console()
Expand All @@ -58,3 +65,4 @@ def register_verify(
result = client.register_verify(email, code)
path = save_credentials({"api_key": result.api_key, "server": server})
console.print(f"[green]Account registered.[/green] Credentials saved to {path}")
_maybe_redeem_referral(server, result.api_key, referral_code, console)
4 changes: 4 additions & 0 deletions src/goodeye_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ class SafetyVerificationUnavailable(GoodeyeError):
"anonymous_daily_cap": AnonymousDailyCapReached,
"safety_verification_failed": SafetyVerificationFailed,
"safety_verification_unavailable": SafetyVerificationUnavailable,
"referral_code_not_found": NotFound,
"self_referral": Conflict,
"already_referred": Conflict,
"referral_not_eligible": Conflict,
}


Expand Down
23 changes: 23 additions & 0 deletions src/goodeye_cli/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,3 +776,26 @@ class ImageGenerationRunResult(_WireBase):
created_at: str
error_code: str | None = None
error_message: str | None = None


# ----- referrals -----


class ReferralStatusResponse(_WireBase):
"""Response from GET /v1/referrals/me."""

code: str
instructions: str
redeemed_count: int
activated_count: int
credits_earned_usd: str
slots_remaining: int


class RedeemResponse(_WireBase):
"""Response from POST /v1/referrals/redeem."""

status: str
credits_granted_usd: str
expires_at: datetime
referrer_handle: str
Loading