@bergetai/odoo-cli 0.12.6 → 0.13.1

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.
package/README.md CHANGED
@@ -46,6 +46,8 @@ odoo models fields res.partner --filter "^email" --json
46
46
  odoo models fields crm.lead --custom # only x_* custom fields
47
47
  odoo models read res.partner --ids 51,52 --fields name,city
48
48
  odoo models read crm.lead --search "microsoft" --json # ilike on name (max 100)
49
+ odoo models search mail.activity --domain '[["user_id","=",39],["date_deadline","<=","2026-09-25"]]' --json
50
+ odoo models search mail.activity --filter user_id=39 --fields res_id,summary,date_deadline --json
49
51
 
50
52
  # List and inspect records
51
53
  odoo crm leads list --open --limit 20
@@ -95,6 +97,17 @@ odoo attachments download 31337 --out ./spec.pdf
95
97
  odoo attachments delete 31337 --dry-run
96
98
  odoo attachments delete 31337
97
99
 
100
+ # Expenses (hr.expense) — receipts attached as ir.attachment
101
+ odoo expenses list --json
102
+ odoo expenses show 137
103
+ odoo expenses categories --json
104
+ odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 --amount 249.50 \
105
+ --currency SEK --category "Travel & Accommodation" --attach receipt.pdf --dry-run
106
+ odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 --amount 249.50 \
107
+ --category "Travel & Accommodation" --attach receipt.pdf
108
+ odoo expenses attach 137 --file kvitto.pdf
109
+ odoo expenses delete 137 --dry-run
110
+
98
111
  # Marketing (mailing.*) — email is the key; trace deletion is always explicit
99
112
  odoo marketing lists list
100
113
  odoo marketing lists show 11
@@ -184,7 +197,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
184
197
  - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
185
198
  - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
186
199
  - Read-only Project task surface (`project tasks list/show`) — `--project` (required on list, resolved by name or id), project-scoped `--stage`, `--assignee` (many2many `user_ids`), `--tag`, `--priority` (0/Low, 1/High), `--open`/`--closed` via the task's `is_closed` flag, `--domain`/`--filter`, table/JSON output with `search_count` envelope, module-not-installed error (exit 4)
187
- - Generic model surface (`models fields/read`) — introspect and read any model exposed through `execute_kw`: `fields` lists `fields_get` metadata (type, label, selection, relation, required, store) with `--filter <regex>` (case-insensitive on field name or label) and `--custom` (only `x_*` fields); `read` fetches up to 100 records by `--ids 1,2,3` or a name ilike `--search`, with `--fields a,b,c` (default: name). Unknown models/fields use the standard error taxonomy
200
+ - Generic model surface (`models fields/read/search`) — introspect, read and search any model exposed through `execute_kw`: `fields` lists `fields_get` metadata (type, label, selection, relation, required, store) with `--filter <regex>` (case-insensitive on field name or label) and `--custom` (only `x_*` fields); `read` fetches up to 100 records by `--ids 1,2,3` or a name ilike `--search`, with `--fields a,b,c` (default: name); `search` runs an arbitrary domain via `--domain <json>` (or repeatable `--filter f=v`, mutually exclusive) with `--fields` (default: id[,name]), `--limit`/`--offset`/`--order`, and `--include-archived` (runs with `active_test: false`; eager-fetches rows other list engines hide by default). Unknown models/fields use the standard error taxonomy
188
201
  - 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 → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes), and `records download <id>` (binary document → local file, `--out <path>`/`--force`, `--out -` streams to stdout). Everywhere a `--folder` is accepted, folders resolve by name, numeric id, or nested `'Parent > Child'` path (segments resolved within their parent, which disambiguates repeated folder names). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
189
202
  - Writes (`create`, `update`, `delete`) — single-call atomic batch `write`, client-side no-op diffing, delete convergence with `skipped` ids, post-failure "record may have been created" hint on `create`
190
203
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
package/dist/cli.js CHANGED
@@ -12,11 +12,12 @@ import { versionCommand } from "./commands/version.js";
12
12
  import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersTagsCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
