odoo-agent-cli 0.3.0__tar.gz → 0.5.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.
Files changed (30) hide show
  1. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/.gitignore +3 -0
  2. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/PKG-INFO +97 -12
  3. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/README.md +96 -11
  4. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/SKILL.md +72 -7
  5. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/pyproject.toml +17 -1
  6. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/AGENT_GUIDE.md +72 -7
  7. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/__init__.py +2 -0
  8. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/_version.py +1 -1
  9. odoo_agent_cli-0.5.0/src/odoocli/aliases.py +310 -0
  10. odoo_agent_cli-0.5.0/src/odoocli/cli/alias_cmd.py +41 -0
  11. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/app.py +174 -5
  12. odoo_agent_cli-0.5.0/src/odoocli/cli/cache_cmds.py +72 -0
  13. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/profile_cmds.py +39 -5
  14. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/read_cmds.py +121 -20
  15. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/write_cmds.py +33 -21
  16. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/config.py +33 -4
  17. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/domain.py +88 -14
  18. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/errors.py +11 -0
  19. odoo_agent_cli-0.5.0/src/odoocli/schema.py +362 -0
  20. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/LICENSE +0 -0
  21. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/__main__.py +0 -0
  22. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/__init__.py +0 -0
  23. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/guide_cmd.py +0 -0
  24. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/output.py +0 -0
  25. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/cli/values.py +0 -0
  26. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/client.py +0 -0
  27. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/lenient.py +0 -0
  28. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/py.typed +0 -0
  29. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/security.py +0 -0
  30. {odoo_agent_cli-0.3.0 → odoo_agent_cli-0.5.0}/src/odoocli/sync.py +0 -0
@@ -11,3 +11,6 @@ build/
11
11
  htmlcov/
12
12
  .env
13
13
  .DS_Store
14
+
15
+ # mkdocs build output
16
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: odoo-agent-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Odoo JSON-RPC CLI and Python client built for AI agents and scripts.
5
5
  Project-URL: Homepage, https://github.com/Organize-IT/odoo-cli
6
6
  Project-URL: Issues, https://github.com/Organize-IT/odoo-cli/issues
@@ -53,14 +53,22 @@ export ODOO_URL=https://mycompany.odoo.com ODOO_DB=mycompany \
53
53
  ODOO_LOGIN=bot@mycompany.com ODOO_API_KEY=... # API key or password
54
54
 
55
55
  odoo info # version, uid, connection source
56
- odoo models --like invoice # find the right technical name
56
+ odoo alias # business names for models, and presets
57
+ odoo models --like invoice # or find the technical name yourself
57
58
  odoo fields account.move --stored # what you can filter and order on
58
- odoo count account.move -w move_type=out_invoice -w payment_state=not_paid
59
- odoo search account.move -w move_type=out_invoice -w invoice_date_due<2026-09-01 \
59
+ odoo count invoices -w overdue
60
+ odoo search invoices -w overdue \
60
61
  --fields name,partner_id,amount_residual --order "invoice_date_due" --limit 20
62
+ odoo group invoices -w unpaid --by partner_id --sum amount_residual
61
63
  ```
62
64
 
63
- Prefer named connections? They live in a `0600` TOML file:
65
+ `invoices` is `account.move` filtered on `move_type = out_invoice`, and `overdue` is
66
+ `payment_state = not_paid` past its due date. Both expand to an ordinary domain before
67
+ anything is sent, and the technical names keep working. Full documentation:
68
+ [Organize-IT.github.io/odoo-cli](https://github.com/Organize-IT/odoo-cli/tree/main/docs).
69
+
70
+ Prefer named connections? They live in a TOML file written owner-only where the platform
71
+ can enforce that — `odoo profile path --check` says whether it can:
64
72
 
65
73
  ```bash
66
74
  odoo profile add acme --url https://acme.odoo.com --db acme --login bot@acme.com \
@@ -77,7 +85,10 @@ First match wins, and the CLI never prompts:
77
85
  3. a profile named `default`
78
86
 
79
87
  Nothing found: exit code 3 with a message listing those three ways. A profile stores the key
