maiactl 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ # ── Python ──────────────────────────────────────────────────
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ build/
7
+
8
+ # ── Test / lint / type caches ──────────────────────────────
9
+ .mypy_cache/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ .coverage.*
14
+ htmlcov/
15
+ coverage/
16
+ .tox/
17
+
18
+ # ── Virtual environments ───────────────────────────────────
19
+ .venv/
20
+ venv/
21
+ env/
22
+
23
+ # ── IDE / editor ───────────────────────────────────────────
24
+ .idea/
25
+ .vscode/
26
+ *.swp
27
+ *.swo
28
+ *~
29
+
30
+ # ── OS ─────────────────────────────────────────────────────
31
+ .DS_Store
32
+ Thumbs.db
33
+
34
+ # ── Environment files (keep examples + frontend prod) ─────
35
+ # .env.production for the frontend has no secrets — it points at the
36
+ # same-origin Firebase Hosting rewrite. Keeping it tracked means anyone
37
+ # redeploying gets the prod config without manually recreating the file.
38
+ .env
39
+ .env.*
40
+ !.env.example
41
+ !frontend/.env.production
42
+
43
+ # ── Docker / databases ─────────────────────────────────────
44
+ pgdata/
45
+ *.db
46
+ *.sqlite
47
+ *.sqlite3
48
+
49
+ # ── uv ─────────────────────────────────────────────────────
50
+ backend/.python-version
51
+
52
+ # ── Node / Vite / Next ─────────────────────────────────────
53
+ node_modules/
54
+ .next/
55
+ dist/
56
+ .vite/
57
+ *.tsbuildinfo
58
+
59
+ # ── Logs ───────────────────────────────────────────────────
60
+ *.log
61
+ logs/
62
+
63
+ # ── Archives / bundles (not source) ────────────────────────
64
+ *.zip
65
+ *.tar
66
+ *.tar.gz
67
+ *.tgz
68
+
69
+ # ── Firebase ───────────────────────────────────────────────
70
+ # Per-machine deploy cache; firebase.json + .firebaserc stay tracked.
71
+ .firebase/
72
+ firebase-debug.log
73
+ firestore-debug.log
74
+ ui-debug.log
75
+
76
+ # ── Claude Code per-machine state ──────────────────────────
77
+ # scheduled_tasks.lock + settings.local.json are local to the operator,
78
+ # not part of the repo's shared config.
79
+ .claude/
80
+
81
+ # ── Misc ───────────────────────────────────────────────────
82
+ .cache/
83
+ tmp/
84
+ *.pid
85
+ messages.json
86
+
87
+ # ── Design handoff (generated static mockups) ──────────────
88
+ design-handoff/
89
+
90
+ # Google service-account JSON keys — NEVER commit. Local dev keys live in
91
+ # backend/.secrets/ (vertex-sa.json, maia-sa.json) and are gitignored as a
92
+ # directory so a new key dropped in won't accidentally leak. Prod uses
93
+ # Secret Manager / workload identity instead.
94
+ backend/.secrets/
95
+ # Legacy patterns kept so any straggler key at the repo root still gets caught.
96
+ /trusty-vim-*.json
97
+ /maia-493810-*.json
98
+ *-service-account*.json
99
+ *-gcp-key*.json
100
+ *-vertex*.json
101
+
102
+ client_secret_497788162142-r2a24nb8hl679nloc43jiqctpvmbb5v8.apps.googleusercontent.com.json
103
+ # Claude Code skill artifacts (local tooling, not app code)
104
+ .agents/
105
+ skills-lock.json
106
+ scratchpad.md
107
+
108
+ # ── Desktop (Electron) ──────────────────────────────────────
109
+ # build/ above is a Python pattern; desktop/build holds source-controlled
110
+ # packaging resources (icon, entitlements). dist output stays ignored.
111
+ !desktop/build/
112
+ desktop/dist/
maiactl-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.5
2
+ Name: maiactl
3
+ Version: 0.4.0
4
+ Summary: Drive the M.AI.A board from a terminal (and from terminal agents).
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.28.1
7
+ Description-Content-Type: text/markdown
8
+
9
+ # maia
10
+
11
+ The M.AI.A board from a terminal, for people and for the coding agents they
12
+ run there.
13
+
14
+ It talks to the same public HTTP API the web app uses, so it inherits the
15
+ server's rules rather than restating them: any active project member can
16
+ move, edit, assign or reassign any ticket, and observers are read-only.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ uv tool install maiactl # from PyPI (the package is maiactl; the command is maia)
22
+ maia login # opens your browser, then asks which workspace/project
23
+ maia # where am I, what next
24
+ maia doctor # if anything feels off
25
+ ```
26
+
27
+ Bare `maia` is a status card (offline): who you are, which workspace and
28
+ project, and what to try. `maia doctor` checks version, server, token,
29
+ workspace and project, and says how to fix each.
30
+
31
+ `login` runs the same brokered PKCE flow the desktop app uses: it opens
32
+ Google in your real browser and polls until the backend releases a token,
33
+ then finishes setting you up: if you're in several workspaces it asks which,
34
+ and if that workspace has several projects it asks which of those. One of
35
+ each is picked for you without a question. In a script or an agent shell
36
+ (no TTY) there's no prompt; pass `--tenant` and then `maia use <code>`.
37
+ The token is written to `~/.config/maia/config.json` with mode 0600.
38
+
39
+ ## Several accounts at once
40
+
41
+ Like `gh`, you can be signed into more than one account and switch between
42
+ them. An account is (email, server), so the same person on prod and on a
43
+ local backend are two accounts — which is the point.
44
+
45
+ ```bash
46
+ maia login # adds an account; never evicts an existing one
47
+ maia auth list # all of them, active one marked with *
48
+ maia auth switch priya # by email, or any unique fragment
49
+ maia auth logout --account priya # sign out of just that one
50
+ maia auth logout --all
51
+ ```
52
+
53
+ Each account remembers its own workspace and project, so switching restores
54
+ where you were. For a single command, `-a` (`--as`) acts as another account
55
+ without switching, and nothing that command does is saved:
56
+
57
+ ```bash
58
+ maia -a priya@cydratech.com ls --mine
59
+ maia ls --mine -a priya # flags may come before or after the command
60
+ ```
61
+
62
+ `-a` can't be combined with `login`, `logout` or `auth switch`; those manage
63
+ saved accounts, so name the account directly (`logout --account priya`).
64
+
65
+ ## Workspaces
66
+
67
+ ```bash
68
+ maia workspaces # the ones this account belongs to
69
+ maia workspace "Cydra Tech" # switch
70
+ ```
71
+
72
+ Switching workspace clears the selected project, because a project belongs
73
+ to exactly one workspace — keeping it would point at something the account
74
+ can no longer see. Pick a new one with `maia projects` and `maia use`.
75
+
76
+ It is a normal app session token, not a scoped API key, so it carries your
77
+ full access and expires on the server's schedule. Treat the file as a
78
+ credential. `maia logout` removes it.
79
+
80
+ For CI or a throwaway shell, skip the file entirely:
81
+
82
+ ```bash
83
+ export MAIA_TOKEN=... MAIA_TENANT_ID=...
84
+ export MAIA_PROJECT=MVP MAIA_API_URL=https://maia.cydratech.com
85
+ ```
86
+
87
+ Anything set this way applies to the current run only and is never written
88
+ to disk: a CI job can't persist its credentials, and `MAIA_PROJECT=X maia
89
+ use Y` won't change your saved project. Commands that would normally save
90
+ say `(not saved: …)` on stderr. `MAIA_ACCOUNT=priya` selects a saved account
91
+ for one run; a name that matches nothing is an error, not a silent fallback.
92
+ With `MAIA_TOKEN` set, `logout` and `auth switch` refuse, since there is no
93
+ saved session in play.
94
+
95
+ ## Reading
96
+
97
+ ```bash
98
+ maia ls # active tickets, doing first (alias: maia tickets)
99
+ maia status # same as bare `maia`
100
+ maia ls --mine --status doing
101
+ maia ls --stage "In QA"
102
+ maia ls -s payment # title/description search
103
+ maia show MVP-42 # detail + comments
104
+ maia show MVP-42 --history
105
+ maia board # stages and card counts
106
+ maia members # who's on it, and their roles
107
+ maia stories
108
+ maia files MVP-42 --download ./tmp
109
+ ```
110
+
111
+ Tickets are addressed by key (`MVP-42`), bare number (`42`), or a title
112
+ fragment. A fragment matching more than one ticket is an error, never a
113
+ guess.
114
+
115
+ ## Writing
116
+
117
+ ```bash
118
+ maia new "Payment webhook retries" -d "Stripe retries land as duplicates." \
119
+ --assign priya --stage "In Progress" --story "Billing"
120
+
121
+ git log -1 --format=%B | maia new "Ship the retry fix" -d -
122
+
123
+ maia mv MVP-42 doing # a status…
124
+ maia mv MVP-42 "In QA" # …or a stage by name
125
+ maia assign MVP-42 priya
126
+ maia story MVP-42 "Billing" # file under an existing story
127
+ maia comment MVP-42 "Deployed to staging." --mention priya
128
+ maia comment MVP-42 - < notes.md
129
+ maia attach MVP-42 screenshot.png trace.pdf
130
+ maia drop MVP-42 --reason "superseded by MVP-51" # project admins only
131
+ ```
132
+
133
+ Every command takes `--json` for programmatic use, and `-p CODE` to act on
134
+ a project other than the selected one for that command. Both may come
135
+ before or after the subcommand. Progress notes ("uploaded x.png") go to
136
+ stderr, so `--json` stdout is always a single parseable document.
137
+
138
+ ## What it deliberately doesn't do
139
+
140
+ - **No stage (column) create, rename or delete.** Board structure is
141
+ admin-curated in the app.
142
+ - **No story create, edit or delete.** Same reason. You can file a ticket
143
+ into an existing story with `maia story`.
144
+ - **No ticket delete.** The API has none. `maia drop` sets the reversible
145
+ `dropped` status, which is what "scrap that" means on this board;
146
+ `maia mv <key> next` puts it back. Dropping is **project-admin only from
147
+ the CLI** (the app lets any member), and `maia mv <key> dropped` is held
148
+ to the same rule.
149
+
150
+ ## For agents
151
+
152
+ ```bash
153
+ maia agent-guide # a cheat-sheet written for an LLM
154
+ maia agent-guide --write CLAUDE.md # drop it into the repo (idempotent; re-run to refresh)
155
+ ```
156
+
157
+ The guide tells the agent to use `--json`, never to run `login` itself,
158
+ how tickets are addressed, the exit codes, and which rules the CLI
159
+ enforces. With `--json` on, errors are JSON on stderr too
160
+ (`{"error", "hint", "exit_code"}`), so failures parse as cleanly as
161
+ successes.
162
+
163
+ An agent on your machine shares your saved login. For a CI job or a shell
164
+ on another box, hand it your identity for one run:
165
+
166
+ ```bash
167
+ eval "$(maia auth env)" # exports MAIA_API_URL, MAIA_TOKEN, MAIA_TENANT_ID, MAIA_PROJECT
168
+ MAIA_TOKEN=$(maia auth token) # or just the token
169
+ ```
170
+
171
+ ## Exit codes
172
+
173
+ | code | meaning |
174
+ |------|---------|
175
+ | 0 | fine |
176
+ | 1 | generic failure |
177
+ | 2 | not authenticated, or nothing selected |
178
+ | 3 | forbidden (e.g. you're an observer) |
179
+ | 4 | not found (a ticket, member, story, stage, project or workspace) |
180
+ | 5 | conflict; refetch and retry |
@@ -0,0 +1,172 @@
1
+ # maia
2
+
3
+ The M.AI.A board from a terminal, for people and for the coding agents they
4
+ run there.
5
+
6
+ It talks to the same public HTTP API the web app uses, so it inherits the
7
+ server's rules rather than restating them: any active project member can
8
+ move, edit, assign or reassign any ticket, and observers are read-only.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ uv tool install maiactl # from PyPI (the package is maiactl; the command is maia)
14
+ maia login # opens your browser, then asks which workspace/project
15
+ maia # where am I, what next
16
+ maia doctor # if anything feels off
17
+ ```
18
+
19
+ Bare `maia` is a status card (offline): who you are, which workspace and
20
+ project, and what to try. `maia doctor` checks version, server, token,
21
+ workspace and project, and says how to fix each.
22
+
23
+ `login` runs the same brokered PKCE flow the desktop app uses: it opens
24
+ Google in your real browser and polls until the backend releases a token,
25
+ then finishes setting you up: if you're in several workspaces it asks which,
26
+ and if that workspace has several projects it asks which of those. One of
27
+ each is picked for you without a question. In a script or an agent shell
28
+ (no TTY) there's no prompt; pass `--tenant` and then `maia use <code>`.
29
+ The token is written to `~/.config/maia/config.json` with mode 0600.
30
+
31
+ ## Several accounts at once
32
+
33
+ Like `gh`, you can be signed into more than one account and switch between
34
+ them. An account is (email, server), so the same person on prod and on a
35
+ local backend are two accounts — which is the point.
36
+
37
+ ```bash
38
+ maia login # adds an account; never evicts an existing one
39
+ maia auth list # all of them, active one marked with *
40
+ maia auth switch priya # by email, or any unique fragment
41
+ maia auth logout --account priya # sign out of just that one
42
+ maia auth logout --all
43
+ ```
44
+
45
+ Each account remembers its own workspace and project, so switching restores
46
+ where you were. For a single command, `-a` (`--as`) acts as another account
47
+ without switching, and nothing that command does is saved:
48
+
49
+ ```bash
50
+ maia -a priya@cydratech.com ls --mine
51
+ maia ls --mine -a priya # flags may come before or after the command
52
+ ```
53
+
54
+ `-a` can't be combined with `login`, `logout` or `auth switch`; those manage
55
+ saved accounts, so name the account directly (`logout --account priya`).
56
+
57
+ ## Workspaces
58
+
59
+ ```bash
60
+ maia workspaces # the ones this account belongs to
61
+ maia workspace "Cydra Tech" # switch
62
+ ```
63
+
64
+ Switching workspace clears the selected project, because a project belongs
65
+ to exactly one workspace — keeping it would point at something the account
66
+ can no longer see. Pick a new one with `maia projects` and `maia use`.
67
+
68
+ It is a normal app session token, not a scoped API key, so it carries your
69
+ full access and expires on the server's schedule. Treat the file as a
70
+ credential. `maia logout` removes it.
71
+
72
+ For CI or a throwaway shell, skip the file entirely:
73
+
74
+ ```bash
75
+ export MAIA_TOKEN=... MAIA_TENANT_ID=...
76
+ export MAIA_PROJECT=MVP MAIA_API_URL=https://maia.cydratech.com
77
+ ```
78
+
79
+ Anything set this way applies to the current run only and is never written
80
+ to disk: a CI job can't persist its credentials, and `MAIA_PROJECT=X maia
81
+ use Y` won't change your saved project. Commands that would normally save
82
+ say `(not saved: …)` on stderr. `MAIA_ACCOUNT=priya` selects a saved account
83
+ for one run; a name that matches nothing is an error, not a silent fallback.
84
+ With `MAIA_TOKEN` set, `logout` and `auth switch` refuse, since there is no
85
+ saved session in play.
86
+
87
+ ## Reading
88
+
89
+ ```bash
90
+ maia ls # active tickets, doing first (alias: maia tickets)
91
+ maia status # same as bare `maia`
92
+ maia ls --mine --status doing
93
+ maia ls --stage "In QA"
94
+ maia ls -s payment # title/description search
95
+ maia show MVP-42 # detail + comments
96
+ maia show MVP-42 --history
97
+ maia board # stages and card counts
98
+ maia members # who's on it, and their roles
99
+ maia stories
100
+ maia files MVP-42 --download ./tmp
101
+ ```
102
+
103
+ Tickets are addressed by key (`MVP-42`), bare number (`42`), or a title
104
+ fragment. A fragment matching more than one ticket is an error, never a
105
+ guess.
106
+
107
+ ## Writing
108
+
109
+ ```bash
110
+ maia new "Payment webhook retries" -d "Stripe retries land as duplicates." \
111
+ --assign priya --stage "In Progress" --story "Billing"
112
+
113
+ git log -1 --format=%B | maia new "Ship the retry fix" -d -
114
+
115
+ maia mv MVP-42 doing # a status…
116
+ maia mv MVP-42 "In QA" # …or a stage by name
117
+ maia assign MVP-42 priya
118
+ maia story MVP-42 "Billing" # file under an existing story
119
+ maia comment MVP-42 "Deployed to staging." --mention priya
120
+ maia comment MVP-42 - < notes.md
121
+ maia attach MVP-42 screenshot.png trace.pdf
122
+ maia drop MVP-42 --reason "superseded by MVP-51" # project admins only
123
+ ```
124
+
125
+ Every command takes `--json` for programmatic use, and `-p CODE` to act on
126
+ a project other than the selected one for that command. Both may come
127
+ before or after the subcommand. Progress notes ("uploaded x.png") go to
128
+ stderr, so `--json` stdout is always a single parseable document.
129
+
130
+ ## What it deliberately doesn't do
131
+
132
+ - **No stage (column) create, rename or delete.** Board structure is
133
+ admin-curated in the app.
134
+ - **No story create, edit or delete.** Same reason. You can file a ticket
135
+ into an existing story with `maia story`.
136
+ - **No ticket delete.** The API has none. `maia drop` sets the reversible
137
+ `dropped` status, which is what "scrap that" means on this board;
138
+ `maia mv <key> next` puts it back. Dropping is **project-admin only from
139
+ the CLI** (the app lets any member), and `maia mv <key> dropped` is held
140
+ to the same rule.
141
+
142
+ ## For agents
143
+
144
+ ```bash
145
+ maia agent-guide # a cheat-sheet written for an LLM
146
+ maia agent-guide --write CLAUDE.md # drop it into the repo (idempotent; re-run to refresh)
147
+ ```
148
+
149
+ The guide tells the agent to use `--json`, never to run `login` itself,
150
+ how tickets are addressed, the exit codes, and which rules the CLI
151
+ enforces. With `--json` on, errors are JSON on stderr too
152
+ (`{"error", "hint", "exit_code"}`), so failures parse as cleanly as
153
+ successes.
154
+
155
+ An agent on your machine shares your saved login. For a CI job or a shell
156
+ on another box, hand it your identity for one run:
157
+
158
+ ```bash
159
+ eval "$(maia auth env)" # exports MAIA_API_URL, MAIA_TOKEN, MAIA_TENANT_ID, MAIA_PROJECT
160
+ MAIA_TOKEN=$(maia auth token) # or just the token
161
+ ```
162
+
163
+ ## Exit codes
164
+
165
+ | code | meaning |
166
+ |------|---------|
167
+ | 0 | fine |
168
+ | 1 | generic failure |
169
+ | 2 | not authenticated, or nothing selected |
170
+ | 3 | forbidden (e.g. you're an observer) |
171
+ | 4 | not found (a ticket, member, story, stage, project or workspace) |
172
+ | 5 | conflict; refetch and retry |
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "maiactl"
3
+ version = "0.4.0"
4
+ description = "Drive the M.AI.A board from a terminal (and from terminal agents)."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ # httpx only, on purpose. This package is installed by developers on their
8
+ # own machines (and run via `uvx`), so every dependency is a thing that can
9
+ # break someone's shell. The backend package is NOT a dependency: the CLI
10
+ # talks to the same public HTTP API a browser does.
11
+ dependencies = ["httpx>=0.28.1"]
12
+
13
+ [project.scripts]
14
+ maia = "maiactl.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/maiactl"]
22
+
23
+ [dependency-groups]
24
+ dev = ["pytest>=9.0.3", "ruff>=0.15.11"]
25
+
26
+ [tool.ruff]
27
+ line-length = 100
28
+ target-version = "py310"
29
+
30
+ # Same ruleset the backend uses, so a contributor moving between the two
31
+ # doesn't hit different lint opinions.
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "N", "W", "UP"]
34
+ ignore = ["E501"]
35
+
36
+ [tool.ruff.lint.isort]
37
+ known-first-party = ["maiactl"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ """maia — the M.AI.A board from a terminal.
2
+
3
+ Layering, deliberately: ``client.py`` holds every operation and knows
4
+ nothing about argv, stdout or exit codes; ``cli.py`` is the argument
5
+ parsing and rendering shell over it. A future MCP server is a second
6
+ shell over the same ``MaiaClient`` rather than a reimplementation, which
7
+ is the whole reason the split exists.
8
+ """
9
+
10
+ __version__ = "0.4.0"
@@ -0,0 +1,8 @@
1
+ """``python -m maiactl`` runs the same entry point as the ``maia`` script."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ """Browser login for a terminal, over the backend's brokered poll flow.
2
+
3
+ This is the same flow the desktop app uses, and it exists because a CLI
4
+ can't safely host an OAuth redirect. We generate a PKCE verifier, ask the
5
+ backend to start a session, open the user's real browser at Google, and
6
+ poll until the backend releases a JWT. The verifier never leaves this
7
+ process except as a header on the poll, and it's what binds redemption to
8
+ us: a leaked session id on its own buys nothing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import secrets
16
+ import time
17
+ import webbrowser
18
+
19
+ import httpx
20
+
21
+ from .config import Config
22
+ from .errors import MaiaError
23
+
24
+ # The server allows 43-128 chars; 32 random bytes lands at 43 base64url chars.
25
+ _VERIFIER_BYTES = 32
26
+ _MAX_WAIT_SECONDS = 300
27
+ # Consecutive network errors / 5xx tolerated before the login gives up.
28
+ _MAX_FLAKY_POLLS = 8
29
+
30
+
31
+ def _pkce_pair() -> tuple[str, str]:
32
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(_VERIFIER_BYTES)).rstrip(b"=").decode()
33
+ challenge = (
34
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
35
+ )
36
+ return verifier, challenge
37
+
38
+
39
+ def login(
40
+ config: Config,
41
+ *,
42
+ open_browser: bool = True,
43
+ echo=print,
44
+ transport: httpx.BaseTransport | None = None,
45
+ sleep=time.sleep,
46
+ ) -> Config:
47
+ """Run the flow to completion and return the config with a token on it.
48
+
49
+ Does not save — the caller decides, because `maia login` also needs to
50
+ pick a workspace before the file is worth writing.
51
+
52
+ ``transport`` and ``sleep`` exist so tests can drive the poll loop
53
+ without a server and without waiting; nothing in the CLI passes them.
54
+ """
55
+ base = f"{config.api_url}/api/v1"
56
+ verifier, challenge = _pkce_pair()
57
+
58
+ with httpx.Client(timeout=httpx.Timeout(30.0, connect=10.0), transport=transport) as http:
59
+ try:
60
+ r = http.post(f"{base}/auth/login/session", json={"code_challenge": challenge})
61
+ except httpx.HTTPError as e:
62
+ raise MaiaError(f"Couldn't reach {config.api_url}: {e}") from e
63
+ if not r.is_success:
64
+ detail = ""
65
+ try:
66
+ detail = r.json().get("detail", "")
67
+ except ValueError:
68
+ detail = r.text[:200]
69
+ raise MaiaError(f"Couldn't start a login session: {detail or r.status_code}")
70
+
71
+ started = r.json()
72
+ session_id = started["session_id"]
73
+ url = started["url"]
74
+ interval = float(started.get("interval") or 2)
75
+ expires_in = float(started.get("expires_in") or _MAX_WAIT_SECONDS)
76
+
77
+ echo("Opening your browser to sign in with Google.")
78
+ echo(f"If it doesn't open, paste this into a browser:\n\n {url}\n")
79
+ if open_browser:
80
+ try:
81
+ webbrowser.open(url)
82
+ except Exception:
83
+ # A headless box has no browser; the printed URL is the fallback.
84
+ pass
85
+
86
+ echo("Waiting for you to finish signing in… (Ctrl-C to cancel)")
87
+ deadline = time.monotonic() + min(expires_in, _MAX_WAIT_SECONDS)
88
+ # Transient trouble (a network blip, a 5xx) is retried, but only so
89
+ # many times in a row: the old loop swallowed EVERY unexpected
90
+ # answer as "still pending" and could spin silently for the full
91
+ # five minutes against a server that was never going to say yes.
92
+ flaky_in_a_row = 0
93
+ while time.monotonic() < deadline:
94
+ sleep(interval)
95
+ try:
96
+ poll = http.get(
97
+ f"{base}/auth/login/session/{session_id}",
98
+ headers={"X-Pkce-Verifier": verifier},
99
+ )
100
+ except httpx.HTTPError as e:
101
+ flaky_in_a_row += 1
102
+ if flaky_in_a_row >= _MAX_FLAKY_POLLS:
103
+ raise MaiaError(
104
+ f"Lost contact with {config.api_url} while signing in: {e}"
105
+ ) from e
106
+ continue
107
+ if poll.status_code == 410:
108
+ raise MaiaError("That login session expired.", hint="Run `maia login` again.")
109
+ if poll.status_code == 403:
110
+ raise MaiaError("Login verification failed (PKCE mismatch).")
111
+ if poll.status_code == 404:
112
+ raise MaiaError("That login session is gone.", hint="Run `maia login` again.")
113
+ if poll.status_code >= 500:
114
+ flaky_in_a_row += 1
115
+ if flaky_in_a_row >= _MAX_FLAKY_POLLS:
116
+ raise MaiaError(
117
+ f"{config.api_url} kept failing (HTTP {poll.status_code}) while signing in."
118
+ )
119
+ continue
120
+ if not poll.is_success:
121
+ # Any other 4xx means OUR request is wrong (a 422 on the
122
+ # verifier header, a 401 from a proxy). Retrying can't fix it.
123
+ detail = ""
124
+ try:
125
+ detail = str(poll.json().get("detail", ""))[:200]
126
+ except ValueError:
127
+ detail = poll.text[:200]
128
+ raise MaiaError(
129
+ f"The server rejected the sign-in poll (HTTP {poll.status_code}). {detail}".strip()
130
+ )
131
+ flaky_in_a_row = 0
132
+ body = poll.json()
133
+ if body.get("status") == "complete" and body.get("token"):
134
+ config.token = body["token"]
135
+ return config
136
+ raise MaiaError("Timed out waiting for the browser sign-in.")