13
13
  import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
14
14
  import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
15
+ import { expensesAttachCommand, expensesCategoriesCommand, expensesCreateCommand, expensesDeleteCommand, expensesListCommand, expensesShowCommand, } from "./commands/expenses.js";
15
16
  import { signTemplatesCreateFromDocumentCommand, signTemplatesListCommand, signTemplatesShowCommand, signItemsAddCommand, signRequestsCreateCommand, signRequestsListCommand, signRequestsCancelCommand, } from "./commands/sign.js";
16
17
  import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, plansPricingCreateCommand, plansPricingListCommand, plansPricingUpdateCommand, } from "./commands/sales.js";
17
18
  import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
18
19
  import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
19
- import { modelsFieldsCommand, modelsReadCommand, } from "./commands/models.js";
20
+ import { modelsFieldsCommand, modelsReadCommand, modelsSearchCommand, } from "./commands/models.js";
20
21
  import { footprintCommand, } from "./commands/footprint.js";
21
22
  import { CliError } from "./errors.js";
22
23
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
@@ -823,6 +824,96 @@ Examples:
823
824
  .action(async (ids) => {
824
825
  await run((g) => attachmentsDeleteCommand(ids, g), program);
825
826
  });
827
+ const expenses = program
828
+ .command("expenses")
829
+ .description("Expenses app (hr.expense, Enterprise) — receipts and outlays");
830
+ expenses
831
+ .command("list")
832
+ .description("list expenses; record rules scope visibility to own expenses unless Expense Manager")
833
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
834
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
835
+ .option("--fields <a,b,c>", "columns to display")
836
+ .option("--limit <n>", "max records (default: 50)", "50")
837
+ .option("--offset <n>", "skip the first n records", "0")
838
+ .option("--order <order>", 'sort order, e.g. "date desc"', "date desc")
839
+ .addHelpText("after", `
840
+ Examples:
841
+ $ odoo expenses list --json
842
+ $ odoo expenses list --filter state=draft
843
+ $ odoo expenses list --domain '[["date",">=","2026-01-01"]]' --json`)
844
+ .action(async (opts) => {
845
+ await run((g) => expensesListCommand(opts, g), program);
846
+ });
847
+ expenses
848
+ .command("show")
849
+ .description("read one expense")
850
+ .argument("<id>", "record id")
851
+ .option("--fields <a,b,c>", "fields to display")
852
+ .option("--all-fields", "display every readable field")
853
+ .action(async (id, opts) => {
854
+ await run((g) => expensesShowCommand(id, opts, g), program);
855
+ });
856
+ expenses
857
+ .command("categories")
858
+ .description("list expense categories (product.product where can_be_expensed)")
859
+ .option("--limit <n>", "max records (default: 50)", "50")
860
+ .option("--offset <n>", "skip the first n records", "0")
861
+ .action(async (opts) => {
862
+ await run((g) => expensesCategoriesCommand(opts, g), program);
863
+ });
864
+ expenses
865
+ .command("create")
866
+ .description("create an expense, optionally attaching one or more receipt files")
867
+ .requiredOption("--name <text>", "short description, e.g. 'Taxi Arlanda'")
868
+ .requiredOption("--date <yyyy-mm-dd>", "receipt date")
869
+ .requiredOption("--amount <n>", "total amount incl. VAT, e.g. 249.50")
870
+ .requiredOption("--category <name|id>", "expense category (see 'expenses categories')")
871
+ .option("--currency <code>", "currency code, e.g. SEK (default: company currency)")
872
+ .option("--payment-mode <mode>", "own (employee to reimburse, default) or company (company card)")
873
+ .option("--employee <name|id>", "expense owner (default: employee linked to the authenticated user)")
874
+ .option("--description <text>", "internal note / details")
875
+ .option("--attach <file>", "receipt file to attach (pdf/jpg/png/...); repeatable", collect, [])
876
+ .addHelpText("after", `
877
+ The receipt file(s) are stored as ir.attachment rows linked to the expense
878
+ (res_model=hr.expense), the same way the web UI stores dropped files.
879
+
880
+ Examples:
881
+ $ odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 \\
882
+ --amount 249.50 --currency SEK --category "Travel & Accommodation" \\
883
+ --attach receipt.pdf --dry-run
884
+ $ odoo expenses create --name "Lunch kundmöte" --date 2026-10-10 \\
885
+ --amount 342 --category Meals --attach kvitto1.jpg --attach kvitto2.jpg --json`)
886
+ .action(async (opts) => {
887
+ await run((g) => expensesCreateCommand(opts, g), program);
888
+ });
889
+ expenses
890
+ .command("attach")
891
+ .description("attach a receipt file to an existing expense")
892
+ .argument("<id>", "expense id")
893
+ .requiredOption("--file <path>", "receipt file (pdf/jpg/png/...)")
894
+ .option("--name <name>", "attachment name (default: file basename)")
895
+ .addHelpText("after", `
896
+ Example:
897
+ $ odoo expenses attach 42 --file kvitto.pdf --dry-run
898
+ $ odoo expenses attach 42 --file kvitto.pdf`)
899
+ .action(async (id, opts) => {
900
+ await run((g) => expensesAttachCommand(id, opts, g), program);
901
+ });
902
+ expenses
903
+ .command("delete")
904
+ .description("delete expenses (preview with --dry-run; attachments are NOT removed, use 'attachments delete')")
905
+ .argument("<id...>", "record ids")
906
+ .addHelpText("after", `
907
+ Note: receipt files (ir.attachment) are soft-linked and survive expense
908
+ deletion — remove them explicitly via 'attachments list --res-model
909
+ hr.expense --res-id <id>' + 'attachments delete'.
910
+
911
+ Example:
912
+ $ odoo expenses delete 137 --dry-run
913
+ $ odoo expenses delete 137`)
914
+ .action(async (ids) => {
915
+ await run((g) => expensesDeleteCommand(ids, g), program);
916
+ });
826
917
  const sign = program
