Help us shape the future of Go! Take the 2026 Go Developer Survey

cli

package
v1.4.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: AGPL-3.0 Imports: 31 Imported by: 0

Documentation

Overview

Package cli holds the pieces `agm` needs that are not the REST client: the Chrome cookie capture, the paste fallback, and the messages the owner reads at a terminal (spec section 11.4).

Index

Constants

View Source
const (
	// CredentialsFileName is the file inside the state directory.
	CredentialsFileName = "credentials.json"
	// CredentialsLockName is the advisory lock beside it.
	CredentialsLockName = "credentials.lock"

	// LegacyDefaultProfile is the name every profile used to be given, back
	// when there was one unnamed profile per machine. There is no such thing
	// now: a profile is named for the server it belongs to, and a file
	// carrying this name is migrated on read (see migrate).
	LegacyDefaultProfile = "default"
)

Credential file layout, spec section 11.5. Tokens are stored per server in $XDG_STATE_HOME/agent-gm/credentials.json, mode 0600 inside a 0700 directory, written atomically under a credentials.lock advisory lock.

View Source
const (
	FormatTable = "table"
	FormatJSON  = "json"
	FormatJSONL = "jsonl"
)

Output formats, spec section 11.1. `--json` is a synonym for `--output json`.

View Source
const (
	WaitSent      = "sent"
	WaitDelivered = "delivered"
	WaitRead      = "read"
	WaitTerminal  = "terminal"
)

The four things `--wait-for` can mean (spec section 11.3).

View Source
const CredentialsVersion = 1

CredentialsVersion is the on-disk format version. It stays at 1: `active_profile` is an added field and the profile rename happens on read, so a file written by an older build is read by this one without a version bump, and a file written by this one is still read by an older build (which simply ignores the field it does not know).

View Source
const DefaultLockWait = 10 * time.Second

DefaultLockWait bounds how long `agm` waits for the credentials lock.

An unbounded wait turns "another agm is busy" into a hang with no output: the operator learns nothing and has nothing to act on. Bounded, they get exit 9 naming the lock, with nothing spent. Ten seconds is longer than any healthy exchange and shorter than a person's patience.

View Source
const DefaultPairingTimeout = 5 * time.Minute

DefaultPairingTimeout is how long `agm pair` waits for the owner to tap the emoji before giving up (spec section 11.4).

View Source
const DefaultTimeout = 60 * time.Second

DefaultTimeout bounds a request when --timeout is not given.

View Source
const DefaultWaitTimeout = 60 * time.Second

DefaultWaitTimeout bounds a `--wait` that was given no --timeout.

View Source
const LockSupported = lockSupported

LockSupported reports whether this platform has an advisory file lock at all. It is false on Windows, where lockFile is a no-op and two concurrent `agm` processes can still lose a rotation — a Slice 4 problem, named here so that a test can say so rather than silently assert nothing.

Variables

