@bergetai/odoo-cli 0.4.2 → 0.5.2

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
@@ -24,6 +24,13 @@ odoo auth status --json
24
24
  # Unauthenticated connectivity check
25
25
  odoo version
26
26
 
27
+ # Partners (companies and contacts — res.partner)
28
+ odoo partners list --company --json
29
+ odoo partners show 51
30
+ odoo partners create --name "Acme Corp" --is-company true --email info@acme.example
31
+ odoo partners update 51 --email new@acme.example --dry-run
32
+ odoo partners delete 54 --dry-run
33
+
27
34
  # List and inspect records
28
35
  odoo crm leads list --open --limit 20
29
36
  odoo crm leads show 42
@@ -59,6 +66,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
59
66
  ## Features
60
67
 
61
68
  - Read-only CRM surface (`crm leads list/show`) — default columns, `--fields`/`--all-fields`, `--limit`/`--offset`/`--order`, `--domain`/`--filter` with `fields_get` type checking, reference resolution (`--stage`/`--team`/`--assignee`/`--tag`), table/JSON output with `search_count` envelope
69
+ - Partners surface (`partners list/show/create/update/delete`) — `res.partner` CRUD with `--company`/`--individual` filters, `--archived` (include inactive), first-class flags for `--name`, `--email`, `--phone`, `--website`, `--parent` (m2o), `--is-company`, `--street`, `--city`, `--zip`, `--country` (m2o), `--comment`, full `--dry-run` support, `--fields`/`--all-fields`, table/JSON output
62
70
  - 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-run` support, `--fields`/`--all-fields`, table/JSON output
63
71
  - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
64
72
  - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
package/dist/cli.js CHANGED
@@ -5,10 +5,11 @@ import { leadsCreateCommand, leadsDeleteCommand, leadsListCommand, leadsShowComm
5
5
  import { teamsCreateCommand, teamsDeleteCommand, teamsListCommand, teamsShowCommand, teamsUpdateCommand, crmTeams, } from "./commands/teams.js";
6
6
  import { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, ticketsShowCommand, ticketsUpdateCommand, helpdeskTickets, } from "./commands/helpdesk.js";
7
7
  import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
8
- import { activitiesListCommand, activityDoneCommand, scheduleCommand, } from "./commands/activities.js";
8
+ import { activitiesListCommand, activityDoneCommand, activityUpdateCommand, scheduleCommand, } from "./commands/activities.js";
9
9
  import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
10
10
  import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, } from "./commands/documents.js";
11
11
  import { versionCommand } from "./commands/version.js";
12
+ import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
12
13
  import { CliError } from "./errors.js";
13
14
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
14
15
  import { printError } from "./output.js";
@@ -226,6 +227,30 @@ Examples:
226
227
  .action(async (ids) => {
227
228
  await run((g) => activityDoneCommand(ids, g), program);
228
229
  });
230
+ leads
231
+ .command("activity-update")
232
+ .description("update activities — deadline, summary, note, type, assignee (preview with --dry-run)")
233
+ .argument("<id...>", "activity ids")
234
+ .option("--deadline <yyyy-mm-dd>", "new deadline")
235
+ .option("--summary <text>", "new summary")
236
+ .option("--note <text>", "new note")
237
+ .option("--type <name|id>", "new activity type (e.g. To-Do, Call, Meeting)")
238
+ .option("--assignee <name|id>", "new assignee (login, email, or name)")
239
+ .addHelpText("after", `
240
+ Updates mail.activity records directly via write. At least one field is required.
241
+
242
+ Examples:
243
+ # Push an activity deadline to tomorrow
244
+ $ odoo crm records activity-update 736 --deadline 2026-08-28
245
+
246
+ # Update summary + deadline for two activities
247
+ $ odoo crm records activity-update 736 731 --deadline 2026-08-28 --summary "Follow-up"
248
+
249
+ # Dry run
250
+ $ odoo crm records activity-update 736 --deadline 2026-08-28 --dry-run`)
251
+ .action(async (ids, opts) => {
252
+ await run((g) => activityUpdateCommand(ids, opts, g), program);
253
+ });
229
254
  const teams = crm.command("teams").description("sales teams (crm.team)");
230
255
  teams
231
256
  .command("list")
@@ -552,6 +577,52 @@ Examples:
552
577
  .action(async (opts) => {
553
578
  await run((g) => uploadCommand(opts, g), program);
554
579
  });
580
+ const partners = program
581
+ .command("partners")
582
+ .description("Contacts and companies (res.partner)");
583
+ partners
584
+ .command("list")
585
+ .description("search and list partners with curated default columns")
586
+ .option("--archived", "include archived partners (active = false) instead of active only")
587
+ .option("--company", "only companies (is_company = true)")
588
+ .option("--individual", "only individuals (is_company = false)")
589
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
590
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
591
+ .option("--fields <a,b,c>", "columns to display")
592
+ .option("--limit <n>", "max records (default: 50)", "50")
593
+ .option("--offset <n>", "skip the first n records", "0")
594
+ .option("--order <order>", 'sort order, e.g. "name asc"', undefined)
595
+ .action(async (opts) => {
596
+ await run((g) => partnersListCommand(opts, g), program);
597
+ });
598
+ partners
599
+ .command("show")
600
+ .description("read one partner")
601
+ .argument("<id>", "record id")
602
+ .option("--fields <a,b,c>", "fields to display")
603
+ .option("--all-fields", "display every readable field")
604
+ .action(async (id, opts) => {
605
+ await run((g) => partnersShowCommand(id, opts, g), program);
606
+ });
607
+ addMutationFlags(partners
608
+ .command("create")
609
+ .description("create a partner (company or individual; preview with --dry-run)"), resPartner).action(async (opts) => {
610
+ await run((g) => partnersCreateCommand(opts, g), program);
611
+ });
612
+ addMutationFlags(partners
613
+ .command("update")
614
+ .description("update partners (preview with --dry-run)"), resPartner)
615
+ .argument("<id...>", "record ids")
616
+ .action(async (ids, opts) => {
617
+ await run((g) => partnersUpdateCommand(ids, opts, g), program);
618
+ });
619
+ partners
620
+ .command("delete")
621
+ .description("delete partners (preview with --dry-run; unknown ids are skipped)")
622
+ .argument("<id...>", "record ids")
623
+ .action(async (ids) => {
624
+ await run((g) => partnersDeleteCommand(ids, g), program);
625
+ });
555
626
  return program;
556
627
  }
557
628
  async function run(fn, program) {
@@ -180,6 +180,101 @@ export async function activityDoneCommand(activityIds, globals) {
180
180
  console.log(`✓ Marked ${ids.length} activity(ies) as done: ${ids.join(", ")}`);
181
181
  }
182
182
  }
183
+ /**
184
+ * Update one or more activities (mail.activity) — deadline, summary, note,
185
+ * type, or assignee. Uses `write` directly on mail.activity.
186
+ */
187
+ export async function activityUpdateCommand(activityIds, opts, globals) {
188
+ const ids = activityIds.map((raw) => {
189
+ const n = Number.parseInt(raw, 10);
190
+ if (Number.isNaN(n) || n <= 0) {
191
+ throw usageError(`invalid activity id: ${raw}`, "provide a positive integer");
192
+ }
193
+ return n;
194
+ });
195
+ if (!opts.deadline && !opts.summary && opts.note === undefined && !opts.type && !opts.assignee) {
196
+ throw usageError("no fields to update", "provide at least one of --deadline, --summary, --note, --type, --assignee");
197
+ }
198
+ const client = await buildClient(globals);
199
+ // Verify the activities exist
200
+ const rows = (await client.executeKw("mail.activity", "search_read", [[["id", "in", ids]]], {
201
+ fields: ["id", "summary", "date_deadline", "state", "activity_type_id", "user_id"],
202
+ limit: ids.length,
203
+ }));
204
+ const byId = new Map(rows.map((r) => [r.id, r]));
205
+ const missing = ids.filter((id) => !byId.has(id));
206
+ if (missing.length > 0) {
207
+ throw notFoundError("record_not_found", `no mail.activity record(s) with id(s): ${missing.join(", ")}`, "no activities were modified — aborts the whole batch when any id is missing");
208
+ }
209
+ const values = {};
210
+ if (opts.deadline) {
211
+ values.date_deadline = parseDate(opts.deadline, "--deadline");
212
+ }
213
+ if (opts.summary) {
214
+ values.summary = opts.summary;
215
+ }
216
+ if (opts.note !== undefined) {
217
+ values.note = opts.note;
218
+ }
219
+ if (opts.type) {
220
+ const activityType = await resolveReference(client, "mail.activity.type", opts.type, {
221
+ label: "activity type",
222
+ });
223
+ values.activity_type_id = activityType.id;
224
+ }
225
+ if (opts.assignee) {
226
+ const user = await resolveReference(client, "res.users", opts.assignee, {
227
+ label: "user",
228
+ searchFields: ["login", "email", "name"],
229
+ });
230
+ values.user_id = user.id;
231
+ }
232
+ if (globals.verbose) {
233
+ logVerbose(`Updating ${ids.length} activity(ies): ${JSON.stringify(values)}`, globals.verbose);
234
+ }
235
+ if (globals.dryRun) {
236
+ if (globals.json) {
237
+ printJson({
238
+ model: "mail.activity",
239
+ action: "update",
240
+ dry_run: true,
241
+ ids,
242
+ values,
243
+ activities: rows.map((r) => ({
244
+ id: r.id,
245
+ summary: r.summary,
246
+ date_deadline: r.date_deadline,
247
+ state: r.state,
248
+ })),
249
+ });
250
+ }
251
+ else {
252
+ console.log(`[DRY RUN] Would update ${ids.length} activity(ies):`);
253
+ for (const r of rows) {
254
+ console.log(` #${r.id} — ${r.summary} (deadline: ${r.date_deadline ?? "none"}, state: ${r.state})`);
255
+ }
256
+ console.log(` New values: ${JSON.stringify(values, null, 2)}`);
257
+ }
258
+ return;
259
+ }
260
+ await client.executeKw("mail.activity", "write", [ids, values]);
261
+ if (globals.json) {
262
+ printJson({
263
+ model: "mail.activity",
264
+ action: "update",
265
+ dry_run: false,
266
+ updated: ids,
267
+ values,
268
+ });
269
+ }
270
+ else {
271
+ console.log(`✓ Updated ${ids.length} activity(ies): ${ids.join(", ")}`);
272
+ if (opts.deadline)
273
+ console.log(` New deadline: ${values.date_deadline}`);
274
+ if (opts.summary)
275
+ console.log(` New summary: ${values.summary}`);
276
+ }
277
+ }
183
278
  /**
184
279
  * List activities for a crm.lead record.
185
280
  */