827
918
  .command("sign")
828
919
  .description("Sign app (sign.template, sign.request) — safe signing pipeline");
@@ -1373,6 +1464,25 @@ Examples:
1373
1464
  .action(async (model, opts) => {
1374
1465
  await run((g) => modelsReadCommand(model, opts, g), program);
1375
1466
  });
1467
+ models
1468
+ .command("search")
1469
+ .description("search records by domain (search_read; use --fields, --limit, --order)")
1470
+ .argument("<model>", "model technical name, e.g. mail.activity")
1471
+ .option("--domain <json>", 'Odoo domain as a JSON array, e.g. [["date_deadline","<=","2026-01-31"]]')
1472
+ .option("--filter <field=value>", "equality filter on raw values (repeatable); many2one values must be numeric ids", collect, [])
1473
+ .option("--fields <a,b,c>", "fields to fetch (default: id[,name])")
1474
+ .option("--limit <n>", "max records (default: 50)", "50")
1475
+ .option("--offset <n>", "skip the first n records", "0")
1476
+ .option("--order <order>", 'sort order, e.g. "date_deadline asc"')
1477
+ .option("--include-archived", "match archived records too (runs with active_test: false)")
1478
+ .addHelpText("after", `
1479
+ Examples:
1480
+ $ odoo models search mail.activity --domain '[["user_id","=",39],["date_deadline","<=","2026-09-25"]]' --json
1481
+ $ odoo models search mail.activity --filter user_id=39 --fields res_id,summary,date_deadline --json
1482
+ $ odoo models search crm.lead --domain '[["team_id","=",2]]' --order "create_date desc" --limit 20 --json`)
1483
+ .action(async (model, opts) => {
1484
+ await run((g) => modelsSearchCommand(model, opts, g), program);
1485
+ });
1376
1486
  return program;
1377
1487
  }