View Source
var Commands = []Command{
	{
		Name:   "pair",
		Routes: []string{"pairing_start", "pairing_get", "pairing_abandon"},
		Supplies: map[string]string{
			"cookies":      "captured over CDP from the short-lived Chrome profile, or read from --paste/--paste-file. Never a flag: a secret never appears in argv (12.1)",
			"device_index": "--device-index",
			"account_id":   "--account",
			"pairing_id":   "held by the running command; GET is the poll and DELETE is what Ctrl-C issues",
		},
		Notes: "one command from start to paired. It covers POST, GET and DELETE /v1/pairing/* (11.3).",
		LocalFlags: map[string]string{
			"--paste":      "read the cookies from stdin instead of capturing them over CDP",
			"--paste-file": "read the cookies from a file instead of capturing them over CDP",
		},
	},
	{
		Name:   "pair --refresh-cookies",
		Routes: []string{"accounts_refresh_cookies"},
		Supplies: map[string]string{
			"account_id": "--account, which --refresh-cookies requires",
			"cookies":    "the same capture, in a fresh short-lived profile (D33)",
		},
		LocalFlags: map[string]string{
			"--refresh-cookies": "re-authenticate an existing pairing rather than adding an account",
		},
	},

	{
		Name: "accounts list", Routes: []string{"accounts_list"},
		Supplies:   map[string]string{},
		Notes:      "the fast operator view (15.3). --all is a display switch here, not a paging one: the route is not paginated.",
		LocalFlags: map[string]string{"--all": "show every column rather than the operator view"},
	},
	{
		Name: "accounts show", Routes: []string{"accounts_get"},
		Supplies: map[string]string{"account_id": "positional <acct-id>, defaulting to --account"},
	},
	{
		Name: "accounts label", Routes: []string{"accounts_label"},
		Supplies: map[string]string{
			"account_id": "positional <acct-id>",
			"label":      "positional <label>",
		},
	},
	{
		Name: "accounts sign-out", Routes: []string{"accounts_sign_out"}, Destructive: true,
		Supplies: map[string]string{
			"account_id": "positional <acct-id>",
			"confirm":    "the interactive y, or --yes",
		},
		Notes: "keeps every row and refuses writes (4.7). Destructive in the sense that it shreds a credential, not in the sense that it deletes data.",
	},
	{
		Name: "accounts remove", Routes: []string{"accounts_remove"}, Destructive: true,
		Supplies: map[string]string{
			"account_id": "positional <acct-id>",
			"confirm":    "the interactive y, or --yes",
		},
		Notes: "THE ONLY command that deletes an account's history (D30).",
	},

	{
		Name:   "session",
		Routes: []string{"accounts_get", "accounts_events", "accounts_events_all"},
		Supplies: map[string]string{
			"account_id": "--account; omitting it with --watch streams every account's changes",
		},
		Notes:      "--watch consumes the SSE route; without --account it is the all-accounts form (11.3).",
		LocalFlags: map[string]string{"--watch": "stream state changes over SSE"},
	},
	{
		Name: "reconnect", Routes: []string{"accounts_reconnect"},
		Supplies: map[string]string{"account_id": "--account"},
	},
	{
		Name: "health", Routes: []string{"health"}, Supplies: map[string]string{},
	},

	{
		Name: "conversations list", Routes: []string{"conversations_list"},
		Supplies: map[string]string{
			"account_id":      "--account",
			"query":           "--query",
			"participant":     "--participant",
			"folder":          "--folder",
			"type":            "--type",
			"unread_only":     "--unread",
			"group_only":      "--group",
			"pinned_only":     "--pinned",
			"after":           "--after",
			"before":          "--before",
			"include_deleted": "--include-deleted",
			"limit":           "--limit",
		},
		Managed: map[string]string{"cursor": managedCursor},
		LocalFlags: map[string]string{
			"--all": "walk every page rather than the first",
		},
	},
	{
		Name: "conversations show", Routes: []string{"conversations_get"},
		Supplies: map[string]string{"conversation_id": "positional <conv-id>"},
	},
	{
		Name: "conversations start", Routes: []string{"conversations_start"},
		Supplies: map[string]string{
			"account_id": "--account, required when more than one account exists (7.3)",
			"recipients": "positional <e164>...",
			"name":       "--name, accepted only for 2+ recipients",
		},
		LocalFlags: map[string]string{
			"--wait":     "wait for the operation before returning",
			"--wait-for": "sent (default), delivered, read or terminal (11.3)",
		},
	},
	{
		Name:   "conversations archive|unarchive|pin|unpin|mark-unread",
		Routes: []string{"conversations_update"},
		Supplies: map[string]string{
			"conversation_id": "positional <conv-id>",
			"folder":          "implied by the subcommand: archive is folder=archived, unarchive is folder=active",
			"pinned":          "implied by the subcommand: pin is pinned=true, unpin is pinned=false",
			"unread":          "implied by the subcommand: mark-unread is unread=true",
		},
		Notes: "five subcommands over one route. Naming the state as a verb is what keeps a `mode` argument off the surface (13.5).",
		LocalFlags: map[string]string{
			"--wait":     "wait for the operation before returning",
			"--wait-for": "sent (default), delivered, read or terminal (11.3)",
		},
	},
	{
		Name: "conversations mark-read", Routes: []string{"conversations_mark_read"},
		Supplies: map[string]string{
			"conversation_id": "positional <conv-id>",
			"message_id":      "--message",
		},
		Notes: "mark-read is mark_read is POST .../read: the same word on all three surfaces (11.3).",
		LocalFlags: map[string]string{
			"--wait":     "wait for the operation before returning",
			"--wait-for": "sent (default), delivered, read or terminal (11.3)",
		},
	},
	{
		Name: "conversations delete", Routes: []string{"conversations_delete"}, Destructive: true,
		Supplies: map[string]string{
			"conversation_id": "positional <conv-id>",
		},
		Notes: "prints the effect sentence taken from the route's response, byte for byte (16 Slice 2 test 28).",
	},
	{
		Name: "conversations typing", Routes: []string{"conversations_typing"},
		Supplies: map[string]string{"conversation_id": "positional <conv-id>"},
	},

	{
		Name:   "messages list",
		Routes: []string{"messages_list", "conversation_messages_list"},
		Supplies: map[string]string{
			"account_id":      "--account",
			"conversation_id": "the optional positional <conv-id>, which also selects the per-conversation route",
			"direction":       "--direction",
			"sender":          "--sender",
			"after":           "--after",
			"before":          "--before",
			"has_attachment":  "--has-attachment",
			"delivery_state":  "--delivery-state",
			"include_system":  "--include-system",
			"limit":           "--limit",
		},
		Managed: map[string]string{"cursor": managedCursor},
		Notes:   "with no conversation ID it is GET /v1/messages across every account, or one with --account (11.3).",
		LocalFlags: map[string]string{
			"--all": "walk every page rather than the first",
		},
	},
	{
		Name: "messages show", Routes: []string{"messages_get"},
		Supplies: map[string]string{"message_id": "positional <msg-id>"},
	},
	{
		Name: "messages context", Routes: []string{"messages_context"},
		Supplies: map[string]string{
			"message_id": "positional <msg-id>",
			"before":     "--before",
			"after":      "--after",
		},
	},
	{
		Name: "messages search", Routes: []string{"search_messages"},
		Supplies: map[string]string{
			"q":               "positional <query>",
			"account_id":      "--account",
			"mode":            "--mode",
			"conversation_id": "--conversation",
			"sender":          "--sender",
			"after":           "--after",
			"before":          "--before",
			"has_attachment":  "--has-attachment",
			"limit":           "--limit",
		},
		Managed: map[string]string{"cursor": managedCursor},
		LocalFlags: map[string]string{
			"--all": "walk every page rather than the first",
		},
	},
	{
		Name:   "messages send",
		Routes: []string{"messages_send", "uploads_create", "uploads_content"},
		Supplies: map[string]string{
			"conversation_id":     "positional <conv-id>",
			"text":                "--text",
			"reply_to_message_id": "--reply-to",
			"force_rcs":           "--force-rcs",
			"filename":            "derived from --file's path",
			"mime_type":           "sniffed from --file's bytes",
			"size_bytes":          "the size of --file",
			"sha256":              "computed over --file",
		},
		Managed: map[string]string{
			"upload_ids": "--file does the whole reserve / PUT / send sequence in one process and never surfaces an upl_ ID for a human to inspect (11.3). It takes ONE path, matching the one-attachment-per-message limit",
			"upload_id":  "held between the reserve and the PUT within one invocation",
		},
		Notes: "--wait defaults to --wait-for sent, and warns on stderr when delivered or read is asked for on an sms_mms conversation (5.5).",
		LocalFlags: map[string]string{
			"--file":     "ONE path; the reserve, PUT and send happen in this process (11.3)",
			"--wait":     "wait for the operation before returning",
			"--wait-for": "sent (default), delivered, read or terminal (11.3)",
		},
	},
	{
		Name: "messages delete", Routes: []string{"messages_delete"}, Destructive: true,
		Supplies: map[string]string{
			"message_id": "positional <msg-id>",
		},
		Notes: "prints the effect sentence taken from the route's response, byte for byte (16 Slice 2 test 28).",
	},
	{
		Name: "messages add-reaction", Routes: []string{"reactions_add"},
		Supplies: map[string]string{
			"message_id": "positional <msg-id>",
			"emoji":      "positional <emoji>",
		},
		LocalFlags: map[string]string{
			"--wait":     "wait for the operation before returning",
			"--wait-for": "sent (default), delivered, read or terminal (11.3)",
		},
	},
	{
		Name:   "messages remove-reaction",
		Routes: []string{"reactions_remove", "reactions_remove_by_id"},
		Supplies: map[string]string{
			"message_id":  "positional <msg-id>",
			"emoji":       "positional <emoji>",
			"reaction_id": "--reaction, which addresses it by react_ ID instead",
		},
		Notes: "there is no `unreact`: remove-reaction is remove_reaction is DELETE .../reactions/{emoji} (11.3).",
		LocalFlags: map[string]string{
			"--wait":     "wait for the operation before returning",
			"--wait-for": "sent (default), delivered, read or terminal (11.3)",
		},
	},

	{
		Name: "attachments list", Routes: []string{"message_attachments_list"},
		Supplies: map[string]string{"message_id": "positional <msg-id>"},
		Notes:    "metadata for one message's attachments. Folded into get_message on MCP (8.2), but a command exists because every /v1 route has one (11.3).",
	},
	{
		Name: "attachments show", Routes: []string{"attachments_get"},
		Supplies: map[string]string{"attachment_id": "positional <att-id>"},
	},
	{
		Name: "attachments download", Routes: []string{"attachments_content"},
		Supplies: map[string]string{"attachment_id": "positional <att-id>"},
		Notes:    "--out names the file, defaulting to the attachment's own. The ticket goes in the Authorization header, never in the URL (10.3).",
		LocalFlags: map[string]string{
			"--out": "the file to write, defaulting to the attachment's own name",
		},
	},

	{
		Name: "contacts list", Routes: []string{"contacts_list"},
		Supplies: map[string]string{
			"account_id": "--account",
			"query":      "--query",
			"top":        "--top",
			"limit":      "--limit",
		},
		Managed: map[string]string{"cursor": managedCursor},
		LocalFlags: map[string]string{
			"--all": "walk every page rather than the first",
		},
	},

	{
		Name: "operations list", Routes: []string{"operations_list"},
		Supplies: map[string]string{
			"account_id": "--account",
			"kind":       "--kind",
			"status":     "--status",
			"terminal":   "--terminal",
			"after":      "--after",
			"before":     "--before",
			"limit":      "--limit",
		},
		Managed: map[string]string{"cursor": managedCursor},
		LocalFlags: map[string]string{
			"--all": "walk every page rather than the first",
		},
	},
	{
		Name: "operations show", Routes: []string{"operations_get"},
		Supplies: map[string]string{"operation_id": "positional <op-id>"},
	},
	{
		Name: "operations wait", Routes: []string{"operations_get"},
		Supplies: map[string]string{"operation_id": "positional <op-id>"},
		Notes:    "polls GET /v1/operations/{id} on the client side and takes its default bound from the server's operations.wait_timeout (11.3).",
		LocalFlags: map[string]string{
			"--for": "sent (default), delivered, read or terminal (11.3)",
		},
	},

	{
		Name:   "auth login",
		Routes: []string{"auth_admin_session", "auth_refresh"},
		Supplies: map[string]string{
			"secret":        "--secret-stdin or a TTY prompt, for --admin. Never argv (12.1)",
			"scopes":        "--scopes",
			"refresh_token": "held in the credentials file and rotated on use (11.5)",
		},
		LocalFlags: map[string]string{
			"--admin":      "the admin bootstrap path, the only one Slice 2 has",
			"--no-browser": "print the URL instead of opening a browser (the OAuth path, Slice 3)",
		},
	},
	{
		Name: "auth logout", Routes: []string{"auth_logout"}, Destructive: true,
		Supplies: map[string]string{},
	},
	{
		Name: "auth whoami", Routes: []string{"auth_whoami"}, Supplies: map[string]string{},
	},

	{
		Name: "admin settings list", Routes: []string{"admin_settings_list"}, Supplies: map[string]string{},
	},
	{
		Name: "admin settings get", Routes: []string{"admin_settings_get"},
		Supplies: map[string]string{"key": "positional <key>"},
	},
	{
		Name: "admin settings set", Routes: []string{"admin_settings_set"},
		Supplies: map[string]string{},
		Notes:    "its body fields are the settings keys themselves, validated against the settings registry (7.7).",
	},
	{
		Name: "admin audit list", Routes: []string{"admin_audit"},
		Supplies: map[string]string{
			"kind":             "--kind",
			"kind_prefix":      "--kind-prefix",
			"account_id":       "--account",
			"authorization_id": "--authorization",
			"after":            "--after",
			"before":           "--before",
			"limit":            "--limit",
		},
		Managed: map[string]string{"cursor": managedCursor},
		LocalFlags: map[string]string{
			"--all": "walk every page rather than the first",
		},
	},
	{
		Name: "admin backfill", Routes: []string{"admin_backfill"},
		Supplies: map[string]string{
			"account_id":      "--account",
			"conversation_id": "--conversation",
			"confirm":         "the interactive y, or --yes, required when neither --account nor --conversation is given",
		},
		Destructive: true,
		Notes:       "with neither flag it walks every account -- the most expensive operation Agent GM offers (5.4).",
	},
	{
		Name: "admin backup", Routes: []string{"admin_backup"}, Supplies: map[string]string{},
	},
	{
		Name: "admin diagnostics", Routes: []string{"admin_diagnostics"},
		Supplies: map[string]string{"account_id": "--account"},
	},

	{
		Name: "admin enrollment-codes create", Routes: []string{"admin_enrollment_codes_create"},
		Supplies: map[string]string{
			"label":        "positional <label>",
			"expires_in":   "--expires-in",
			"scopes":       "--scopes",
			"allow_scopes": "--allow-scopes",
		},
		Notes: "prints the code once. It is never retrievable again: only its SHA-256 is stored (9.5).",
	},
	{
		Name: "admin enrollment-codes list", Routes: []string{"admin_enrollment_codes_list"},
		Supplies:   map[string]string{},
		LocalFlags: map[string]string{"--all": "include expired and inactive entries"},
	},
	{
		Name: "admin enrollment-codes show", Routes: []string{"admin_enrollment_codes_get"},
		Supplies: map[string]string{"enrollment_code_id": "positional <enroll-id>"},
	},
	{
		Name: "admin enrollment-codes revoke", Routes: []string{"admin_enrollment_codes_revoke"},
		Supplies: map[string]string{
			"enrollment_code_id": "positional <enroll-id>",
			"reason":             "--reason",
		},
		Destructive: true,
		Notes:       "repeating it prints `revoked: false` and exits 0 (9.5).",
	},
	{
		Name: "admin authorization-requests list", Routes: []string{"admin_authorization_requests_list"},
		Supplies:   map[string]string{"status": "--status"},
		LocalFlags: map[string]string{"--all": "include expired and inactive entries"},
	},
	{
		Name: "admin authorization-requests show", Routes: []string{"admin_authorization_requests_get"},
		Supplies: map[string]string{"authorization_request_id": "positional <authreq-id>"},
	},
	{
		Name: "admin authorization-requests approve", Routes: []string{"admin_authorization_requests_approve"},
		Supplies: map[string]string{
			"authorization_request_id": "positional <authreq-id>",
			"scopes":                   "--scopes, which may only NARROW what the browser selected (9.5)",
		},
	},
	{
		Name: "admin authorization-requests deny", Routes: []string{"admin_authorization_requests_deny"},
		Supplies: map[string]string{
			"authorization_request_id": "positional <authreq-id>",
			"reason":                   "--reason",
		},
	},
	{
		Name: "admin authorizations list", Routes: []string{"admin_authorizations_list"},
		Supplies: map[string]string{"include_revoked": "--include-revoked"},
	},
	{
		Name: "admin authorizations show", Routes: []string{"admin_authorizations_get"},
		Supplies: map[string]string{"authorization_id": "positional <auth-id>"},
	},
	{
		Name: "admin authorizations revoke", Routes: []string{"admin_authorizations_revoke"},
		Supplies: map[string]string{
			"authorization_id": "positional <auth-id>",
			"reason":           "--reason",
		},
		Destructive: true,
		Notes:       "revoking an authorization revokes every token of it, so the connector's next call is a 401.",
	},
	{
		Name: "admin clients create", Routes: []string{"admin_clients_create"},
		Supplies: map[string]string{
			"client_id":        "positional <client-id>",
			"name":             "--name",
			"redirect_uris":    "--redirect",
			"default_resource": "--default-resource",
		},
		Notes: "for a connector that cannot register itself. Every other client registers through DCR, and a `client_` id is refused here because that prefix is what this server mints.",
	},
	{
		Name: "admin clients list", Routes: []string{"admin_clients_list"},
		Supplies:   map[string]string{},
		LocalFlags: map[string]string{"--all": "include expired and inactive entries"},
	},
	{
		Name: "admin clients show", Routes: []string{"admin_clients_get"},
		Supplies: map[string]string{"client_id": "positional <client-id>"},
	},
	{
		Name: "admin clients revoke", Routes: []string{"admin_clients_revoke"},
		Supplies: map[string]string{
			"client_id": "positional <client-id>",
			"reason":    "--reason",
		},
		Destructive: true,
		Notes:       "removes the registration and revokes every authorization it holds.",
	},
}

