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.
- maiactl-0.4.0/.gitignore +112 -0
- maiactl-0.4.0/PKG-INFO +180 -0
- maiactl-0.4.0/README.md +172 -0
- maiactl-0.4.0/pyproject.toml +40 -0
- maiactl-0.4.0/src/maiactl/__init__.py +10 -0
- maiactl-0.4.0/src/maiactl/__main__.py +8 -0
- maiactl-0.4.0/src/maiactl/auth.py +136 -0
- maiactl-0.4.0/src/maiactl/cli.py +1672 -0
- maiactl-0.4.0/src/maiactl/client.py +624 -0
- maiactl-0.4.0/src/maiactl/config.py +418 -0
- maiactl-0.4.0/src/maiactl/errors.py +36 -0
- maiactl-0.4.0/src/maiactl/render.py +194 -0
- maiactl-0.4.0/src/maiactl/upload.py +114 -0
- maiactl-0.4.0/tests/__init__.py +0 -0
- maiactl-0.4.0/tests/conftest.py +370 -0
- maiactl-0.4.0/tests/test_accounts.py +779 -0
- maiactl-0.4.0/tests/test_auth.py +275 -0
- maiactl-0.4.0/tests/test_bugs.py +208 -0
- maiactl-0.4.0/tests/test_cli.py +564 -0
- maiactl-0.4.0/tests/test_client.py +148 -0
- maiactl-0.4.0/tests/test_config.py +65 -0
- maiactl-0.4.0/tests/test_coverage_gaps.py +378 -0
- maiactl-0.4.0/tests/test_render.py +92 -0
- maiactl-0.4.0/uv.lock +258 -0
maiactl-0.4.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.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 |
|
maiactl-0.4.0/README.md
ADDED
|
@@ -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,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.")
|