80
- (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file.
88
+ (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file,
89
+ `--check` reports what its permissions are actually worth: mode 600 means owner-only on
90
+ POSIX and nothing at all on Windows, where `chmod` only toggles a read-only attribute. On
91
+ such a platform, prefer `--api-key-env` and keep the key in your secret manager.
81
92
 
82
93
  ## Output contract
83
94
 
@@ -89,6 +100,7 @@ Nothing found: exit code 3 with a message listing those three ways. A profile st
89
100
  | bad arguments | | `{"error": ...}` | 2 |
90
101
  | connection, auth, no profile | | `{"error": ...}` | 3 |
91
102
  | refused by a guard | | `{"error": ...}` | 4 |
103
+ | query repaired to run | rows | `{"error": ...}` | 5 |
92
104
  | write executed | result | `{"write": {"model", "method", "ids", "fields"}}` | 0 |
93
105
 
94
106
  Data is never humanised: many2one fields stay `[id, "name"]`, empty values stay `false`.
@@ -102,6 +114,7 @@ Values of fields named like `password`, `api_key`, `secret` are `[redacted]` unl
102
114
  | `--company ID`, `--lang CODE`, `--context JSON` | Odoo context merged into every call |
103
115
  | `--insecure` | skip TLS verification (self-signed on-prem) |
104
116
  | `--no-redact`, `--include-sensitive` | lift the two output/model guards |
117
+ | `--no-validate` | skip the field-name check against the model schema |
105
118
  | `--debug` | one JSON line per RPC on stderr (method, id, duration, retries) |
106
119
  | `--verbose` | include Odoo's server traceback in error output |
107
120
 
@@ -122,13 +135,76 @@ Values of fields named like `password`, `api_key`, `secret` are `[redacted]` unl
122
135
 
123
136
  Values: `true/false/null`, integers, floats, JSON lists or objects, quoted strings, else text.
124
137
 
138
+ A bare word is looked up as a preset for the model: `-w overdue`, `-w unpaid`, `-w draft`,
139
+ `-w confirmed`, `-w archived`. `odoo alias MODEL --presets` lists the ones that apply. A bare
140
+ word that is not a preset exits 2 listing the ones that are.
141
+
142
+ ## Your own names
143
+
144
+ The shipped table covers what most tenants call things. A profile file adds the rest, and a
145
+ user entry replaces a built-in of the same name — your tenant knows its vocabulary better than
146
+ this tool does:
147
+
148
+ ```toml
149
+ [aliases.subscriptions]
150
+ model = "sale.subscription"
151
+ domain = [["stage_category", "=", "progress"]]
152
+ help = "Running subscriptions"
153
+
154
+ [aliases.invoices] # replaces the built-in
155
+ model = "account.move"
156
+ domain = [["move_type", "=", "out_invoice"], ["company_id", "=", 3]]
157
+
158
+ [presets.mine]
159
+ domain = [["user_id", "=", 7]]
160
+ models = ["crm.lead", "sale.order"]
161
+ ```
162
+
163
+ `odoo alias` marks each entry `builtin` or `config`. A malformed table fails the command with
164
+ exit 2 instead of being skipped: a filter you believe is applied and is not is exactly what
165
+ this mechanism exists to avoid. Dynamic dates work too — `@today`, `@month-start`, `@year-start`.
166
+
167
+ ## Names and typos
168
+
169
+ Read commands accept an alias in place of a technical model name (`invoices`, `customers`,
170
+ `quotes`, `pickings`, ...); `odoo alias` prints the table and needs no connection. Write
171
+ commands refuse an alias that carries a filter, so a vendor bill cannot be filed as a
172
+ customer invoice by accident.
173
+
174
+ Field names are checked against the model schema before the call, which costs nothing after
175
+ the first lookup:
176
+
177
+ ```console
178
+ $ odoo search partners -w mobil=+32
179
+ {"error": {"code": "unknown_field", "message": "res.partner has no field 'mobil'. Did you mean 'mobile'? (also: phone)"}}
180
+ $ echo $?
181
+ 2
182
+ ```
183
+
184
+ `fields_get` is read once per model and cached under `~/.cache/odoo-cli` for 24 hours
185
+ (`odoo cache path|list|clear`, `ODOO_CACHE_DIR`, `ODOO_SCHEMA_TTL`). Dotted paths are
186
+ followed across relations. A computed non-stored field used in `-w` or `--order` warns on
187
+ stderr rather than failing. Validation only rejects a name it has positively read from the
188
+ server: when the schema is unreadable it stays quiet, so it can never refuse wrongly.
189
+ `--no-validate` or `ODOO_NO_VALIDATE=1` turns it off.
190
+
191
+ ## Aggregates
192
+
193
+ ```bash
194
+ odoo group invoices -w overdue --by partner_id --sum amount_residual \
195
+ --order "amount_residual desc" --limit 10
196
+ odoo group invoices --by invoice_date:month --sum amount_total
197
+ ```
198
+
199
+ `read_group` under the hood: counts and totals per group without pulling the records.
200
+
125
201
  ## Writes
126
202
 
127
203
  Off by default. Enable per connection with `allow_writes = true` on the profile
128
204
  (`odoo profile add ... --allow-writes`) or `ODOO_ALLOW_WRITES=1`.
129
205
 
130
206
  ```bash
131
- odoo create crm.lead -v name="Website inquiry" -v partner_id=42 --dry-run # shows payload, exit 0
207
+ odoo create crm.lead -v name="Website inquiry" -v partner_id=42 --dry-run # payload only, exit 0
132
208
  odoo create crm.lead -v name="Website inquiry" -v partner_id=42 # prints the new id
133
209
  odoo write res.partner 42,43 -v active=false
134
210
  odoo unlink res.partner 99 --yes # --yes required
@@ -146,8 +222,9 @@ are refused unless `--include-sensitive`.
146
222
  many2one shapes) and recipes.
147
223
  - The same text ships as an [Agent Skill](https://github.com/Organize-IT/odoo-cli/blob/main/SKILL.md):
148
224
  `npx skills add Organize-IT/odoo-cli`.
149
- - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries, with a warning on
150
- stderr. Exploration only.
225
+ - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries. It prints the
226
+ rows, then exits **5**: they answer a wider question than the one you asked. Exploration
227
+ stays comfortable; a script that checks its exit codes cannot be fooled by it.
151
228
 
152
229
  ## Library
153
230
 
@@ -190,8 +267,11 @@ repair loop in `odoocli.lenient`.
190
267
 
191
268
  ```bash
192
269
  uv sync --group dev
270
+ uvx pre-commit install # ruff, mypy and hygiene hooks on commit
193
271
  uv run pytest # unit tests, mocked JSON-RPC
194
272
  uv run ruff check && uv run mypy
273
+ uv run pytest --cov --cov-report=term-missing # gated at 93% in CI
274
+ uv run --group docs mkdocs serve # the documentation site
195
275
 
196
276
  ODOO_VERSION=17.0 scripts/start-odoo.sh # throwaway Odoo in Docker
197
277
  ODOO_URL=http://localhost:8069 ODOO_DB=test ODOO_LOGIN=admin ODOO_API_KEY=admin \
@@ -199,9 +279,14 @@ ODOO_ALLOW_WRITES=1 uv run pytest -m integration -o addopts=""
199
279
  docker compose -f docker/odoo-compose.yml down -v
200
280
  ```
201
281
 
202
- CI runs the unit suite on every PR and the integration matrix (Odoo 17.0, 18.0, 19.0) on
203
- `main`, tags and manual dispatch. Releases are published to PyPI on `v*` tags through
204
- trusted publishing.
282
+ CI runs the unit suite on Python 3.11-3.13 and builds the docs on every PR; the integration
283
+ matrix (Odoo 17.0, 18.0, 19.0) runs on `main`, tags and manual dispatch. Releases are
284
+ published to PyPI on `v*` tags through trusted publishing, with every action pinned to a
285
+ commit digest.
286
+
287
+ `AGENTS.md` is the specification: layering, the contracts that may not change silently, and
288
+ the definition of done. [docs/decisions/](docs/decisions/) records the decisions with a real
289
+ trade-off behind them. Read both before changing anything.
205
290
 
206
291
  ## License
207
292
 
@@ -26,14 +26,22 @@ export ODOO_URL=https://mycompany.odoo.com ODOO_DB=mycompany \
26
26
  ODOO_LOGIN=bot@mycompany.com ODOO_API_KEY=... # API key or password
27
27
 
28
28
  odoo info # version, uid, connection source
29
- odoo models --like invoice # find the right technical name
29
+ odoo alias # business names for models, and presets
30
+ odoo models --like invoice # or find the technical name yourself
30
31
  odoo fields account.move --stored # what you can filter and order on
31
- odoo count account.move -w move_type=out_invoice -w payment_state=not_paid
32
- odoo search account.move -w move_type=out_invoice -w invoice_date_due<2026-09-01 \
32
+ odoo count invoices -w overdue
33
+ odoo search invoices -w overdue \
33
34
  --fields name,partner_id,amount_residual --order "invoice_date_due" --limit 20
35
+ odoo group invoices -w unpaid --by partner_id --sum amount_residual
34
36
  ```
35
37
 
36
- Prefer named connections? They live in a `0600` TOML file:
38
+ `invoices` is `account.move` filtered on `move_type = out_invoice`, and `overdue` is
39
+ `payment_state = not_paid` past its due date. Both expand to an ordinary domain before
40
+ anything is sent, and the technical names keep working. Full documentation:
41
+ [Organize-IT.github.io/odoo-cli](https://github.com/Organize-IT/odoo-cli/tree/main/docs).
42
+
43
+ Prefer named connections? They live in a TOML file written owner-only where the platform
44
+ can enforce that — `odoo profile path --check` says whether it can:
37
45
 
38
46
  ```bash
39
47
  odoo profile add acme --url https://acme.odoo.com --db acme --login bot@acme.com \
@@ -50,7 +58,10 @@ First match wins, and the CLI never prompts:
50
58
  3. a profile named `default`
51
59
 
52
60
  Nothing found: exit code 3 with a message listing those three ways. A profile stores the key
53
- (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file.
61
+ (`--api-key`) or points to an env var (`--api-key-env`). `odoo profile path` shows the file,
62
+ `--check` reports what its permissions are actually worth: mode 600 means owner-only on
63
+ POSIX and nothing at all on Windows, where `chmod` only toggles a read-only attribute. On
64
+ such a platform, prefer `--api-key-env` and keep the key in your secret manager.
54
65
 
55
66
  ## Output contract
56
67
 
@@ -62,6 +73,7 @@ Nothing found: exit code 3 with a message listing those three ways. A profile st
62
73
  | bad arguments | | `{"error": ...}` | 2 |
63
74
  | connection, auth, no profile | | `{"error": ...}` | 3 |
64
75
  | refused by a guard | | `{"error": ...}` | 4 |
76
+ | query repaired to run | rows | `{"error": ...}` | 5 |
65
77
  | write executed | result | `{"write": {"model", "method", "ids", "fields"}}` | 0 |
66
78
 
67
79
  Data is never humanised: many2one fields stay `[id, "name"]`, empty values stay `false`.
@@ -75,6 +87,7 @@ Values of fields named like `password`, `api_key`, `secret` are `[redacted]` unl
75
87
  | `--company ID`, `--lang CODE`, `--context JSON` | Odoo context merged into every call |
76
88
  | `--insecure` | skip TLS verification (self-signed on-prem) |
77
89
  | `--no-redact`, `--include-sensitive` | lift the two output/model guards |
90
+ | `--no-validate` | skip the field-name check against the model schema |
78
91
  | `--debug` | one JSON line per RPC on stderr (method, id, duration, retries) |
79
92
  | `--verbose` | include Odoo's server traceback in error output |
80
93
 
@@ -95,13 +108,76 @@ Values of fields named like `password`, `api_key`, `secret` are `[redacted]` unl
95
108
 
96
109
  Values: `true/false/null`, integers, floats, JSON lists or objects, quoted strings, else text.
97
110
 
111
+ A bare word is looked up as a preset for the model: `-w overdue`, `-w unpaid`, `-w draft`,
112
+ `-w confirmed`, `-w archived`. `odoo alias MODEL --presets` lists the ones that apply. A bare
113
+ word that is not a preset exits 2 listing the ones that are.
114
+
115
+ ## Your own names
116
+
117
+ The shipped table covers what most tenants call things. A profile file adds the rest, and a
118
+ user entry replaces a built-in of the same name — your tenant knows its vocabulary better than
119
+ this tool does:
120
+
121
+ ```toml
122
+ [aliases.subscriptions]
123
+ model = "sale.subscription"
124
+ domain = [["stage_category", "=", "progress"]]
125
+ help = "Running subscriptions"
126
+
127
+ [aliases.invoices] # replaces the built-in
128
+ model = "account.move"
129
+ domain = [["move_type", "=", "out_invoice"], ["company_id", "=", 3]]
130
+
131
+ [presets.mine]
132
+ domain = [["user_id", "=", 7]]
133
+ models = ["crm.lead", "sale.order"]
134
+ ```
135
+
136
+ `odoo alias` marks each entry `builtin` or `config`. A malformed table fails the command with
137
+ exit 2 instead of being skipped: a filter you believe is applied and is not is exactly what
138
+ this mechanism exists to avoid. Dynamic dates work too — `@today`, `@month-start`, `@year-start`.
139
+
140
+ ## Names and typos
141
+
142
+ Read commands accept an alias in place of a technical model name (`invoices`, `customers`,
143
+ `quotes`, `pickings`, ...); `odoo alias` prints the table and needs no connection. Write
144
+ commands refuse an alias that carries a filter, so a vendor bill cannot be filed as a
145
+ customer invoice by accident.
146
+
147
+ Field names are checked against the model schema before the call, which costs nothing after
148
+ the first lookup:
149
+
150
+ ```console
151
+ $ odoo search partners -w mobil=+32
152
+ {"error": {"code": "unknown_field", "message": "res.partner has no field 'mobil'. Did you mean 'mobile'? (also: phone)"}}
153
+ $ echo $?
154
+ 2
155
+ ```
156
+
157
+ `fields_get` is read once per model and cached under `~/.cache/odoo-cli` for 24 hours
158
+ (`odoo cache path|list|clear`, `ODOO_CACHE_DIR`, `ODOO_SCHEMA_TTL`). Dotted paths are
159
+ followed across relations. A computed non-stored field used in `-w` or `--order` warns on
160
+ stderr rather than failing. Validation only rejects a name it has positively read from the
161
+ server: when the schema is unreadable it stays quiet, so it can never refuse wrongly.
162
+ `--no-validate` or `ODOO_NO_VALIDATE=1` turns it off.
163
+
164
+ ## Aggregates
165
+
166
+ ```bash
167
+ odoo group invoices -w overdue --by partner_id --sum amount_residual \
168
+ --order "amount_residual desc" --limit 10
169
+ odoo group invoices --by invoice_date:month --sum amount_total
170
+ ```
171
+
172
+ `read_group` under the hood: counts and totals per group without pulling the records.
173
+
98
174
  ## Writes
99
175
 
100
176
  Off by default. Enable per connection with `allow_writes = true` on the profile
101
177
  (`odoo profile add ... --allow-writes`) or `ODOO_ALLOW_WRITES=1`.
102
178
 
103
179
  ```bash
104
- odoo create crm.lead -v name="Website inquiry" -v partner_id=42 --dry-run # shows payload, exit 0
180
+ odoo create crm.lead -v name="Website inquiry" -v partner_id=42 --dry-run # payload only, exit 0
105
181
  odoo create crm.lead -v name="Website inquiry" -v partner_id=42 # prints the new id
106
182
  odoo write res.partner 42,43 -v active=false
107
183
  odoo unlink res.partner 99 --yes # --yes required
@@ -119,8 +195,9 @@ are refused unless `--include-sensitive`.
119
195
  many2one shapes) and recipes.
120
196
  - The same text ships as an [Agent Skill](https://github.com/Organize-IT/odoo-cli/blob/main/SKILL.md):
121
197
  `npx skills add Organize-IT/odoo-cli`.
122
- - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries, with a warning on
123
- stderr. Exploration only.
198
+ - `odoo search ... --lenient-fields` removes fields Odoo rejects and retries. It prints the
199
+ rows, then exits **5**: they answer a wider question than the one you asked. Exploration
200
+ stays comfortable; a script that checks its exit codes cannot be fooled by it.
124
201
 
125
202
  ## Library
126
203
 
@@ -163,8 +240,11 @@ repair loop in `odoocli.lenient`.
163
240
 
164
241
  ```bash
165
242
  uv sync --group dev
243
+ uvx pre-commit install # ruff, mypy and hygiene hooks on commit
166
244
  uv run pytest # unit tests, mocked JSON-RPC
167
245
  uv run ruff check && uv run mypy
246
+ uv run pytest --cov --cov-report=term-missing # gated at 93% in CI
247
+ uv run --group docs mkdocs serve # the documentation site
168
248
 
169
249
  ODOO_VERSION=17.0 scripts/start-odoo.sh # throwaway Odoo in Docker
170
250
  ODOO_URL=http://localhost:8069 ODOO_DB=test ODOO_LOGIN=admin ODOO_API_KEY=admin \
@@ -172,9 +252,14 @@ ODOO_ALLOW_WRITES=1 uv run pytest -m integration -o addopts=""
172
252
  docker compose -f docker/odoo-compose.yml down -v
173
253
  ```
174
254
 
175
- CI runs the unit suite on every PR and the integration matrix (Odoo 17.0, 18.0, 19.0) on
176
- `main`, tags and manual dispatch. Releases are published to PyPI on `v*` tags through
177
- trusted publishing.
255
+ CI runs the unit suite on Python 3.11-3.13 and builds the docs on every PR; the integration
256
+ matrix (Odoo 17.0, 18.0, 19.0) runs on `main`, tags and manual dispatch. Releases are
257
+ published to PyPI on `v*` tags through trusted publishing, with every action pinned to a
258
+ commit digest.
259
+
260
+ `AGENTS.md` is the specification: layering, the contracts that may not change silently, and
261
+ the definition of done. [docs/decisions/](docs/decisions/) records the decisions with a real
262
+ trade-off behind them. Read both before changing anything.
178
263
 
179
264
  ## License
180
265
 
@@ -18,6 +18,8 @@ Resolution order, first match wins:
18
18
  3. A profile named `default`
19
19
 
20
20
  Nothing resolved: exit code 3 and a message listing these three ways. The CLI never prompts.
21
+ `odoo profile path --check` says whether the stored key is really owner-only on this
22
+ platform; on Windows it is not, so prefer `--api-key-env` there.
21
23
  `ODOO_API_KEY` accepts an Odoo API key (preferred) or the user's password.
22
24
  Check a connection with `odoo info`. Self-signed on-prem server: `--insecure`
23
25
  (or `odoo profile add ... --no-verify-ssl`).
@@ -36,6 +38,52 @@ work on every command:
36
38
  --context '{"tz": "Europe/Brussels"}' any other key, merged with the flags above
37
39
  ```
38
40
 
41
+ ## Business names instead of technical ones
42
+
43
+ Read commands accept an alias in place of a model name, and a preset name in place of a
44
+ condition. Both expand to an ordinary domain before anything is sent, so the result has
45
+ exactly the same shape.
46
+
47
+ ```
48
+ odoo alias every alias, its model and its filter (no connection needed)
49
+ odoo alias invoices --presets the presets that apply to that model
50
+ ```
51
+
52
+ ```
53
+ odoo search invoices -w overdue same as
54
+ odoo search account.move -w move_type=out_invoice -w payment_state=not_paid \
55
+ -w invoice_date_due<TODAY
56
+ ```
57
+
58
+ Aliases include `partners`, `customers`, `suppliers`, `invoices`, `bills`, `credit-notes`,
59
+ `orders`, `quotes`, `opportunities`, `products`, `pickings`, `users`, `employees`, `tasks`.
60
+ Presets include `overdue`, `unpaid`, `paid`, `draft`, `posted`, `cancelled`, `confirmed`,
61
+ `this-month`, `this-year`, `ready`, `done`, and `archived` / `active` on any model.
62
+
63
+ Write commands refuse an alias that carries a filter (exit 2, `alias_not_writable`): use the
64
+ technical name and set the discriminator field yourself, so a vendor bill cannot be filed as
65
+ a customer invoice by accident.
66
+
67
+ ## Field names are checked before the call
68
+
69
+ A misspelled field costs no round trip. The CLI reads the model schema once with
70
+ `fields_get`, caches it for 24 hours, and checks every name used in `-w`, `--fields`,
71
+ `--order`, `-v` and `--by`:
72
+
73
+ ```
74
+ $ odoo search partners -w mobil=+32
75
+ {"error": {"code": "unknown_field", "message": "res.partner has no field 'mobil'.
76
+ Did you mean 'mobile'? (also: phone)"}} exit 2, nothing was sent
77
+ ```
78
+
79
+ Dotted paths are followed across relations, and the error names the model where the path
80
+ actually broke. Using a computed non-stored field in `-w` or `--order` produces a
81
+ `field_not_stored` warning on stderr and the call still goes out.
82
+
83
+ Validation only ever rejects a name it has positively read from the server: if the schema
84
+ cannot be read, it stays quiet. Turn it off with `--no-validate` or `ODOO_NO_VALIDATE=1`.
85
+ Refresh it after a module install or an Odoo upgrade with `odoo cache clear`.
86
+
39
87
  ## Output contract
40
88
 
41
89
  - Piped or captured: raw Odoo JSON, exactly what `search_read`, `read` or `fields_get` return.
@@ -45,7 +93,8 @@ work on every command:
45
93
  - Errors: one JSON object on stderr, `{"error": {"code": ..., "message": ..., "odoo": {...}}}`.
46
94
  - Warnings and write logs: one JSON object per line on stderr.
47
95
  - Exit codes: `0` ok, `1` Odoo raised, `2` bad usage, `3` connection or authentication,
48
- `4` refused by a guard (writes disabled, missing `--yes`, sensitive model).
96
+ `4` refused by a guard (writes disabled, missing `--yes`, sensitive model), `5` the query
97
+ was repaired to make it run, so the rows answer a wider question than the one you asked.
49
98
  - Values of fields named like `password`, `api_key`, `secret` are replaced by `[redacted]`
50
99
  unless `--no-redact`.
51
100
 
@@ -59,8 +108,16 @@ odoo search MODEL [-w COND]... [--domain JSON] [--fields a,b] [--limit N] [--off
59
108
  [--order "x desc"] [--all] [--ids-only] [--lenient-fields]
60
109
  odoo count MODEL [-w COND]... [--domain JSON]
61
110
  odoo read MODEL ID [ID...] [--fields a,b]
111
+ odoo group MODEL --by FIELD[,FIELD] [--sum a,b] [--avg a,b] [-w COND]... [--limit N]
112
+ odoo alias [NAME] [--presets] aliases and presets, offline
113
+ odoo cache path|list|clear the schema cache
62
114
  ```
63
115
 
116
+ `MODEL` accepts an alias on every read command. `odoo group` runs `read_group`, so you
117
+ get counts and totals per group without pulling the records:
118
+ `odoo group invoices -w overdue --by partner_id --sum amount_residual`.
119
+ Grouping keys accept a date granularity: `--by invoice_date:month`.
120
+
64
121
  Conditions (`-w`, repeatable, AND-ed together, combined with `--domain`):
65
122
 
66
123
  ```
@@ -84,7 +141,8 @@ odoo unlink MODEL IDS --yes [--dry-run]
84
141
  odoo call MODEL METHOD [--ids 1,2] [--args JSON] [--kwargs JSON] [--yes] [--dry-run]
85
142
  ```
86
143
 
87
- - `--dry-run` prints the exact payload and exits 0 without calling Odoo. Use it first.
144
+ - `--dry-run` prints the exact payload and exits 0 without writing anything. It does read
145
+ the model schema to check your field names, so a typo is caught there too. Use it first.
88
146
  - `unlink` and any `call` to a non read-only method need `--yes` (or `ODOO_ASSUME_YES=1`).
89
147
  - `call` on read-only methods (`name_search`, `read_group`, `default_get`, ...) needs
90
148
  neither `allow_writes` nor `--yes`.
@@ -103,8 +161,9 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
103
161
 
104
162
  ## Working method that avoids most failures
105
163
 
106
- 1. Unknown model? `odoo fields MODEL` first. It shows `type`, `required`, `store`, `relation`
107
- and `selection` values.
164
+ 1. Unknown model? Try `odoo alias` first, then `odoo models --like word`. Then
165
+ `odoo fields MODEL`, which shows `type`, `required`, `store`, `relation` and `selection`
166
+ values.
108
167
  2. Only filter or order on fields with `store: true`. Computed non-stored fields
109
168
  (`qty_available`, `amount_to_invoice`, ...) can be read but not searched; Odoo answers
110
169
  "Cannot convert ... to SQL". Read them and filter client-side.
@@ -117,9 +176,11 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
117
176
  6. Dates are strings, `YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS` in UTC.
118
177
  7. Field names drift between Odoo 17, 18 and 19 (for example `account.account.company_id`
119
178
  became `company_ids`). If a field is rejected, `odoo fields` is the truth.
120
- `--lenient-fields` on `search` removes rejected fields and retries, with a warning on
121
- stderr; only use it for exploration, never in a script that relies on the result.
122
- 8. Never guess a model name: `odoo models --like invoice`.
179
+ `--lenient-fields` on `search` removes rejected fields and retries. It prints the rows and
180
+ then exits 5, because they answer a wider question than the one you asked. Use it to
181
+ explore; if you keep it in a script, check the exit code.
182
+ 8. Never guess a model name: `odoo alias`, then `odoo models --like invoice`. A dotless
183
+ name close to a known alias is reported as a typo instead of being sent.
123
184
  9. Sensitive models (`ir.config_parameter`, `ir.mail_server`, `res.users.apikeys`, `ir.cron`,
124
185
  `ir.actions.server`, ...) are refused unless `--include-sensitive`.
125
186
  10. A record you know exists but cannot find is usually archived (`--include-archived`) or in
@@ -131,6 +192,10 @@ One2many and many2many fields take Odoo commands, written as JSON in `-v` or `--
131
192
  ## Recipes
132
193
 
133
194
  ```
195
+ odoo search customers -w country_id.code=BE --fields name,email,vat --limit 20
196
+ odoo count invoices -w overdue
197
+ odoo group invoices -w overdue --by partner_id --sum amount_residual --order "amount_residual desc" --limit 10
198
+ odoo group invoices --by invoice_date:month --sum amount_total
134
199
  odoo search res.partner -w is_company=true -w country_id.code=BE --fields name,email,vat --limit 20
135
200
  odoo search sale.order -w state=sale -w date_order>=2026-01-01 --fields name,partner_id,amount_total --order "amount_total desc"
136
201
  odoo count account.move -w move_type=out_invoice -w payment_state=not_paid -w invoice_date_due<2026-09-01
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "odoo-agent-cli"
7
- version = "0.3.0"
7
+ version = "0.5.0"
8
8
  description = "Odoo JSON-RPC CLI and Python client built for AI agents and scripts."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -44,6 +44,13 @@ dev = [
44
44
  "respx>=0.21",
45
45
  "ruff>=0.6",
46
46
  "mypy>=1.11",
47
+ "pytest-cov>=5",
48
+ "hypothesis>=6.168.0",
49
+ ]
50
+ docs = [
51
+ "mkdocs>=1.6",
52
+ "mkdocs-material>=9.5",
53
+ "mkdocstrings[python]>=0.26",
47
54
  ]
48
55
 
49
56
  [tool.hatch.build.targets.wheel]
@@ -76,6 +83,15 @@ warn_unreachable = true
76
83
  module = ["respx", "respx.*"]
77
84
  ignore_missing_imports = true
78
85
 
86
+ [tool.coverage.run]
87
+ source = ["odoocli"]
88
+ branch = true
89
+
90
+ [tool.coverage.report]
91
+ show_missing = true
92
+ fail_under = 93
93
+ exclude_also = ["if TYPE_CHECKING:", "raise AssertionError\\(\"unreachable\"\\)"]
94
+
79
95
  [tool.pytest.ini_options]
80
96
  asyncio_mode = "auto"
81
97
  testpaths = ["tests"]