Commands is the whole `agm` surface of section 11.3.

`agm completion` and `agm version` are absent on purpose: they drive no route, and the two-way test treats a command with no routes as a local command rather than as an unmapped one.

View Source
var ErrAborted = errors.New("aborted; nothing was done")

ErrAborted is a destructive command the operator declined. It exits 2: answering "no" to "Continue? y/N" is not a failure of Agent GM, it is the prompt doing its job, and 0 would tell a script the deletion happened.

View Source
var ErrChromeExited = errors.New("chrome exited before the capture could start")

ErrChromeExited means Chrome stopped before the capture could start. Its own stderr is carried in the message, because that is where the reason is.

View Source
var ErrNoChrome = errors.New("no Chrome or Chromium binary found")

ErrNoChrome means no Chrome or Chromium binary was found. `agm pair` does not fail with a missing-binary error: it explains what it needs and offers the alternatives, and exits 9 (local configuration), not 2 -- nothing about the command was malformed.

View Source
var ErrOSIDNeverAppeared = errors.New("the OSID cookie never appeared")

ErrOSIDNeverAppeared means the sign-in never reached messages.google.com. OSID is host-scoped there and is not set by the accounts.google.com sign-in alone; reading before it exists silently returns an unusable set.

View Source
var ErrPasteUnreadable = errors.New("could not read cookies from that paste")

