maiactl 0.1.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.
- maiactl-0.1.0/.gitignore +112 -0
- maiactl-0.1.0/PKG-INFO +111 -0
- maiactl-0.1.0/README.md +103 -0
- maiactl-0.1.0/pyproject.toml +40 -0
- maiactl-0.1.0/src/maiactl/__init__.py +10 -0
- maiactl-0.1.0/src/maiactl/__main__.py +8 -0
- maiactl-0.1.0/src/maiactl/auth.py +109 -0
- maiactl-0.1.0/src/maiactl/cli.py +936 -0
- maiactl-0.1.0/src/maiactl/client.py +622 -0
- maiactl-0.1.0/src/maiactl/config.py +103 -0
- maiactl-0.1.0/src/maiactl/errors.py +36 -0
- maiactl-0.1.0/src/maiactl/render.py +171 -0
- maiactl-0.1.0/src/maiactl/upload.py +110 -0
- maiactl-0.1.0/tests/__init__.py +0 -0
- maiactl-0.1.0/tests/conftest.py +366 -0
- maiactl-0.1.0/tests/test_auth.py +182 -0
- maiactl-0.1.0/tests/test_bugs.py +194 -0
- maiactl-0.1.0/tests/test_cli.py +334 -0
- maiactl-0.1.0/tests/test_client.py +148 -0
- maiactl-0.1.0/tests/test_config.py +65 -0
- maiactl-0.1.0/tests/test_coverage_gaps.py +285 -0
- maiactl-0.1.0/tests/test_render.py +71 -0
maiactl-0.1.0/.gitignore
ADDED
|
@@ -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.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: maiactl
|
|
3
|
+
Version: 0.1.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 --from ./cli maiactl # from a checkout
|
|
22
|
+
maia login # opens your browser
|
|
23
|
+
maia projects
|
|
24
|
+
maia use MVP
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`login` runs the same brokered PKCE flow the desktop app uses: it opens
|
|
28
|
+
Google in your real browser and polls until the backend releases a token.
|
|
29
|
+
The token is written to `~/.config/maia/config.json` with mode 0600.
|
|
30
|
+
|
|
31
|
+
It is a normal app session token, not a scoped API key, so it carries your
|
|
32
|
+
full access and expires on the server's schedule. Treat the file as a
|
|
33
|
+
credential. `maia logout` removes it.
|
|
34
|
+
|
|
35
|
+
For CI or a throwaway shell, skip the file entirely:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export MAIA_TOKEN=... MAIA_TENANT_ID=...
|
|
39
|
+
export MAIA_PROJECT=MVP MAIA_API_URL=https://maia.cydratech.com
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Reading
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
maia ls # active tickets, doing first
|
|
46
|
+
maia ls --mine --status doing
|
|
47
|
+
maia ls --stage "In QA"
|
|
48
|
+
maia ls -s payment # title/description search
|
|
49
|
+
maia show MVP-42 # detail + comments
|
|
50
|
+
maia show MVP-42 --history
|
|
51
|
+
maia board # stages and card counts
|
|
52
|
+
maia members # who's on it, and their roles
|
|
53
|
+
maia stories
|
|
54
|
+
maia files MVP-42 --download ./tmp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Tickets are addressed by key (`MVP-42`), bare number (`42`), or a title
|
|
58
|
+
fragment. A fragment matching more than one ticket is an error, never a
|
|
59
|
+
guess.
|
|
60
|
+
|
|
61
|
+
## Writing
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
maia new "Payment webhook retries" -d "Stripe retries land as duplicates." \
|
|
65
|
+
--assign priya --stage "In Progress" --story "Billing"
|
|
66
|
+
|
|
67
|
+
git log -1 --format=%B | maia new "Ship the retry fix" -d -
|
|
68
|
+
|
|
69
|
+
maia mv MVP-42 doing # a status…
|
|
70
|
+
maia mv MVP-42 "In QA" # …or a stage by name
|
|
71
|
+
maia assign MVP-42 priya
|
|
72
|
+
maia story MVP-42 "Billing" # file under an existing story
|
|
73
|
+
maia comment MVP-42 "Deployed to staging." --mention priya
|
|
74
|
+
maia comment MVP-42 - < notes.md
|
|
75
|
+
maia attach MVP-42 screenshot.png trace.pdf
|
|
76
|
+
maia drop MVP-42 --reason "superseded by MVP-51"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Every command takes `--json` for programmatic use, and `-p CODE` to act on
|
|
80
|
+
a project other than the selected one.
|
|
81
|
+
|
|
82
|
+
## What it deliberately doesn't do
|
|
83
|
+
|
|
84
|
+
- **No stage (column) create, rename or delete.** Board structure is
|
|
85
|
+
admin-curated in the app.
|
|
86
|
+
- **No story create, edit or delete.** Same reason. You can file a ticket
|
|
87
|
+
into an existing story with `maia story`.
|
|
88
|
+
- **No ticket delete.** The API has none. `maia drop` sets the reversible
|
|
89
|
+
`dropped` status, which is what "scrap that" means on this board;
|
|
90
|
+
`maia mv <key> next` puts it back.
|
|
91
|
+
|
|
92
|
+
## For agents
|
|
93
|
+
|
|
94
|
+
Point your terminal agent at `maia --help` and the per-command
|
|
95
|
+
`maia <command> --help`; both are written to be read once and remembered.
|
|
96
|
+
Two habits worth putting in a project's `CLAUDE.md`:
|
|
97
|
+
|
|
98
|
+
- Prefer `--json` when parsing, plain output when showing a human.
|
|
99
|
+
- Narrow before listing. `maia ls` caps at 40 rows by default; on a big
|
|
100
|
+
board use `--mine`, `--stage`, or `-s` rather than `--limit 0`.
|
|
101
|
+
|
|
102
|
+
## Exit codes
|
|
103
|
+
|
|
104
|
+
| code | meaning |
|
|
105
|
+
|------|---------|
|
|
106
|
+
| 0 | fine |
|
|
107
|
+
| 1 | generic failure |
|
|
108
|
+
| 2 | not authenticated, or nothing selected |
|
|
109
|
+
| 3 | forbidden (e.g. you're an observer) |
|
|
110
|
+
| 4 | not found |
|
|
111
|
+
| 5 | conflict; refetch and retry |
|
maiactl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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 --from ./cli maiactl # from a checkout
|
|
14
|
+
maia login # opens your browser
|
|
15
|
+
maia projects
|
|
16
|
+
maia use MVP
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`login` runs the same brokered PKCE flow the desktop app uses: it opens
|
|
20
|
+
Google in your real browser and polls until the backend releases a token.
|
|
21
|
+
The token is written to `~/.config/maia/config.json` with mode 0600.
|
|
22
|
+
|
|
23
|
+
It is a normal app session token, not a scoped API key, so it carries your
|
|
24
|
+
full access and expires on the server's schedule. Treat the file as a
|
|
25
|
+
credential. `maia logout` removes it.
|
|
26
|
+
|
|
27
|
+
For CI or a throwaway shell, skip the file entirely:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export MAIA_TOKEN=... MAIA_TENANT_ID=...
|
|
31
|
+
export MAIA_PROJECT=MVP MAIA_API_URL=https://maia.cydratech.com
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Reading
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
maia ls # active tickets, doing first
|
|
38
|
+
maia ls --mine --status doing
|
|
39
|
+
maia ls --stage "In QA"
|
|
40
|
+
maia ls -s payment # title/description search
|
|
41
|
+
maia show MVP-42 # detail + comments
|
|
42
|
+
maia show MVP-42 --history
|
|
43
|
+
maia board # stages and card counts
|
|
44
|
+
maia members # who's on it, and their roles
|
|
45
|
+
maia stories
|
|
46
|
+
maia files MVP-42 --download ./tmp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Tickets are addressed by key (`MVP-42`), bare number (`42`), or a title
|
|
50
|
+
fragment. A fragment matching more than one ticket is an error, never a
|
|
51
|
+
guess.
|
|
52
|
+
|
|
53
|
+
## Writing
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
maia new "Payment webhook retries" -d "Stripe retries land as duplicates." \
|
|
57
|
+
--assign priya --stage "In Progress" --story "Billing"
|
|
58
|
+
|
|
59
|
+
git log -1 --format=%B | maia new "Ship the retry fix" -d -
|
|
60
|
+
|
|
61
|
+
maia mv MVP-42 doing # a status…
|
|
62
|
+
maia mv MVP-42 "In QA" # …or a stage by name
|
|
63
|
+
maia assign MVP-42 priya
|
|
64
|
+
maia story MVP-42 "Billing" # file under an existing story
|
|
65
|
+
maia comment MVP-42 "Deployed to staging." --mention priya
|
|
66
|
+
maia comment MVP-42 - < notes.md
|
|
67
|
+
maia attach MVP-42 screenshot.png trace.pdf
|
|
68
|
+
maia drop MVP-42 --reason "superseded by MVP-51"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every command takes `--json` for programmatic use, and `-p CODE` to act on
|
|
72
|
+
a project other than the selected one.
|
|
73
|
+
|
|
74
|
+
## What it deliberately doesn't do
|
|
75
|
+
|
|
76
|
+
- **No stage (column) create, rename or delete.** Board structure is
|
|
77
|
+
admin-curated in the app.
|
|
78
|
+
- **No story create, edit or delete.** Same reason. You can file a ticket
|
|
79
|
+
into an existing story with `maia story`.
|
|
80
|
+
- **No ticket delete.** The API has none. `maia drop` sets the reversible
|
|
81
|
+
`dropped` status, which is what "scrap that" means on this board;
|
|
82
|
+
`maia mv <key> next` puts it back.
|
|
83
|
+
|
|
84
|
+
## For agents
|
|
85
|
+
|
|
86
|
+
Point your terminal agent at `maia --help` and the per-command
|
|
87
|
+
`maia <command> --help`; both are written to be read once and remembered.
|
|
88
|
+
Two habits worth putting in a project's `CLAUDE.md`:
|
|
89
|
+
|
|
90
|
+
- Prefer `--json` when parsing, plain output when showing a human.
|
|
91
|
+
- Narrow before listing. `maia ls` caps at 40 rows by default; on a big
|
|
92
|
+
board use `--mine`, `--stage`, or `-s` rather than `--limit 0`.
|
|
93
|
+
|
|
94
|
+
## Exit codes
|
|
95
|
+
|
|
96
|
+
| code | meaning |
|
|
97
|
+
|------|---------|
|
|
98
|
+
| 0 | fine |
|
|
99
|
+
| 1 | generic failure |
|
|
100
|
+
| 2 | not authenticated, or nothing selected |
|
|
101
|
+
| 3 | forbidden (e.g. you're an observer) |
|
|
102
|
+
| 4 | not found |
|
|
103
|
+
| 5 | conflict; refetch and retry |
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maiactl"
|
|
3
|
+
version = "0.1.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.1.0"
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
|
|
28
|
+
|
|
29
|
+
def _pkce_pair() -> tuple[str, str]:
|
|
30
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(_VERIFIER_BYTES)).rstrip(b"=").decode()
|
|
31
|
+
challenge = (
|
|
32
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
33
|
+
)
|
|
34
|
+
return verifier, challenge
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def login(
|
|
38
|
+
config: Config,
|
|
39
|
+
*,
|
|
40
|
+
open_browser: bool = True,
|
|
41
|
+
echo=print,
|
|
42
|
+
transport: httpx.BaseTransport | None = None,
|
|
43
|
+
sleep=time.sleep,
|
|
44
|
+
) -> Config:
|
|
45
|
+
"""Run the flow to completion and return the config with a token on it.
|
|
46
|
+
|
|
47
|
+
Does not save — the caller decides, because `maia login` also needs to
|
|
48
|
+
pick a workspace before the file is worth writing.
|
|
49
|
+
|
|
50
|
+
``transport`` and ``sleep`` exist so tests can drive the poll loop
|
|
51
|
+
without a server and without waiting; nothing in the CLI passes them.
|
|
52
|
+
"""
|
|
53
|
+
base = f"{config.api_url}/api/v1"
|
|
54
|
+
verifier, challenge = _pkce_pair()
|
|
55
|
+
|
|
56
|
+
with httpx.Client(timeout=httpx.Timeout(30.0, connect=10.0), transport=transport) as http:
|
|
57
|
+
try:
|
|
58
|
+
r = http.post(f"{base}/auth/login/session", json={"code_challenge": challenge})
|
|
59
|
+
except httpx.HTTPError as e:
|
|
60
|
+
raise MaiaError(f"Couldn't reach {config.api_url}: {e}") from e
|
|
61
|
+
if not r.is_success:
|
|
62
|
+
detail = ""
|
|
63
|
+
try:
|
|
64
|
+
detail = r.json().get("detail", "")
|
|
65
|
+
except ValueError:
|
|
66
|
+
detail = r.text[:200]
|
|
67
|
+
raise MaiaError(f"Couldn't start a login session: {detail or r.status_code}")
|
|
68
|
+
|
|
69
|
+
started = r.json()
|
|
70
|
+
session_id = started["session_id"]
|
|
71
|
+
url = started["url"]
|
|
72
|
+
interval = float(started.get("interval") or 2)
|
|
73
|
+
expires_in = float(started.get("expires_in") or _MAX_WAIT_SECONDS)
|
|
74
|
+
|
|
75
|
+
echo("Opening your browser to sign in with Google.")
|
|
76
|
+
echo(f"If it doesn't open, paste this into a browser:\n\n {url}\n")
|
|
77
|
+
if open_browser:
|
|
78
|
+
try:
|
|
79
|
+
webbrowser.open(url)
|
|
80
|
+
except Exception:
|
|
81
|
+
# A headless box has no browser; the printed URL is the fallback.
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
echo("Waiting for you to finish signing in… (Ctrl-C to cancel)")
|
|
85
|
+
deadline = time.monotonic() + min(expires_in, _MAX_WAIT_SECONDS)
|
|
86
|
+
while time.monotonic() < deadline:
|
|
87
|
+
sleep(interval)
|
|
88
|
+
try:
|
|
89
|
+
poll = http.get(
|
|
90
|
+
f"{base}/auth/login/session/{session_id}",
|
|
91
|
+
headers={"X-Pkce-Verifier": verifier},
|
|
92
|
+
)
|
|
93
|
+
except httpx.HTTPError:
|
|
94
|
+
# Transient network blip mid-flow: keep polling until the
|
|
95
|
+
# session itself expires rather than failing the login.
|
|
96
|
+
continue
|
|
97
|
+
if poll.status_code == 410:
|
|
98
|
+
raise MaiaError("That login session expired.", hint="Run `maia login` again.")
|
|
99
|
+
if poll.status_code == 403:
|
|
100
|
+
raise MaiaError("Login verification failed (PKCE mismatch).")
|
|
101
|
+
if poll.status_code == 404:
|
|
102
|
+
raise MaiaError("That login session is gone.", hint="Run `maia login` again.")
|
|
103
|
+
if not poll.is_success:
|
|
104
|
+
continue
|
|
105
|
+
body = poll.json()
|
|
106
|
+
if body.get("status") == "complete" and body.get("token"):
|
|
107
|
+
config.token = body["token"]
|
|
108
|
+
return config
|
|
109
|
+
raise MaiaError("Timed out waiting for the browser sign-in.")
|