@bergetai/odoo-cli
v0.12.1
Published
Agent-first CLI for the Odoo External API
Readme
odoo-cli
Agent-first CLI for the Odoo External API.
Every command is non-interactive, scriptable, and supports --json for stable machine-readable output.
Installation
npm install -g @bergetai/odoo-cliRequires Node.js 20 or later.
Usage
# Authenticate (API key via stdin — never as a flag)
pass odoo/key | odoo auth login --profile production --url https://acme.odoo.com --db acme --username automation
# Authenticate a second environment (kept side by side in one config)
pass odoo/stage-key | odoo auth login --profile stage --url https://odoo.stage.berget.ai --db berget-stage --username automation
# Always select the profile explicitly — commands never guess credentials
odoo --profile production partners list --company --json
ODOO_PROFILE=stage odoo partners list --company --json
# Check credentials and their configuration sources
odoo auth status --profile production --json
# Unauthenticated connectivity check
odoo version
# Partners (companies and contacts — res.partner)
odoo partners list --company --json
odoo partners list --search "acme" --tag "Partner" --json # ilike on name/email, tag filter
odoo partners show 51
odoo partners tags # tags with usage counts
odoo partners create --name "Acme Corp" --is-company true --email [email protected]
odoo partners update 51 --email [email protected] --dry-run
odoo partners update 51 --add-tag "AI" --remove-tag "Draft" --dry-run
odoo partners delete 54 --dry-run
# Generic model introspection (any model with an XML-RPC API)
odoo models fields res.partner --filter "^email" --json
odoo models fields crm.lead --custom # only x_* custom fields
odoo models read res.partner --ids 51,52 --fields name,city
odoo models read crm.lead --search "microsoft" --json # ilike on name (max 100)
# List and inspect records
odoo crm leads list --open --limit 20
odoo crm leads show 42
odoo helpdesk tickets list --team Support --json
odoo project list --json
odoo project tasks list --project "Content Pipeline" --open
odoo project tasks show 850
odoo documents tree --count
odoo documents records list --folder "Product Backlog" --limit 20
odoo documents records list --folder "Projects > Product Backlog" # nested path
odoo documents records show 25
odoo documents records download 25 --out ./spec.pdf
# Mutations (support --dry-run)
odoo crm leads create --name "New lead" --email [email protected]
odoo crm leads update 42 --stage Won
odoo crm leads delete 42 --yes
# Sales teams (crm.team)
odoo crm teams list
odoo crm teams show 5
odoo crm teams create --name "Partnerships" --team-leader [email protected]
odoo crm teams update 5 --name "Direct Sales (renamed)" --dry-run
odoo crm teams delete 5 --dry-run
# Record attachments (ir.attachment) — explicit scope required
odoo attachments list --res-model helpdesk.ticket --res-id 398 --json
odoo attachments list --res-model mail.message --res-id 253924
odoo attachments list --search "spec" --limit 100
odoo attachments show 31337
odoo attachments download 31337 --out ./spec.pdf
odoo attachments delete 31337 --dry-run
odoo attachments delete 31337
# Marketing (mailing.*) — email is the key; trace deletion is always explicit
odoo marketing lists list
odoo marketing lists show 11
odoo marketing contacts list --email [email protected] --json
odoo marketing contacts list --list "Berget Code waitlist"
odoo marketing contacts delete 441 --dry-run
odoo marketing contacts delete 441
odoo marketing traces list --email [email protected] --json
odoo marketing traces delete 7001 --dry-run
odoo marketing traces delete 7001
# Sign-in identities (res.users) — show + guarded delete only
odoo users show 9
odoo users delete 9 --dry-run
odoo users delete 9
odoo users delete 9 3782 --json
# Footprint (read-only) — everything tied to an email, with SOP dispositions
odoo footprint [email protected] --json
odoo footprint [email protected] # grouped human report
odoo footprint [email protected] --active-only # what the UI sees
# Internal notes (NEVER sends email to customer)
odoo helpdesk tickets add-note 398 --body "Internal note text"
odoo helpdesk tickets add-note 398 --body-file note.md
odoo crm records add-note 338 --body "Customer replied: ..." --author 40
# Log note correction (message-update / message-delete; comment-type only)
odoo crm records message-update 270799 --body-file fixed.html --dry-run
odoo crm records message-update 270799 --body-file fixed.html
odoo crm records message-delete 253924 258332 --dry-run
odoo crm records message-delete 253924 258332Configuration precedence: flags > environment (ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY) > the selected profile in $XDG_CONFIG_HOME/odoo/config.toml. The API key is never accepted as a flag value.
Profiles
Credentials are stored per profile — one TOML table per environment:
[profiles.production]
url = "https://odoo.berget.ai"
db = "berget"
username = "automation"
api_key = "…"
server_version = "18.0+e"
[profiles.stage]
url = "https://odoo.stage.berget.ai"
db = "berget-stage"
username = "automation"
api_key = "…"Every command requires an explicit profile: pass --profile <name> or set ODOO_PROFILE. Without one the command exits 2 — credentials are never guessed, so a command can never hit the wrong environment by accident. auth login and the flags-only odoo version check tolerate a profile that does not exist yet (bootstrap), while any other command reports an unknown profile.
Older configs (config.json, including top-level credential keys) keep working: they read as the default profile and migrate to config.toml on the next auth login — the legacy file is removed once the TOML config holds everything.
Note: odoo --version prints the CLI's own version; odoo version queries the server.
Features
- Footprint overview (
footprint <email>) — one read-only invocation that enumerates everything tied to an email across the erasure-relevant modules (res.partnerincl. archived,res.users,crm.lead,mailing.*,helpdesk.ticket,mail.message,ir.attachment), correlates the found partners into the RESTRICT accounting surfaces (account.move/sale.orderincl. invoice & shipping addresses,account.payment), and classifies every record by erasure disposition:delete,anonymize(referenced — shell, never unlink),keep(bokföringslagen 7-year retention),review(account roots, legal-claim branch, internal users, unverifiable attachment parents).--jsonemits a structured per-group payload (fixed 11-group key order,totalRecords, per-groupfound/totalserver count/truncated/dispositionstally, many2one tuples normalized to{id, name}) with acommandHinton each actionable record, a top-levelrunOrder(SOP execution order), and decision-grade fields on partner records (child_ids/user_ids/vat). Review records with out-of-footprint parents are resolved inline — the parent's name is embedded in the reason (bounded by a per-model fetch cap; beyond it records keep aninspectHint). Every record tells you what to run next — or already answers the question. Read-only by design: verification (art. 12.6) is a human step and the argument must already be the verified email; record-rule hidden rows (e.g.user_notificationmessages) mean the raw SQL pre-flight stays the authoritative evidence. Never mutates anything - Read-only CRM surface (
crm leads list/show) — default columns,--fields/--all-fields,--limit/--offset/--order,--domain/--filterwithfields_gettype checking, reference resolution (--stage/--team/--assignee/--tag), table/JSON output withsearch_countenvelope - Partners surface (
partners list/show/tags/create/update/delete) —res.partnerCRUD with--company/--individualfilters,--search(case-insensitive name/email match),--tag(repeatable, resolvesres.partner.categoryby exact name then ilike),--archived(include inactive), curated list columns (ID, Name, Company Type, City, Email, Is a Company, Tags, Salesperson), curatedshowview with tag names, salesperson, customer/supplier ranks plus every populatedx_studio*custom field,partners tags(usage counts viaread_group, sorted by usage), first-class mutation flags for--name,--email,--phone,--website,--parent(m2o),--is-company,--street,--city,--zip,--country(m2o),--comment,--add-tag/--remove-tagon update (unknown tags fail on add; removal silently skips tags the record lacks; protected fieldscompany_typeandx_studio_invoice_approvedrefuse writes), full--dry-runsupport,--fields/--all-fields, table/JSON output - Sales teams surface (
crm teams list/show/create/update/delete) —--archived(include inactive teams),--team-leader(team leader, login/email/name),--active/--use-leads/--use-opportunities/--invoiced-target/--color/--sequence, full--dry-runsupport,--fields/--all-fields, table/JSON output - Read-only Helpdesk surface (
helpdesk tickets list/show) —--open/--closedvia stagefold, strict team-scoped stage resolution, module-not-installed error (exit 4) - Read-only Project surface (
project list/show) —--user(project manager) and--tagfilters, archived projects excluded by default, module-not-installed error (exit 4) - Read-only Project task surface (
project tasks list/show) —--project(required on list, resolved by name or id), project-scoped--stage,--assignee(many2manyuser_ids),--tag,--priority(0/Low, 1/High),--open/--closedvia the task'sis_closedflag,--domain/--filter, table/JSON output withsearch_countenvelope, module-not-installed error (exit 4) - Generic model surface (
models fields/read) — introspect and read any model exposed throughexecute_kw:fieldslistsfields_getmetadata (type, label, selection, relation, required, store) with--filter <regex>(case-insensitive on field name or label) and--custom(onlyx_*fields);readfetches up to 100 records by--ids 1,2,3or a name ilike--search, with--fields a,b,c(default: name). Unknown models/fields use the standard error taxonomy - Documents surface (
documents) —tree(hierarchical folder listing with--count/--depth/--folder),records list/show(folders excluded by default,--type folder|binary|url|all,--folder/--owner/--partner/--tag),folders list,records create(folder via--folder-type folder, link via--link <url>),records update(move via--folder),records delete,upload(local file → base64datas, MIME guessed from extension,--dry-runpreviews without sending bytes), andrecords download <id>(binary document → local file,--out <path>/--force,--out -streams to stdout). Everywhere a--folderis accepted, folders resolve by name, numeric id, or nested'Parent > Child'path (segments resolved within their parent, which disambiguates repeated folder names). Folders aredocuments.documentrows withtype='folder'; the hierarchy isfolder_id. Module-not-installed error (exit 4) - Writes (
create,update,delete) — single-call atomic batchwrite, client-side no-op diffing, delete convergence withskippedids, post-failure "record may have been created" hint oncreate --dry-runfor all mutations — full validation, resolution, and before/after diffs without mutating- Internal notes (
add-note) — creates internal Log Notes only (subtype_id=2, message_type=comment), NEVER sends email to customer, by design prevents accidental customer communication. Optional--author <email|name|id>attributes the note to the partner who actually sent the logged message (resolved viares.partner; ambiguous matches list candidates and never guess) - Log note correction (
message-update,message-delete) — repair formatting or remove duplicate log notes; strictly limited to comment-type messages (emails/notifications refused by design), full--dry-runsupport, unknown delete ids skipped - Config profiles (named environments in one
0600config file, explicit--profile/ODOO_PROFILEselection), atomic config writes with legacy migration, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
Compatibility
Validated against a real Odoo 18.0+e instance. Schema realities discovered against the live server:
helpdesk.ticket.ticket_type_idmay be absent (edition/version-dependent). Curatedshowfields are intersected withfields_get, so absent optional fields degrade gracefully instead of faulting the read.helpdesk.stagelinks to teams viateam_ids(many2many, shared stages) on some instances rather thanteam_id(many2one). Stage scoping introspects the stage model's schema and uses whichever field exists.execute_kwkwargs must be a separate trailing dict param; packing it into the positional-args array makes the server silently ignore it (this causedfields_getto return full/empty structs).project.taskhas nouser_idfield; assignees are the many2manyuser_ids. Thepriorityselection is two-valued (0/Low, 1/High), not the four-level scale used by CRM/Helpdesk. Tasks carry a directis_closedboolean, which--open/--closedfilter on (authoritative, unlike stagefold). Stages (project.task.type) link to projects via the many2manyproject_ids, so--stageresolves within the--project.
Development
npm install
npm run build # compile to dist/
npm test # build + unit/e2e tests against a fake XML-RPC serverContributing
Issues and pull requests are welcome at github.com/berget-ai/odoo-cli.