ErrPasteUnreadable means the paste was neither a JSON object of cookie name to value nor a cURL command carrying a Cookie header.

View Source
var RoutesWithoutCommand = map[string]string{
	"uploads_get": "`agm messages send --file` performs the whole reserve / PUT / send " +
		"sequence in one process and never surfaces an upl_ ID for a human to inspect or cancel (11.3)",
	"uploads_delete": "the same reason as uploads_get",
	"healthz": "not a /v1 route. It is the liveness probe a supervisor calls, and " +
		"`agm health` answers the question a human is asking",
}

RoutesWithoutCommand are the `/v1` routes section 11.3 states have no command, with the reason. It is a closed list: a route absent from both this and the command table fails section 16 Slice 2 test 27.

Functions

func CredentialsPath

func CredentialsPath(flagValue string, getenv func(string) string) string

CredentialsPath resolves the file per spec section 11.5: --credentials-file, else AGENT_GM_CREDENTIALS_FILE, else $XDG_STATE_HOME/agent-gm/credentials.json.

func DisplayHint

func DisplayHint(getenv func(string) string) string

DisplayHint diagnoses the common Linux-desktop case: a CLI run from a terminal that has no access to the owner's graphical session. Chrome cannot open a window there and exits at once, and the message it prints ("cannot open display") is not one an owner reads as "set XAUTHORITY".