1378
1488
  async function run(fn, program) {
@@ -0,0 +1,281 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { ModelFields, resolveReference } from "../model.js";
4
+ import { authenticatedClient, listCommand, parseId, showCommand, } from "../resource.js";
5
+ import { parseDate, parseDecimal, deleteCommand } from "../mutation.js";
6
+ import { notFoundError, usageError } from "../errors.js";
7
+ import { colorEnabled, printJson } from "../output.js";
8
+ /**
9
+ * Expenses app (hr.expense, Enterprise). Record rules limit visibility to
10
+ * own expenses unless the user is an Expense Manager — list output is
11
+ * therefore per-user by design.
12
+ *
13
+ * Receipt files are stored as plain ir.attachment rows with
14
+ * res_model="hr.expense" / res_id=<expense id> (the same mechanism the web
15
+ * UI uses when you drop a file on an expense).
16
+ */
17
+ export const hrExpense = {
18
+ model: "hr.expense",
19
+ showCommand: "odoo expenses show",
20
+ defaultListFields: [
21
+ "id",
22
+ "name",
23
+ "date",
24
+ "total_amount",
25
+ "currency_id",
26
+ "product_id",
27
+ "payment_mode",
28
+ "state",
29
+ ],
30
+ showExtraFields: [
31
+ "description",
32
+ "employee_id",
33
+ "unit_amount",
34
+ "quantity",
35
+ "tax_ids",
36
+ "sheet_id",
37
+ "write_date",
38
+ ],
39
+ resolutionHints: {
40
+ product_id: "--category",
41
+ employee_id: "--employee",
42
+ },
43
+ priorityLabels: {},
44
+ pinnedDomain: [],
45
+ references: {},
46
+ };
47
+ export function expensesListCommand(opts, globals) {
48
+ return listCommand(hrExpense, opts, globals);
49
+ }
50
+ export function expensesShowCommand(idArg, opts, globals) {
51
+ return showCommand(hrExpense, idArg, opts, globals);
52
+ }
53
+ export function expensesDeleteCommand(idArgs, globals) {
54
+ return deleteCommand(hrExpense, idArgs, globals);
55
+ }
56
+ export async function expensesCategoriesCommand(opts, globals) {
57
+ const client = await authenticatedClient(globals);
58
+ const limit = Number(opts.limit);
59
+ const offset = Number(opts.offset);
60
+ if (!Number.isInteger(limit) || limit < 0)
61
+ throw usageError(`invalid --limit value ${JSON.stringify(opts.limit)}`);
62
+ if (!Number.isInteger(offset) || offset < 0)
63
+ throw usageError(`invalid --offset value ${JSON.stringify(opts.offset)}`);
64
+ const domain = [["can_be_expensed", "=", true]];
65
+ const records = (await client.executeKw("product.product", "search_read", [
66
+ domain,
67
+ ], {
68
+ fields: ["id", "name", "standard_price"],
69
+ limit,
70
+ offset,
71
+ order: "name asc",
72
+ }));
73
+ if (globals.json) {
74
+ printJson({ model: "product.product", records, count: records.length, limit, offset });
75
+ return;
76
+ }
77
+ for (const r of records) {
78
+ process.stdout.write(`${r.id}\t${r.name}\n`);
79
+ }
80
+ }
81
+ // create — expense + optional receipt file attachments
82
+ // ---------------------------------------------------------------------------
83
+ const MIME_BY_EXT = {
84
+ ".pdf": "application/pdf",
85
+ ".png": "image/png",
86
+ ".jpg": "image/jpeg",
87
+ ".jpeg": "image/jpeg",
88
+ ".gif": "image/gif",
89
+ ".webp": "image/webp",
90
+ ".heic": "image/heic",
91
+ ".txt": "text/plain",
92
+ };
93
+ const PAYMENT_MODES = {
94
+ own: "own_account",
95
+ company: "company_account",
96
+ };
97
+ /** Find the hr.employee linked to the authenticated user (default owner). */
98
+ async function currentEmployee(client) {
99
+ const uid = await client.authenticate();
100
+ const rows = (await client.executeKw("hr.employee", "search_read", [
101
+ [["user_id", "=", uid]],
102
+ ], { fields: ["id"], limit: 1 }));
103
+ if (rows.length === 0) {
104
+ throw notFoundError("employee_not_found", "no hr.employee record linked to the authenticated user", "pass --employee explicitly, or link an employee to your user in Odoo");
105
+ }
106
+ return rows[0].id;
107
+ }
108
+ async function prepareAttachment(file) {
109
+ const filePath = resolve(file);
110
+ let fileStat;
111
+ try {
112
+ fileStat = await stat(filePath);
113
+ }
114
+ catch {
115
+ throw usageError(`file not found: ${file}`);
116
+ }
117
+ if (!fileStat.isFile())
118
+ throw usageError(`not a regular file: ${file}`);
119
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
120
+ const buffer = await readFile(filePath);
121
+ return {
122
+ path: filePath,
123
+ name: basename(filePath),
124
+ mimetype: MIME_BY_EXT[ext] ?? "application/octet-stream",
125
+ datas: buffer.toString("base64"),
126
+ size: fileStat.size,
127
+ };
128
+ }
129
+ export async function expensesCreateCommand(opts, globals) {
130
+ const client = await authenticatedClient(globals);
131
+ await ModelFields.load(client, "hr.expense");
132
+ const date = parseDate(opts.date, "--date");
133
+ const amount = parseDecimal(opts.amount, "--amount");
134
+ if (amount <= 0) {
135
+ throw usageError("--amount must be greater than zero");
136
+ }
137
+ const category = await resolveReference(client, "product.product", opts.category, {
138
+ label: "expense category",
139
+ domain: [["can_be_expensed", "=", true]],
140
+ });
141
+ const paymentMode = opts.paymentMode !== undefined
142
+ ? (() => {
143
+ const key = opts.paymentMode.toLowerCase();
144
+ const mode = PAYMENT_MODES[key] ??
145
+ (Object.values(PAYMENT_MODES).includes(key) ? key : undefined);
146
+ if (mode === undefined) {
147
+ throw usageError(`invalid --payment-mode ${JSON.stringify(opts.paymentMode)}`, `expected one of: ${Object.keys(PAYMENT_MODES).join(", ")} (own = employee to reimburse, company = company card)`);
148
+ }
149
+ return mode;
150
+ })()
151
+ : "own_account";
152
+ const currency = opts.currency !== undefined
153
+ ? await resolveReference(client, "res.currency", opts.currency, {
154
+ label: "currency",
155
+ exactFields: ["name"],
156
+ })
157
+ : undefined;
158
+ const employeeId = opts.employee !== undefined
159
+ ? (await resolveReference(client, "hr.employee", opts.employee, {
160
+ label: "employee",
161
+ })).id
162
+ : await currentEmployee(client);
163
+ const attachments = [];
164
+ for (const file of opts.attach ?? []) {
165
+ attachments.push(await prepareAttachment(file));
166
+ }
167
+ const values = {
168
+ name: opts.name,
169
+ date,
170
+ total_amount: amount,
171
+ product_id: category.id,
172
+ payment_mode: paymentMode,
173
+ employee_id: employeeId,
174
+ };
175
+ if (currency !== undefined)
176
+ values.currency_id = currency.id;
177
+ if (opts.description !== undefined)
178
+ values.description = opts.description;
179
+ if (globals.dryRun) {
180
+ const preview = {
181
+ ...values,
182
+ attachments: attachments.map((a) => `${a.name} (${a.size} bytes)`),
183
+ };
184
+ if (globals.json) {
185
+ printJson({ model: "hr.expense", action: "create", dry_run: true, values: preview });
186
+ return;
187
+ }
188
+ process.stdout.write(`Would create hr.expense:\n` +
189
+ Object.entries(preview)
190
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
191
+ .join("\n") +
192
+ "\n");
193
+ return;
194
+ }
195
+ const id = (await client.executeKw("hr.expense", "create", [values]));
196
+ const uploaded = [];
197
+ for (const a of attachments) {
198
+ const attId = (await client.executeKw("ir.attachment", "create", [
199
+ {
200
+ name: a.name,
201
+ mimetype: a.mimetype,
202
+ datas: a.datas,
203
+ res_model: "hr.expense",
204
+ res_id: id,
205
+ },
206
+ ]));
207
+ uploaded.push({ id: attId, name: a.name });
208
+ }
209
+ if (globals.json) {
210
+ printJson({
211
+ model: "hr.expense",
212
+ action: "create",
213
+ dry_run: false,
214
+ id,
215
+ name: opts.name,
216
+ attachments: uploaded,
217
+ });
218
+ return;
219
+ }
220
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
221
+ const attSuffix = uploaded.length > 0 ? ` + ${uploaded.length} attachment(s)` : "";
222
+ const line = `Created hr.expense ${id} (${opts.name})${attSuffix}`;
223
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
224
+ }
225
+ export async function expensesAttachCommand(idArg, opts, globals) {
226
+ const id = parseId(idArg);
227
+ const client = await authenticatedClient(globals);
228
+ // Verify the expense exists (and is visible under record rules).
229
+ const rows = (await client.executeKw("hr.expense", "read", [[id]], {
230
+ fields: ["name"],
231
+ }));
232
+ if (rows.length === 0) {
233
+ throw notFoundError("record_not_found", `no hr.expense record with id ${id}`, "expenses are only visible to their owner unless you are an Expense Manager");
234
+ }
235
+ const prepared = await prepareAttachment(opts.file);
236
+ const name = opts.name ?? prepared.name;
237
+ if (globals.dryRun) {
238
+ if (globals.json) {
239
+ printJson({
240
+ model: "ir.attachment",
241
+ action: "attach",
242
+ dry_run: true,
243
+ values: {
244
+ name,
245
+ mimetype: prepared.mimetype,
246
+ res_model: "hr.expense",
247
+ res_id: id,
248
+ datas: `<${prepared.size} bytes base64>`,
249
+ },
250
+ });
251
+ return;
252
+ }
253
+ process.stdout.write(`Would attach ${name} (${prepared.size} bytes) to hr.expense ${id}\n`);
254
+ return;
255
+ }
256
+ const attId = (await client.executeKw("ir.attachment", "create", [
257
+ {
258
+ name,
259
+ mimetype: prepared.mimetype,
260
+ datas: prepared.datas,
261
+ res_model: "hr.expense",
262
+ res_id: id,
263
+ },
264
+ ]));
265
+ if (globals.json) {
266
+ printJson({
267
+ model: "ir.attachment",
268
+ action: "attach",
269
+ dry_run: false,
270
+ id: attId,
271
+ name,
272
+ res_model: "hr.expense",
273
+ res_id: id,
274
+ file_size: prepared.size,
275
+ });
276
+ return;
277
+ }
278
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
279
+ const line = `Attached ${name} to hr.expense ${id} (ir.attachment ${attId})`;
280
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
281
+ }
@@ -1,9 +1,16 @@
1
+ import { buildFilterClauses, parseDomain } from "../filter.js";
1
2
  import { notFoundError, usageError } from "../errors.js";
2
3
  import { fieldsGet, ModelFields } from "../model.js";
3
4
  import { colorEnabled, printJson } from "../output.js";
4
5
  import { authenticatedClient } from "../resource.js";
5
6
  import { formatTable, humanCell } from "../table.js";
6
7
  const MAX_READ = 100;
8
+ function parseCount(raw, flag) {
9
+ if (!/^\d+$/.test(raw)) {
10
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
11
+ }
12
+ return Number(raw);
13
+ }
7
14
  function parseFieldList(raw) {
8
15
  const fields = raw
9
16
  .split(",")
@@ -125,3 +132,54 @@ export async function modelsReadCommand(model, opts, globals) {
125
132
  const rows = records.map((r) => fields.map((f) => humanCell(r[f])));
126
133
  process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
127
134
  }
135
+ /**
136
+ * `models search <model>` — generic domain search over any model reachable
137
+ * through execute_kw. The read-side counterpart to `models read` when the
138
+ * name-ilike search is not enough: arbitrary domains via JSON --domain, or
139
+ * another model's filter surface via repeatable --filter f=v (id equality on
140
+ * many2one fields). Sqlite-style paging via --limit/--offset; ordering via
141
+ * --order. --include-archived disables active_test so archived rows are
142
+ * matched; on models without an `active` field it is a no-op.
143
+ */
144
+ export async function modelsSearchCommand(model, opts, globals) {
145
+ if (opts.domain !== undefined && opts.filter && opts.filter.length > 0) {
146
+ throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
147
+ }
148
+ const limit = parseCount(opts.limit, "--limit");
149
+ const offset = parseCount(opts.offset, "--offset");
150
+ const client = await authenticatedClient(globals);
151
+ const meta = await ModelFields.load(client, model);
152
+ const domain = [];
153
+ if (opts.domain !== undefined) {
154
+ domain.push(...parseDomain(opts.domain));
155
+ }
156
+ else if (opts.filter && opts.filter.length > 0) {
157
+ domain.push(...buildFilterClauses(opts.filter, meta, {}, `run \`odoo models fields ${model}\` to list available fields`));
158
+ }
159
+ const defaultFields = meta.get("name") ? ["id", "name"] : ["id"];
160
+ const fields = opts.fields !== undefined ? parseFieldList(opts.fields) : defaultFields;
161
+ for (const field of fields) {
162
+ if (field !== "id" && !meta.get(field)) {
163
+ throw usageError(`unknown field ${JSON.stringify(field)} on ${model}`, "run `odoo models fields <model>` to list available fields");
164
+ }
165
+ }
166
+ const context = opts.includeArchived
167
+ ? { active_test: false }
168
+ : undefined;
169
+ const kwargs = { fields, limit, offset, order: opts.order ?? "id asc" };
170
+ if (context)
171
+ kwargs.context = context;
172
+ const [records, count] = await Promise.all([
173
+ client.executeKw(model, "search_read", [domain], kwargs),
174
+ client.executeKw(model, "search_count", [domain], {
175
+ ...(context ? { context } : {}),
176
+ }),
177
+ ]);
178
+ if (globals.json) {
179
+ printJson({ model, records, count, limit, offset });
180
+ return;
181
+ }
182
+ const headers = fields.map((f) => meta.get(f)?.string ?? f);
183
+ const rows = records.map((r) => fields.map((f) => humanCell(r[f])));
184
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
185
+ }
package/dist/filter.js CHANGED
@@ -33,7 +33,7 @@ export function parseFilterScalar(raw) {
33
33
  * many2one fields only accept numeric ids — a non-numeric value is a usage
34
34
  * error pointing at the resolution flags, never a silently empty result.
35
35
  */
36
- export function buildFilterClauses(filters, fields, resolutionHints) {
36
+ export function buildFilterClauses(filters, fields, resolutionHints, unknownFieldHint) {
37
37
  const clauses = [];
38
38
  for (const filter of filters) {
39
39
  const eq = filter.indexOf("=");
@@ -44,7 +44,8 @@ export function buildFilterClauses(filters, fields, resolutionHints) {
44
44
  const rawValue = filter.slice(eq + 1);
45
45
  const meta = fields.get(field);
46
46
  if (!meta) {
47
- throw usageError(`unknown field ${JSON.stringify(field)} in --filter`, "check the field name with `odoo crm leads show <id> --all-fields`");
47
+ throw usageError(`unknown field ${JSON.stringify(field)} in --filter`, unknownFieldHint ??
48
+ "check the field name with `odoo crm leads show <id> --all-fields`");
48
49
  }
49
50
  const value = parseFilterScalar(rawValue);
50
51
  if (meta.type === "many2one" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.12.6",
3
+ "version": "0.13.1",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"