@@ -0,0 +1,244 @@
1
+ import { ModelFields } from "../model.js";
2
+ import { authenticatedClient, showCommand, } from "../resource.js";
3
+ import { createCommand, deleteCommand, updateCommand, } from "../mutation.js";
4
+ import { buildFilterClauses, parseDomain } from "../filter.js";
5
+ import { usageError } from "../errors.js";
6
+ import { colorEnabled, printJson } from "../output.js";
7
+ import { formatTable, humanCell } from "../table.js";
8
+ function parseYesNoFlag(raw, flagName) {
9
+ const v = raw.toLowerCase();
10
+ if (v === "true" || v === "1" || v === "yes")
11
+ return true;
12
+ if (v === "false" || v === "0" || v === "no")
13
+ return false;
14
+ throw usageError(`invalid ${flagName} value ${JSON.stringify(raw)}`, "expected: true or false");
15
+ }
16
+ /**
17
+ * Mutation flags for res.partner. Keeps a focused first-class surface — the
18
+ * full partner model has hundreds of fields; `--set` and `--values` cover the
19
+ * long tail. `--parent` resolves the parent company (many2one), and
20
+ * `--is-company` is a boolean flag matching the Odoo `is_company` selection.
21
+ */
22
+ const resPartnerMutation = {
23
+ flags: [
24
+ {
25
+ flags: "--name <name>",
26
+ option: "name",
27
+ field: "name",
28
+ help: "Partner name",
29
+ requiredOnCreate: true,
30
+ },
31
+ {
32
+ flags: "--email <address>",
33
+ option: "email",
34
+ field: "email",
35
+ help: "Email",
36
+ },
37
+ {
38
+ flags: "--phone <number>",
39
+ option: "phone",
40
+ field: "phone",
41
+ help: "Phone",
42
+ },
43
+ {
44
+ flags: "--website <url>",
45
+ option: "website",
46
+ field: "website",
47
+ help: "Website",
48
+ },
49
+ {
50
+ flags: "--parent <name|id>",
51
+ option: "parent",
52
+ field: "parent_id",
53
+ help: "Parent company (name or id)",
54
+ reference: {
55
+ model: "res.partner",
56
+ label: "partner",
57
+ searchFields: ["name", "email"],
58
+ },
59
+ },
60
+ {
61
+ flags: "--is-company <true|false>",
62
+ option: "isCompany",
63
+ field: "is_company",
64
+ help: "Is a company (default: false on create)",
65
+ parse: parseYesNoFlag,
66
+ },
67
+ {
68
+ flags: "--active <true|false>",
69
+ option: "active",
70
+ field: "active",
71
+ help: "Active flag (default: true on create)",
72
+ parse: parseYesNoFlag,
73
+ },
74
+ {
75
+ flags: "--street <text>",
76
+ option: "street",
77
+ field: "street",
78
+ help: "Street",
79
+ },
80
+ {
81
+ flags: "--city <text>",
82
+ option: "city",
83
+ field: "city",
84
+ help: "City",
85
+ },
86
+ {
87
+ flags: "--zip <code>",
88
+ option: "zip",
89
+ field: "zip",
90
+ help: "Postal / ZIP code",
91
+ },
92
+ {
93
+ flags: "--country <name|id>",
94
+ option: "country",
95
+ field: "country_id",
96
+ help: "Country (name or id)",
97
+ reference: {
98
+ model: "res.country",
99
+ label: "country",
100
+ },
101
+ },
102
+ {
103
+ flags: "--comment <text>",
104
+ option: "comment",
105
+ field: "comment",
106
+ help: "Internal comment",
107
+ },
108
+ ],
109
+ createDefaultValues: {
110
+ is_company: false,
111
+ active: true,
112
+ },
113
+ };
114
+ export const resPartner = {
115
+ model: "res.partner",
116
+ showCommand: "odoo partners show",
117
+ defaultListFields: [
118
+ "id",
119
+ "name",
120
+ "is_company",
121
+ "email",
122
+ "phone",
123
+ "parent_id",
124
+ "active",
125
+ "create_date",
126
+ ],
127
+ showExtraFields: [
128
+ "website",
129
+ "street",
130
+ "city",
131
+ "zip",
132
+ "country_id",
133
+ "comment",
134
+ "company_id",
135
+ "write_date",
136
+ ],
137
+ resolutionHints: {
138
+ parent_id: "--parent",
139
+ country_id: "--country",
140
+ },
141
+ priorityLabels: {},
142
+ pinnedDomain: [],
143
+ references: {
144
+ assignee: {
145
+ model: "res.partner",
146
+ label: "partner",
147
+ field: "parent_id",
148
+ searchFields: ["name", "email"],
149
+ },
150
+ },
151
+ mutation: resPartnerMutation,
152
+ };
153
+ function parseCount(raw, flag) {
154
+ if (!/^\d+$/.test(raw)) {
155
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
156
+ }
157
+ return Number(raw);
158
+ }
159
+ function parseFieldList(raw) {
160
+ const fields = raw
161
+ .split(",")
162
+ .map((f) => f.trim())
163
+ .filter((f) => f !== "");
164
+ if (fields.length === 0) {
165
+ throw usageError("--fields requires at least one field name");
166
+ }
167
+ return fields;
168
+ }
169
+ export async function partnersListCommand(opts, globals) {
170
+ const client = await authenticatedClient(globals);
171
+ if (opts.domain && opts.filter && opts.filter.length > 0) {
172
+ throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
173
+ }
174
+ if (opts.company && opts.individual) {
175
+ throw usageError("--company and --individual are mutually exclusive", "pick one (or neither to list both)");
176
+ }
177
+ const limit = parseCount(opts.limit, "--limit");
178
+ const offset = parseCount(opts.offset, "--offset");
179
+ const fieldsMeta = await ModelFields.load(client, "res.partner");
180
+ const domain = [];
181
+ if (opts.archived) {
182
+ domain.push(["active", "=", false]);
183
+ }
184
+ else {
185
+ domain.push(["active", "=", true]);
186
+ }
187
+ if (opts.company)
188
+ domain.push(["is_company", "=", true]);
189
+ if (opts.individual)
190
+ domain.push(["is_company", "=", false]);
191
+ if (opts.domain) {
192
+ domain.push(...parseDomain(opts.domain));
193
+ }
194
+ else if (opts.filter && opts.filter.length > 0) {
195
+ domain.push(...buildFilterClauses(opts.filter, fieldsMeta, {
196
+ parent_id: "--parent",
197
+ country_id: "--country",
198
+ }));
199
+ }
200
+ const fields = opts.fields
201
+ ? parseFieldList(opts.fields)
202
+ : resPartner.defaultListFields;
203
+ for (const field of fields) {
204
+ if (field !== "id" && !fieldsMeta.get(field)) {
205
+ throw usageError(`unknown field ${JSON.stringify(field)} on res.partner`, `run \`odoo partners show <id> --all-fields\` to see available fields`);
206
+ }
207
+ }
208
+ const order = opts.order ?? "id asc";
209
+ // active_test:false so Odoo returns both active and archived rows; the
210
+ // domain above is the authoritative filter.
211
+ const kwargs = {
212
+ fields,
213
+ limit,
214
+ offset,
215
+ order,
216
+ context: { active_test: false },
217
+ };
218
+ const [records, count] = await Promise.all([
219
+ client.executeKw("res.partner", "search_read", [domain], kwargs),
220
+ client.executeKw("res.partner", "search_count", [domain], {
221
+ context: { active_test: false },
222
+ }),
223
+ ]);
224
+ if (globals.json) {
225
+ printJson({ model: "res.partner", records, count, limit, offset });
226
+ return;
227
+ }
228
+ const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
229
+ const rows = records.map((rec) => fields.map((f) => humanCell(rec[f])));
230
+ const color = colorEnabled(globals.noColor);
231
+ process.stdout.write(formatTable(headers, rows, color) + "\n");
232
+ }
233
+ export function partnersShowCommand(idArg, opts, globals) {
234
+ return showCommand(resPartner, idArg, opts, globals);
235
+ }
236
+ export function partnersCreateCommand(opts, globals) {
237
+ return createCommand(resPartner, opts, globals);
238
+ }
239
+ export function partnersUpdateCommand(idArgs, opts, globals) {
240
+ return updateCommand(resPartner, idArgs, opts, globals);
241
+ }
242
+ export function partnersDeleteCommand(idArgs, globals) {
243
+ return deleteCommand(resPartner, idArgs, globals);
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.2",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"