It returns "" on macOS and Windows, which have no such variables, and "" when the environment already names a display and an authority.

func ExitCodeFor

func ExitCodeFor(err error) int

ExitCodeFor maps any error this package produces onto spec section 11.2's exit code. Server error codes go through apierr.ExitCodeFor, which is the one table; the cases below are the CLI-only codes that no section 7.2 code produces.

func FindChrome

func FindChrome(env string) (string, error)

FindChrome resolves the browser: AGENT_GM_CHROME, then the platform's usual locations, then PATH (spec section 11.4).

func FindChromeIn

func FindChromeIn(env string, candidates []string, lookPath func(string) (string, error)) (string, error)

FindChromeIn is FindChrome with the filesystem injected, so the no-Chrome case is deterministic on a machine that has Chrome. The search order is the spec's: the environment, then the platform's usual locations, then PATH.

func GlobalFlags

func GlobalFlags() []string

GlobalFlags are section 11.1's flags.

func LockForTest

func LockForTest(f *os.File) error

LockForTest takes the credentials lock the way a second `agm` process would, so that a test can hold it. It is not called by any production path.

It lives here, without a build constraint, because the test that uses it has none either: a symbol declared only for one platform is a test package that does not compile for the others, which is exactly how this file came to exist.

func NewProfileDir

func NewProfileDir(root string) (string, error)

NewProfileDir creates the short-lived Chrome profile for one capture.

The profile is deliberately temporary (D33): it holds a logged-in Google session, so it is created fresh under root for the capture and deleted the moment Chrome is closed -- on success, on failure, on timeout and on Ctrl-C -- leaving nothing under the state directory. root may be empty for the platform's temporary directory.

func NoChromeMessage

func NoChromeMessage(publicURL string) string

NoChromeMessage is what `agm pair` prints when it cannot find Chrome. The two ways forward are in the order spec section 11.4 states, best first.

An unknown server prints a placeholder and NOT a hostname. There is no built-in deployment host anywhere in this package: a message that names one is a message that invites an owner to paste their Google cookies at somebody else's server.

func ParsePaste

func ParsePaste(input string) (map[string]string, error)

ParsePaste accepts either a JSON object of cookie name to value or a cURL command copied from browser devtools, matching upstream's own instruction text (connector/login.go:227).

The input is parsed once, never echoed and never copied to disk.

func ProfileNameForServer

func ProfileNameForServer(server string) (string, error)

ProfileNameForServer is the name `agm auth login` gives a profile when `--profile` is not passed: the server's host, so that `agm profiles list` reads as the list of servers this machine can talk to.

func ReadRefreshTokenFile

func ReadRefreshTokenFile(path string) (string, error)

ReadRefreshTokenFile reads the automation credential.

func RouteTableForTest

func RouteTableForTest() map[string]RouteEntry

RouteTableForTest exposes the table to internal/cli's tests. It exists so the cross-check against internal/api lives in an external test package, which is where a test that imports the server's inventory belongs.

func Run

func Run(env Env) int

Run executes one `agm` invocation and returns its exit code (spec section 11.2). It never calls os.Exit, so a test runs the real command.

Types

type Capture

type Capture struct {
	// Chrome is the browser binary.
	Chrome string
	// ProfileRoot is the directory the short-lived --user-data-dir is made
	// under. Empty means the platform's temporary directory. The profile
	// itself is created by Run and removed by Run, always.
	ProfileRoot string
	// Timeout bounds how long the owner has to finish signing in.
	Timeout time.Duration
	// Logf receives progress lines for the owner. Cookie values never reach
	// it.
	Logf func(format string, args ...any)
	// Environ is the environment Chrome's display hint is read from. Empty
	// means the process environment. It exists so the hint is testable.
	Environ func(string) string
	// contains filtered or unexported fields
}

Capture drives the short-lived Chrome profile over CDP.

func (*Capture) ProfileDir

func (c *Capture) ProfileDir() string

ProfileDir reports the short-lived profile directory the last Run used. It must not exist after Run returns.

func (*Capture) Run

func (c *Capture) Run(ctx context.Context) (map[string]string, error)

Run launches Chrome, waits for the sign-in, reads exactly the seven cookies across both hosts, terminates Chrome and returns them.

The cookies are never written to a file of Agent GM's own: they go from CDP into the caller and nowhere else.

type Client

type Client struct {
	BaseURL string
	Token   string
	Timeout time.Duration
	HTTP    *http.Client
	// UserAgent identifies the CLI in the server's logs.
	UserAgent string
	// OnInvalidToken is called at most once per request, when the server
	// answers `invalid_token`. It refreshes the credential and updates
	// Token; the request is then sent again, exactly once.
	//
	// It lives on the client rather than at each call site because every
	// command goes through Do, and a refresh wired into some commands and
	// not others is a refresh an owner cannot rely on. A nil hook, or an
	// error from it, leaves the server's refusal in place -- which is what
	// makes `exit 3` mean "a refresh was attempted and refused".
	OnInvalidToken func() error
}

Client is `agm`'s whole knowledge of the server: a base URL, a credential and a timeout. It speaks the REST API and nothing else (spec section 11), so anything the CLI can do an agent can do too.

It turns a `/v1` error envelope back into an *apierr.Error, which is what gives the exit-code mapping of section 11.2 something typed to work on: the CLI never re-derives an exit code from an HTTP status, it reads the code out of the envelope and hands it to apierr.ExitCodeFor.

The token is never logged, never placed in a URL and never rendered by String(): section 10.3's rule for tickets is the same rule for the access token, because a proxy log and a shell history do not distinguish them.

func (*Client) Do

func (c *Client) Do(ctx context.Context, r Request) (*Response, error)

Do sends the request, and on `invalid_token` refreshes once and sends it again. The retry carries the SAME Idempotency-Key, because it is the same logical request: a mutation that was refused before it was applied must not become two operations because the credential was stale (spec section 6.3).

A request carrying its own Token -- an upload or download ticket -- is never retried: a ticket is not a credential this client can refresh.

func (*Client) String

func (c *Client) String() string

String describes the client without its credential.

type Command

type Command struct {
	// Name is the command as typed, with its subcommands:
	// "messages add-reaction".
	Name string
	// Routes are the route names (internal/api's Route.Name) this command
	// drives, in the order it drives them.
	Routes []string
	// Supplies maps a route parameter to how this command supplies it. Every
	// parameter of every route in Routes must appear here or in Managed.
	Supplies map[string]string
	// LocalFlags are the flags this command takes that supply no route
	// parameter -- --wait, --for, --no-browser and the rest. They are
	// declared here for the same reason Supplies is: docs/cli.md and the
	// implementation are checked against one table rather than against each
	// other's memory. A flag that supplies a route parameter belongs in
	// Supplies and may not appear here as well.
	LocalFlags map[string]string
	// Managed lists route parameters the CLI supplies without asking the
	// owner, with the reason. `cursor` is the archetype: `agm ... --all`
	// walks the pages itself, and a human pasting an opaque signed cursor on
	// a command line is not a workflow anyone wants.
	Managed map[string]string
	// Destructive marks a command that prints the route's exact `effect`
	// sentence and requires an interactive `y` or `--yes` (section 11.3).
	Destructive bool
	// Notes records anything a reader would otherwise reconstruct.
	Notes string
}

Command is one `agm` command, declared rather than discovered, for the same reason the route inventory is (internal/api): section 11.3 promises that **every `/v1` route parameter has a flag and every `/v1` route has a command**, and section 16 Slice 2 test 27 asserts that in both directions. A promise checked by a table test needs a table.

The mapping is deliberately explicit about *how* a route parameter is supplied, because "there is a flag for it" is not the only honest answer: some parameters are positional, some are implied by the subcommand the owner typed (`archive` is `folder=archived`), and some the CLI manages on the owner's behalf (`cursor`, and the whole `upl_` dance behind `--file`). Recording which of those it is keeps the test from being satisfied by a flag that does not exist.

func CommandByName

func CommandByName(name string) (Command, bool)

CommandByName looks a command up by its typed name.

func (Command) Covers

func (c Command) Covers(param string) bool

Covers reports whether this command accounts for a route parameter, either by supplying it from the owner's input or by managing it on their behalf.

func (Command) FlagsMentioned

func (c Command) FlagsMentioned() []string

FlagsMentioned extracts the `--flag` names out of a Supplies description and the LocalFlags keys, so the two-way test can assert a described flag looks like one and docs/cli.md is checked against both halves. A flag that drives no route parameter -- `--for`, `--no-browser` -- is exactly the kind that would otherwise drift out of the page unnoticed.

func (Command) String

func (c Command) String() string

String renders a command the way help text and an error message name it.

func (Command) Validate

func (c Command) Validate() error

Validate reports a command whose own table is internally inconsistent: a parameter both supplied and managed, or an empty description.

type ContractError

type ContractError struct {
	Msg string
}

ContractError is exit 10: the server answered something that is not the contract -- a body that is not an envelope, a status with no error object.

func (*ContractError) Error

func (e *ContractError) Error() string

type Credential

type Credential struct {
	Server  string
	Token   string
	Source  CredentialSource
	Profile string
	// RefreshTokenFile and ClientID are set for SourceRefreshFile, whose
	// exchange happens at the start of the invocation.
	RefreshTokenFile string
	ClientID         string
	// Refreshable reports whether the token can be exchanged for a new one.
	// AGENT_GM_ACCESS_TOKEN is not: it is used as given (spec section 11.5).
	Refreshable bool
}

Credential is the resolved answer to "which server, with what".

func ResolveCredential

func ResolveCredential(store *Store, getenv func(string) string, serverFlag, profileFlag string, creating bool) (Credential, error)

ResolveCredential applies the precedence of spec section 11.5, in order.

The server comes from `--server`, else `AGENT_GM_URL`, else the ACTIVE profile, and from nowhere else. There is no built-in hostname: when none of the three resolves, the command fails naming what to type.

The profile comes from `--profile`, else `AGENT_GM_PROFILE`, else `active_profile` in the credentials file. There is no implicit "default".

The credential comes from AGENT_GM_ACCESS_TOKEN, else the refresh token file, else the stored profile.

A profile is bound to an exact server, and on a command that is not `agm auth login` a `--server` naming a server no profile holds is REFUSED rather than attempted: the alternative is a request sent to an origin this machine has no credential for, which fails later and less clearly, or -- worse -- succeeds against the wrong server. `creating` is true only for `agm auth login`, which is where a profile comes from and therefore the one command allowed to name a server it has never seen.

type CredentialSource

type CredentialSource string

CredentialSource names where the credential in use came from, for `--verbose` and for an error message that has to say what to fix.

const (
	// SourceEnvAccessToken is AGENT_GM_ACCESS_TOKEN: one access token, used
	// as given, not refreshable.
	SourceEnvAccessToken CredentialSource = "AGENT_GM_ACCESS_TOKEN"
	// SourceRefreshFile is AGENT_GM_REFRESH_TOKEN_FILE + AGENT_GM_CLIENT_ID:
	// automation. Every invocation exchanges the token and rewrites the file
	// with the rotated value.
	SourceRefreshFile CredentialSource = "AGENT_GM_REFRESH_TOKEN_FILE"
	// SourceProfile is the stored profile, written by `agm auth login`.
	SourceProfile CredentialSource = "profile"
	// SourceNone is no credential at all.
	SourceNone CredentialSource = "none"
)

type Credentials

type Credentials struct {
	Version       int                `json:"version"`
	ActiveProfile string             `json:"active_profile,omitempty"`
	Profiles      map[string]Profile `json:"profiles"`
}

Credentials is the whole file.

ActiveProfile is which profile a command with no `--profile` uses. It is recorded by `agm auth login` and changed by `agm profiles use`; there is no implicit "default" profile to fall back on, because a fallback that picks a profile for you is a fallback that eventually picks the wrong server.

type Env

type Env struct {
	// Args are the arguments after the program name.
	Args   []string
	Stdin  io.Reader
	Stdout io.Writer
	Stderr io.Writer
	// Getenv reads the environment. Nil means os.Getenv.
	Getenv func(string) string
	// HTTP is the transport. Nil means http.DefaultClient.
	HTTP *http.Client
	// Version is what `agm version` prints.
	Version string
	// Commit is the source commit `agm version` prints beside the version.
	// It is stamped at link time by the release build; a checkout build
	// reads it from the VCS stamp instead, and a build with neither says
	// "unknown" rather than inventing one. Section 1.4 makes the commit the
	// AGPL section 13 source offer, and an offer of an unknown commit is not
	// an offer -- but that is the SERVER's obligation, discharged by
	// `GET /v1/health`; on the client side the commit is here so that an
	// owner reporting a bug can say which build they ran.
	Commit string
	// PollInterval is how often a wait re-reads an operation. Zero means one
	// second; a test sets it small so a wait is not a sleep.
	PollInterval time.Duration
	// Now is the clock, injected for the same reason.
	Now func() time.Time
	// LockWait bounds the wait for the credentials lock. Zero means
	// DefaultLockWait; a test sets it small so that proving the deadline
	// exists does not cost ten seconds of every run. It is here, beside the
	// clock and the poll interval, rather than in a package variable,
	// because a package variable can be made LARGE -- and a large one
	// restores exactly the unbounded hang the deadline removes.
	LockWait time.Duration
}

Env is one invocation's environment. Everything the CLI touches outside its own process is here, so a test drives the real command with real argument parsing against a stub server rather than calling handlers directly.

type LocalError

type LocalError struct {
	Msg string
	Err error
}

LocalError is exit 9: a local configuration or credential-store failure. It is decided BEFORE a request is sent -- that ordering is the whole point of section 11.5's write-back safety rule -- so a LocalError always means nothing was spent.

func (*LocalError) Error

func (e *LocalError) Error() string

func (*LocalError) Unwrap

func (e *LocalError) Unwrap() error

type MissingCookiesError

type MissingCookiesError struct {
	Missing []string
}

MissingCookiesError names the required cookies a paste did not carry, and the domain each comes from -- because a paste scoped to .google.com alone silently omits OSID, which is the most common way this fails.

func (*MissingCookiesError) Error

func (e *MissingCookiesError) Error() string

type OperationFailedError

type OperationFailedError struct {
	OperationID string
	Status      string
	Detail      string
}

OperationFailedError is exit 8: an operation reached `failed` or `unknown` while `agm` was waiting on it. It is deliberately NOT what phone_not_responding produces -- that is exit 7 and the operation is still pending (section 11.2).

func (*OperationFailedError) Error

func (e *OperationFailedError) Error() string

type Output

type Output struct {
	// contains filtered or unexported fields
}

Output is the one place anything is written, and it exists to make spec section 11.3's promise structural rather than remembered:

"--json writes the server envelope plus CLI metadata as one value on
 stdout; diagnostics, progress, warnings and confirmation prompts go to
 stderr, so stdout stays machine-readable."

Every diagnostic method here writes to stderr. There is exactly one method that writes to stdout -- Emit -- and it writes one value. That is what makes `agm ... --json | jq` safe in a pipeline even when the command has something to say (section 16 Slice 2 test 26).

func (*Output) Emit

func (o *Output) Emit(r *Response) error

Emit writes the one result value. It is the only method that touches stdout.

func (*Output) Errorf

func (o *Output) Errorf(format string, args ...any)

Errorf writes the final error line. Stderr.

func (*Output) Infof

func (o *Output) Infof(format string, args ...any)

Infof writes progress. Stderr, and silenced by --quiet.

func (*Output) JSON

func (o *Output) JSON() bool

JSON reports whether stdout carries JSON rather than a table.

func (*Output) Verbosef

func (o *Output) Verbosef(format string, args ...any)

Verbosef writes diagnostic detail. Stderr, and only with --verbose.

func (*Output) Warnf

func (o *Output) Warnf(format string, args ...any)

Warnf writes a warning. Stderr, always: a warning that lands on stdout is a parse error for whatever is reading the result (spec section 11.3).

type PendingWrite

type PendingWrite struct {
	// contains filtered or unexported fields
}

PendingWrite is a proved-writable destination: a held lock and an open temporary file in the same directory as the destination, ready to be renamed into place.

func (*PendingWrite) Close

func (p *PendingWrite) Close()

Close abandons the write, removing the temporary file and dropping the lock. It is safe to call after Commit.

func (*PendingWrite) Commit

func (p *PendingWrite) Commit(data []byte) error

Commit writes the bytes and renames them into place atomically.

type Profile

type Profile struct {
	Server          string   `json:"server"`
	AccessToken     string   `json:"access_token,omitempty"`
	RefreshToken    string   `json:"refresh_token,omitempty"`
	Scopes          []string `json:"scopes,omitempty"`
	ExpiresAt       string   `json:"expires_at,omitempty"`
	AuthorizationID string   `json:"authorization_id,omitempty"`
	Issuer          string   `json:"issuer,omitempty"`
	Resource        string   `json:"resource,omitempty"`
	// ClientID is the dynamic registration this profile's tokens belong to.
	// It is stored because the OAuth refresh grant requires it (spec section
	// 9.6) and because a profile that could not name its client would have
	// to register a second one on every refresh.
	ClientID string `json:"client_id,omitempty"`
}

Profile is one server's stored authorization. It is bound to an exact server: changing --server selects the credentials for THAT server and never forwards one profile's token to a new origin (spec section 11.5).

type Request

type Request struct {
	Method string
	Path   string
	Query  url.Values
	// Body is marshalled as JSON. Nil sends no body.
	Body any
	// RawBody, when set, is sent verbatim with ContentType. It is the upload
	// PUT of section 10.2 and nothing else.
	RawBody     []byte
	ContentType string
	// IdempotencyKey sets the Idempotency-Key header (spec section 6.3). It
	// is set only by --idempotency-key; the header is the only transport
	// field so that one code path covers every mutation, including the two
	// DELETEs whose body would otherwise be empty.
	IdempotencyKey string
	// Token overrides the client's credential for this request. It carries
	// an upload or download ticket, which goes in the Authorization header
	// and never in the URL (spec section 10.3).
	Token string
	// AbsoluteURL, when set, replaces BaseURL+Path. The server hands out
	// upload_url and download_url built from AGENT_GM_PUBLIC_URL, and a
	// client that rebuilt them itself would defeat the point.
	AbsoluteURL string
	// Accept overrides the Accept header, for the SSE stream.
	Accept string
}

Request is one call. Path is a `/v1` path with its parameters already substituted; Query and Body are what the route inventory says the route accepts, and nothing else, because every route rejects an unknown one (spec section 7.1).

type Response

type Response struct {
	Status     int
	Data       json.RawMessage
	NextCursor *string
	Warnings   []string
	RequestID  string
	// Raw is the whole body, for a route that serves bytes rather than an
	// envelope.
	Raw []byte
	// Header is the response header, for Content-Disposition and the like.
	Header http.Header
}

Response is one answer. Data is the envelope's `data` left undecoded, so a command decodes only the shape it needs and `--json` can re-emit the envelope byte for byte.

type RouteEntry

type RouteEntry struct {
	Method     string
	Path       string
	Idempotent bool
}

RouteEntry is one row of the CLI's route table, exported for the test that holds it to internal/api's inventory.

type Store

type Store struct {
	Path string
	// LockWait bounds the wait for the credentials lock; zero means
	// DefaultLockWait. It is a field set at construction rather than a
	// package variable so that it cannot be changed after a write is
	// possible: a value made small fails closed, but a value made large
	// restores exactly the unbounded hang the deadline exists to remove.
	LockWait time.Duration
}

Store is the credentials file and the directory it lives in.

func NewStore

func NewStore(path string) *Store

NewStore builds a store for a path.

func (*Store) Begin

func (s *Store) Begin() (*PendingWrite, error)

Begin proves the destination writable and takes the advisory lock, WITHOUT writing anything yet.

This is spec section 11.5's write-back safety rule, and the ordering is the whole of it: the temporary file that the write will later rename into place is created FIRST, before the token is spent. An unwritable directory is therefore exit 9 while the token is still good -- nothing was consumed, so the operator fixes the machine and runs again. Rotation makes a refresh token single-use, so a run that exchanges one and then cannot store the replacement has lost it; that is the failure this ordering exists to prevent.

func (*Store) DeleteProfile

func (s *Store) DeleteProfile(w *PendingWrite, name string) error

DeleteProfile removes one profile. It never leaves `active_profile` naming a profile that is no longer there: removing the active one leaves the remaining profile active when exactly one remains, and otherwise leaves no active profile at all, so the next command says so rather than guessing.

func (*Store) Dir

func (s *Store) Dir() string

Dir is the directory the file and its lock live in.

func (*Store) Load

func (s *Store) Load() (Credentials, error)

Load reads the file. A missing file is an empty set of profiles, not an error: the first `agm auth login` on a machine has nothing to read.

func (*Store) SaveProfile

func (s *Store) SaveProfile(w *PendingWrite, name string, p Profile) error

SaveProfile stores one profile under the write-back safety rule: the caller must already hold a PendingWrite proving the destination writable. The profile it stores becomes the active one, because storing a credential the next command would not use is not what anyone typing `agm auth login` means.

func (*Store) SetActiveProfile

func (s *Store) SetActiveProfile(w *PendingWrite, name string) error

SetActiveProfile is `agm profiles use`. A name that is not stored is refused: it is exactly the typo that would otherwise leave the machine pointed at nothing.

func (*Store) UpdateProfile

func (s *Store) UpdateProfile(w *PendingWrite, name string, p Profile) error

UpdateProfile rewrites one profile in place, WITHOUT changing which profile is active. It is what a token rotation uses: refreshing the credential of a profile selected for one invocation with `--profile` must not silently make that profile the machine's active one.

type TransportError

type TransportError struct {
	Op  string
	Err error
}

TransportError is a request that never produced an envelope: a refused connection, a DNS failure, a read that timed out. Section 11.2 calls exit 7 "retryable network, Google, phone, or rate-limit failure", and a network that was not there is the first of those.

func (*TransportError) Error

func (e *TransportError) Error() string

func (*TransportError) Unwrap

func (e *TransportError) Unwrap() error

type UsageError

type UsageError struct {
	Flag string
	Msg  string
	Err  error
}

UsageError is exit 2: a usage or validation error decided locally. Flag names the flag at fault when there is one, because "invalid duration" with no flag name is a worse message than no message.

func (*UsageError) Error

func (e *UsageError) Error() string

func (*UsageError) Unwrap

func (e *UsageError) Unwrap() error

type WaitTimeoutError

type WaitTimeoutError struct {
	OperationID string
	WaitFor     string
}

WaitTimeoutError is exit 7: the wait ran out of time. The operation is not failed, so the message names it and says where to pick it up again.

func (*WaitTimeoutError) Error

func (e *WaitTimeoutError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL