@bergetai/odoo-cli 0.3.2 → 0.4.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
@@ -40,6 +40,13 @@ odoo crm leads create --name "New lead" --email info@example.com
40
40
  odoo crm leads update 42 --stage Won
41
41
  odoo crm leads delete 42 --yes
42
42
 
43
+ # Sales teams (crm.team)
44
+ odoo crm teams list
45
+ odoo crm teams show 5
46
+ odoo crm teams create --name "Partnerships" --team-leader alice@acme.com
47
+ odoo crm teams update 5 --name "Direct Sales (renamed)" --dry-run
48
+ odoo crm teams delete 5 --dry-run
49
+
43
50
  # Internal notes (NEVER sends email to customer)
44
51
  odoo helpdesk tickets add-note 398 --body "Internal note text"
45
52
  odoo helpdesk tickets add-note 398 --body-file note.md
@@ -52,6 +59,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
52
59
  ## Features
53
60
 
54
61
  - 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
62
+ - 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
55
63
  - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
56
64
  - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
57
65
  - 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)
package/dist/cli.js CHANGED
@@ -2,8 +2,10 @@
2
2
  import { Command, CommanderError } from "commander";
3
3
  import { authLoginCommand, authStatusCommand } from "./commands/auth.js";
4
4
  import { leadsCreateCommand, leadsDeleteCommand, leadsListCommand, leadsShowCommand, leadsUpdateCommand, crmLeads, } from "./commands/crm.js";
5
+ import { teamsCreateCommand, teamsDeleteCommand, teamsListCommand, teamsShowCommand, teamsUpdateCommand, crmTeams, } from "./commands/teams.js";
5
6
  import { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, ticketsShowCommand, ticketsUpdateCommand, helpdeskTickets, } from "./commands/helpdesk.js";
6
7
  import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
8
+ import { activitiesListCommand, activityDoneCommand, scheduleCommand, } from "./commands/activities.js";
7
9
  import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
8
10
  import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, } from "./commands/documents.js";
9
11
  import { versionCommand } from "./commands/version.js";
@@ -130,6 +132,142 @@ Examples:
130
132
  .action(async (ids) => {
131
133
  await run((g) => leadsDeleteCommand(ids, g), program);
132
134
  });
135
+ leads
136
+ .command("add-note")
137
+ .description("add an internal Log Note to a lead/opportunity (NEVER sends email)")
138
+ .argument("<id>", "record id")
139
+ .option("--body <text>", "note body (HTML allowed)")
140
+ .option("--body-file <path>", "read note body from file ('-' for stdin)")
141
+ .addHelpText("after", `
142
+ IMPORTANT: This command creates INTERNAL notes only. These notes:
143
+ • Are NEVER sent to the customer via email
144
+ • Are only visible to internal users in Odoo
145
+ • Use subtype_id=2 (Note) and message_type=comment
146
+ • Cannot be used for customer-facing communication
147
+
148
+ This is by design to prevent accidental customer emails.
149
+
150
+ Examples:
151
+ $ odoo crm records add-note 338 --body "Internal note text"
152
+ $ odoo crm records add-note 338 --body-file note.md
153
+ $ cat note.md | odoo crm records add-note 338 --body-file -`)
154
+ .action(async (id, opts) => {
155
+ await run((g) => addNoteCommand(id, opts, g, "crm.lead"), program);
156
+ });
157
+ leads
158
+ .command("messages")
159
+ .description("list all messages for a record (emails, comments, notes)")
160
+ .argument("<id>", "record id")
161
+ .option("--limit <n>", "max messages to fetch", "50")
162
+ .option("--message-type <type>", "filter by message type (email, comment, notification)")
163
+ .option("--subtype <id>", "filter by subtype ID")
164
+ .option("--author <id>", "filter by author ID")
165
+ .option("--since <date>", "only messages after this date (YYYY-MM-DD HH:MM:SS)")
166
+ .addHelpText("after", `
167
+ Examples:
168
+ # List all messages for lead 338
169
+ $ odoo crm records messages 338
170
+
171
+ # List only internal notes
172
+ $ odoo crm records messages 338 --subtype 2
173
+
174
+ # JSON output for scripting
175
+ $ odoo crm records messages 338 --json`)
176
+ .action(async (id, opts) => {
177
+ await run((g) => listMessagesCommand(id, opts, g, "crm.lead"), program);
178
+ });
179
+ leads
180
+ .command("schedule")
181
+ .description("schedule a new activity on a record (preview with --dry-run)")
182
+ .argument("<id>", "record id")
183
+ .requiredOption("--type <name|id>", "activity type (e.g. To-Do, Call, Meeting)")
184
+ .requiredOption("--deadline <yyyy-mm-dd>", "activity deadline")
185
+ .requiredOption("--summary <text>", "short summary (max 1 sentence)")
186
+ .option("--note <text>", "optional note (1-2 lines, point to the log)")
187
+ .option("--assignee <name|id>", "assignee (login, email, or name; default: you)")
188
+ .addHelpText("after", `
189
+ A scheduled activity is the source of truth for a record's next step.
190
+ An opportunity without a scheduled activity is a red flag.
191
+
192
+ Examples:
193
+ $ odoo crm records schedule 338 --type "To-Do" --deadline 2026-08-25 \\
194
+ --summary "Send quote to Fredrik"
195
+ $ odoo crm records schedule 338 --type Meeting --deadline 2026-09-01 \\
196
+ --summary "Kickoff call" --assignee erik@berget.ai
197
+ $ odoo crm records schedule 338 --type "To-Do" --deadline 2026-08-25 \\
198
+ --summary "Send quote" --dry-run`)
199
+ .action(async (id, opts) => {
200
+ await run((g) => scheduleCommand(id, opts, g), program);
201
+ });
202
+ leads
203
+ .command("activities")
204
+ .description("list activities for a record")
205
+ .argument("<id>", "record id")
206
+ .option("--open", "only open (pending/planned) activities")
207
+ .option("--limit <n>", "max activities to fetch", "50")
208
+ .addHelpText("after", `
209
+ Examples:
210
+ $ odoo crm records activities 338
211
+ $ odoo crm records activities 338 --open --json`)
212
+ .action(async (id, opts) => {
213
+ await run((g) => activitiesListCommand(id, opts, g), program);
214
+ });
215
+ leads
216
+ .command("activity-done")
217
+ .description("mark activities as done via action_done (preview with --dry-run)")
218
+ .argument("<id...>", "activity ids")
219
+ .addHelpText("after", `
220
+ Uses action_done — the only reliable way to close an activity in Odoo
221
+ (writing state='done' directly does not work).
222
+
223
+ Examples:
224
+ $ odoo crm records activity-done 714
225
+ $ odoo crm records activity-done 714 721 --dry-run`)
226
+ .action(async (ids) => {
227
+ await run((g) => activityDoneCommand(ids, g), program);
228
+ });
229
+ const teams = crm.command("teams").description("sales teams (crm.team)");
230
+ teams
231
+ .command("list")
232
+ .description("search and list sales teams with curated default columns")
233
+ .option("--archived", "include archived teams (active = false) instead of active only")
234
+ .option("--domain <json>", "Odoo domain as a JSON array")
235
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
236
+ .option("--fields <a,b,c>", "columns to display")
237
+ .option("--limit <n>", "max records (default: 50)", "50")
238
+ .option("--offset <n>", "skip the first n records", "0")
239
+ .option("--order <order>", 'sort order, e.g. "name asc"', undefined)
240
+ .action(async (opts) => {
241
+ await run((g) => teamsListCommand(opts, g), program);
242
+ });
243
+ teams
244
+ .command("show")
245
+ .description("read one sales team")
246
+ .argument("<id>", "record id")
247
+ .option("--fields <a,b,c>", "fields to display")
248
+ .option("--all-fields", "display every readable field")
249
+ .action(async (id, opts) => {
250
+ await run((g) => teamsShowCommand(id, opts, g), program);
251
+ });
252
+ addMutationFlags(teams
253
+ .command("create")
254
+ .description("create a sales team (preview with --dry-run)"), crmTeams).action(async (opts) => {
255
+ await run((g) => teamsCreateCommand(opts, g), program);
256
+ });
257
+ addMutationFlags(teams
258
+ .command("update")
259
+ .description("update sales teams (preview with --dry-run)"), crmTeams)
260
+ .argument("<id...>", "record ids")
261
+ .action(async (ids, opts) => {
262
+ await run((g) => teamsUpdateCommand(ids, opts, g), program);
263
+ });
264
+ teams
265
+ .command("delete")
266
+ .description("delete sales teams (preview with --dry-run; unknown ids are skipped)")
267
+ .argument("<id...>", "record ids")
268
+ .action(async (ids) => {
269
+ await run((g) => teamsDeleteCommand(ids, g), program);
270
+ });
133
271
  const helpdesk = program
134
272
  .command("helpdesk")
135
273
  .description("Helpdesk app (helpdesk.ticket, Enterprise)");
@@ -207,7 +345,7 @@ Examples:
207
345
  $ odoo helpdesk tickets add-note 398 --body-file note.md
208
346
  $ cat note.md | odoo helpdesk tickets add-note 398 --body-file -`)
209
347
  .action(async (id, opts) => {
210
- await run((g) => addNoteCommand(id, opts, g), program);
348
+ await run((g) => addNoteCommand(id, opts, g, "helpdesk.ticket"), program);
211
349
  });
212
350
  tickets
213
351
  .command("messages")
@@ -235,7 +373,7 @@ Examples:
235
373
  # JSON output for scripting
236
374
  $ odoo helpdesk tickets messages 398 --json`)
237
375
  .action(async (id, opts) => {
238
- await run((g) => listMessagesCommand(id, opts, g), program);
376
+ await run((g) => listMessagesCommand(id, opts, g, "helpdesk.ticket"), program);
239
377
  });
240
378
  const project = program
241
379
  .command("project")
@@ -0,0 +1,239 @@
1
+ import { OdooClient } from "../client.js";
2
+ import { resolveConfig } from "../config.js";
3
+ import { notFoundError, usageError } from "../errors.js";
4
+ import { parseDate } from "../mutation.js";
5
+ import { resolveReference } from "../model.js";
6
+ import { printJson, verbose as logVerbose } from "../output.js";
7
+ import { formatTable } from "../table.js";
8
+ /**
9
+ * Scheduled activities (mail.activity) for crm.lead records.
10
+ *
11
+ * A `mail.activity` is the source of truth for a lead's next step. It carries
12
+ * a type (To-Do, Call, Meeting, Email, ...), a deadline, an assignee, and a
13
+ * short note. `action_done` marks it complete (the only reliable way to close
14
+ * an activity — writing state='done' directly does not work in Odoo).
15
+ */
16
+ const CRM_LEAD_MODEL = "crm.lead";
17
+ async function buildClient(globals) {
18
+ const config = await resolveConfig(globals);
19
+ if (!config.url || !config.db || !config.username || !config.apiKey) {
20
+ throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
21
+ }
22
+ const { parseDurationMs } = await import("../duration.js");
23
+ const timeoutMs = parseDurationMs(globals.timeout);
24
+ return new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
25
+ }
26
+ /** Resolve the ir.model id for a technical model name (e.g. "crm.lead"). */
27
+ async function resolveResModelId(client, technicalName) {
28
+ const rows = (await client.executeKw("ir.model", "search_read", [[["model", "=", technicalName]]], { fields: ["id"], limit: 1 }));
29
+ if (rows.length === 0) {
30
+ throw notFoundError("model_not_found", `no ir.model for ${JSON.stringify(technicalName)}`);
31
+ }
32
+ return rows[0].id;
33
+ }
34
+ /**
35
+ * Schedule a new activity on a crm.lead record.
36
+ */
37
+ export async function scheduleCommand(recordId, opts, globals) {
38
+ const id = Number.parseInt(recordId, 10);
39
+ if (Number.isNaN(id) || id <= 0) {
40
+ throw usageError(`invalid lead id: ${recordId}`, "provide a positive integer");
41
+ }
42
+ const client = await buildClient(globals);
43
+ // Verify the lead exists
44
+ const leads = (await client.executeKw(CRM_LEAD_MODEL, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
45
+ if (!leads || leads.length === 0) {
46
+ throw notFoundError("record_not_found", `crm.lead #${id} does not exist`, "check the lead id");
47
+ }
48
+ // Resolve the activity type by name or id
49
+ const activityType = await resolveReference(client, "mail.activity.type", opts.type, {
50
+ label: "activity type",
51
+ });
52
+ // Resolve the assignee (defaults to the authenticated user)
53
+ let assigneeId;
54
+ let assigneeName;
55
+ if (opts.assignee) {
56
+ const user = await resolveReference(client, "res.users", opts.assignee, {
57
+ label: "user",
58
+ searchFields: ["login", "email", "name"],
59
+ });
60
+ assigneeId = user.id;
61
+ assigneeName = user.name;
62
+ }
63
+ else {
64
+ // The authenticated user's own id is not directly exposed; resolve the
65
+ // current user via the uid returned by authenticate.
66
+ const uid = await client.authenticate();
67
+ const me = (await client.executeKw("res.users", "search_read", [[["id", "=", uid]]], { fields: ["id", "name"], limit: 1 }));
68
+ assigneeId = me[0]?.id ?? uid;
69
+ assigneeName = me[0]?.name ?? String(uid);
70
+ }
71
+ const resModelId = await resolveResModelId(client, CRM_LEAD_MODEL);
72
+ const deadline = parseDate(opts.deadline, "--deadline");
73
+ const values = {
74
+ res_model_id: resModelId,
75
+ res_id: id,
76
+ activity_type_id: activityType.id,
77
+ user_id: assigneeId,
78
+ date_deadline: deadline,
79
+ summary: opts.summary,
80
+ ...(opts.note ? { note: opts.note } : {}),
81
+ };
82
+ if (globals.verbose) {
83
+ logVerbose(`Scheduling activity on crm.lead #${id} (${leads[0].name})`, globals.verbose);
84
+ logVerbose(`Activity values: ${JSON.stringify(values, null, 2)}`, globals.verbose);
85
+ }
86
+ if (globals.dryRun) {
87
+ if (globals.json) {
88
+ printJson({
89
+ model: "mail.activity",
90
+ action: "create",
91
+ dry_run: true,
92
+ record: { model: CRM_LEAD_MODEL, id, name: leads[0].name },
93
+ values,
94
+ resolved: {
95
+ activity_type_id: [activityType.id, activityType.name],
96
+ user_id: [assigneeId, assigneeName],
97
+ },
98
+ });
99
+ }
100
+ else {
101
+ console.log(`[DRY RUN] Would schedule activity on crm.lead #${id} — ${leads[0].name}:`);
102
+ console.log(` Type: ${activityType.name} (${activityType.id})`);
103
+ console.log(` Assignee: ${assigneeName} (${assigneeId})`);
104
+ console.log(` Deadline: ${deadline}`);
105
+ console.log(` Summary: ${opts.summary}`);
106
+ if (opts.note)
107
+ console.log(` Note: ${opts.note}`);
108
+ }
109
+ return;
110
+ }
111
+ const activityId = (await client.executeKw("mail.activity", "create", [
112
+ values,
113
+ ]));
114
+ if (globals.json) {
115
+ printJson({
116
+ model: "mail.activity",
117
+ action: "create",
118
+ dry_run: false,
119
+ id: activityId,
120
+ record: { model: CRM_LEAD_MODEL, id, name: leads[0].name },
121
+ resolved: {
122
+ activity_type_id: [activityType.id, activityType.name],
123
+ user_id: [assigneeId, assigneeName],
124
+ },
125
+ });
126
+ }
127
+ else {
128
+ console.log(`✓ Activity scheduled (ID: ${activityId})`);
129
+ console.log(` Lead: crm.lead #${id} — ${leads[0].name}`);
130
+ console.log(` Type: ${activityType.name} · Deadline: ${deadline} · Assignee: ${assigneeName}`);
131
+ }
132
+ }
133
+ /**
134
+ * Mark one or more activities as done via `action_done`.
135
+ */
136
+ export async function activityDoneCommand(activityIds, globals) {
137
+ const ids = activityIds.map((raw) => {
138
+ const n = Number.parseInt(raw, 10);
139
+ if (Number.isNaN(n) || n <= 0) {
140
+ throw usageError(`invalid activity id: ${raw}`, "provide a positive integer");
141
+ }
142
+ return n;
143
+ });
144
+ const client = await buildClient(globals);
145
+ // Verify the activities exist
146
+ const rows = (await client.executeKw("mail.activity", "search_read", [[["id", "in", ids]]], { fields: ["id", "summary", "state"], limit: ids.length }));
147
+ const byId = new Map(rows.map((r) => [r.id, r]));
148
+ const missing = ids.filter((id) => !byId.has(id));
149
+ if (missing.length > 0) {
150
+ 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");
151
+ }
152
+ if (globals.dryRun) {
153
+ if (globals.json) {
154
+ printJson({
155
+ model: "mail.activity",
156
+ action: "action_done",
157
+ dry_run: true,
158
+ ids,
159
+ activities: rows.map((r) => ({ id: r.id, summary: r.summary, state: r.state })),
160
+ });
161
+ }
162
+ else {
163
+ console.log(`[DRY RUN] Would mark ${ids.length} activity(ies) as done:`);
164
+ for (const r of rows) {
165
+ console.log(` #${r.id} — ${r.summary} (${r.state})`);
166
+ }
167
+ }
168
+ return;
169
+ }
170
+ await client.executeKw("mail.activity", "action_done", [ids]);
171
+ if (globals.json) {
172
+ printJson({
173
+ model: "mail.activity",
174
+ action: "action_done",
175
+ dry_run: false,
176
+ done: ids,
177
+ });
178
+ }
179
+ else {
180
+ console.log(`✓ Marked ${ids.length} activity(ies) as done: ${ids.join(", ")}`);
181
+ }
182
+ }
183
+ /**
184
+ * List activities for a crm.lead record.
185
+ */
186
+ export async function activitiesListCommand(recordId, opts, globals) {
187
+ const id = Number.parseInt(recordId, 10);
188
+ if (Number.isNaN(id) || id <= 0) {
189
+ throw usageError(`invalid lead id: ${recordId}`, "provide a positive integer");
190
+ }
191
+ const client = await buildClient(globals);
192
+ // Verify the lead exists
193
+ const leads = (await client.executeKw(CRM_LEAD_MODEL, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
194
+ if (!leads || leads.length === 0) {
195
+ throw notFoundError("record_not_found", `crm.lead #${id} does not exist`, "check the lead id");
196
+ }
197
+ const resModelId = await resolveResModelId(client, CRM_LEAD_MODEL);
198
+ const domain = [
199
+ ["res_model_id", "=", resModelId],
200
+ ["res_id", "=", id],
201
+ ];
202
+ if (opts.open) {
203
+ domain.push(["state", "in", ["pending", "planned", "a_review"]]);
204
+ }
205
+ const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 50;
206
+ const activities = (await client.executeKw("mail.activity", "search_read", [domain], {
207
+ fields: [
208
+ "id",
209
+ "activity_type_id",
210
+ "date_deadline",
211
+ "summary",
212
+ "note",
213
+ "state",
214
+ "user_id",
215
+ ],
216
+ limit,
217
+ order: "date_deadline asc",
218
+ }));
219
+ if (globals.json) {
220
+ printJson({
221
+ model: "mail.activity",
222
+ record: { model: CRM_LEAD_MODEL, id, name: leads[0].name },
223
+ count: activities.length,
224
+ activities,
225
+ });
226
+ return;
227
+ }
228
+ console.log(`Activities for crm.lead #${id}: ${leads[0].name}`);
229
+ console.log(`Found ${activities.length} activities\n`);
230
+ const rows = activities.map((a) => [
231
+ a.id.toString(),
232
+ a.state,
233
+ a.activity_type_id ? a.activity_type_id[1] : "",
234
+ a.date_deadline ? String(a.date_deadline) : "",
235
+ a.user_id ? a.user_id[1] : "",
236
+ a.summary,
237
+ ]);
238
+ console.log(formatTable(["ID", "State", "Type", "Deadline", "Assignee", "Summary"], rows, !globals.noColor));
239
+ }
@@ -3,12 +3,27 @@ import { resolveConfig } from "../config.js";
3
3
  import { notFoundError, usageError } from "../errors.js";
4
4
  import { printJson, verbose as logVerbose } from "../output.js";
5
5
  import { formatTable } from "../table.js";
6
- export async function addNoteCommand(ticketId, opts, globals) {
7
- const id = Number.parseInt(ticketId, 10);
8
- if (Number.isNaN(id) || id <= 0) {
9
- throw usageError(`invalid ticket id: ${ticketId}`, "provide a positive integer");
6
+ const THREAD_MODELS = {
7
+ "helpdesk.ticket": { model: "helpdesk.ticket", label: "ticket" },
8
+ "crm.lead": { model: "crm.lead", label: "lead" },
9
+ };
10
+ function threadModel(model) {
11
+ const spec = THREAD_MODELS[model];
12
+ if (!spec) {
13
+ throw usageError(`unsupported thread model ${JSON.stringify(model)}`, `expected one of: ${Object.keys(THREAD_MODELS).join(", ")}`);
14
+ }
15
+ return spec;
16
+ }
17
+ async function buildClient(globals) {
18
+ const config = await resolveConfig(globals);
19
+ if (!config.url || !config.db || !config.username || !config.apiKey) {
20
+ throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
10
21
  }
11
- // Read body from file if specified
22
+ const { parseDurationMs } = await import("../duration.js");
23
+ const timeoutMs = parseDurationMs(globals.timeout);
24
+ return new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
25
+ }
26
+ async function readBody(opts) {
12
27
  let body = opts.body;
13
28
  if (opts.bodyFile) {
14
29
  const fs = await import("node:fs/promises");
@@ -22,39 +37,45 @@ export async function addNoteCommand(ticketId, opts, globals) {
22
37
  if (!body || body.trim().length === 0) {
23
38
  throw usageError("note body cannot be empty", "provide --body or --body-file");
24
39
  }
25
- const config = await resolveConfig(globals);
26
- if (!config.url || !config.db || !config.username || !config.apiKey) {
27
- throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
40
+ return body;
41
+ }
42
+ /**
43
+ * Add an internal Log Note to a mail-thread record (ticket or lead).
44
+ */
45
+ export async function addNoteCommand(recordId, opts, globals, model) {
46
+ const spec = threadModel(model);
47
+ const id = Number.parseInt(recordId, 10);
48
+ if (Number.isNaN(id) || id <= 0) {
49
+ throw usageError(`invalid ${spec.label} id: ${recordId}`, "provide a positive integer");
28
50
  }
29
- const { parseDurationMs } = await import("../duration.js");
30
- const timeoutMs = parseDurationMs(globals.timeout);
31
- const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
32
- // Verify ticket exists
33
- const tickets = (await client.executeKw("helpdesk.ticket", "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
34
- if (!tickets || tickets.length === 0) {
35
- throw notFoundError("ticket_not_found", `helpdesk ticket #${id} does not exist`, "check the ticket id");
51
+ const body = await readBody(opts);
52
+ const client = await buildClient(globals);
53
+ // Verify the record exists
54
+ const rows = (await client.executeKw(spec.model, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
55
+ if (!rows || rows.length === 0) {
56
+ throw notFoundError("record_not_found", `${spec.model} #${id} does not exist`, `check the ${spec.label} id`);
36
57
  }
37
58
  // Create internal Log Note
38
59
  // CRITICAL: These parameters ensure the note is INTERNAL ONLY
39
60
  // - subtype_id=2: "Note" subtype (internal, not sent to customer)
40
61
  // - message_type="comment": Internal comment (not email)
41
- // - author_id: Use the authenticated user's partner_id
42
62
  const noteParams = {
43
- model: "helpdesk.ticket",
63
+ model: spec.model,
44
64
  res_id: id,
45
65
  body: body,
46
66
  message_type: "comment",
47
67
  subtype_id: 2, // Note subtype = internal only, NEVER sends email
48
68
  };
49
69
  if (globals.verbose) {
50
- logVerbose(`Creating internal note for ticket #${id} (${tickets[0].name})`, globals.verbose);
70
+ logVerbose(`Creating internal note for ${spec.model} #${id} (${rows[0].name})`, globals.verbose);
51
71
  logVerbose(`Note parameters: ${JSON.stringify(noteParams, null, 2)}`, globals.verbose);
52
72
  }
53
73
  if (globals.dryRun) {
54
74
  const result = {
55
75
  dry_run: true,
56
- ticket_id: id,
57
- ticket_name: tickets[0].name,
76
+ model: spec.model,
77
+ record_id: id,
78
+ record_name: rows[0].name,
58
79
  note_type: "internal_log_note",
59
80
  will_send_email: false,
60
81
  body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
@@ -65,7 +86,7 @@ export async function addNoteCommand(ticketId, opts, globals) {
65
86
  }
66
87
  else {
67
88
  console.log(`[DRY RUN] Would create internal Log Note:`);
68
- console.log(` Ticket: #${id} — ${tickets[0].name}`);
89
+ console.log(` Record: ${spec.model} #${id} — ${rows[0].name}`);
69
90
  console.log(` Type: Internal note (never sent to customer)`);
70
91
  console.log(` Body length: ${body.length} characters`);
71
92
  console.log(` Body preview: ${result.body_preview}`);
@@ -77,8 +98,9 @@ export async function addNoteCommand(ticketId, opts, globals) {
77
98
  ]));
78
99
  const result = {
79
100
  note_id: noteId,
80
- ticket_id: id,
81
- ticket_name: tickets[0].name,
101
+ model: spec.model,
102
+ record_id: id,
103
+ record_name: rows[0].name,
82
104
  note_type: "internal_log_note",
83
105
  email_sent: false,
84
106
  };
@@ -87,37 +109,31 @@ export async function addNoteCommand(ticketId, opts, globals) {
87
109
  }
88
110
  else {
89
111
  console.log(`✓ Internal Log Note created (ID: ${noteId})`);
90
- console.log(` Ticket: #${id} — ${tickets[0].name}`);
112
+ console.log(` Record: ${spec.model} #${id} — ${rows[0].name}`);
91
113
  console.log(` Type: Internal note (not sent to customer)`);
92
114
  }
93
115
  }
94
116
  /**
95
- * List messages for a helpdesk ticket.
117
+ * List messages for a mail-thread record (emails, comments, notes).
96
118
  *
97
- * Fetches all messages (emails, comments, notes) associated with a ticket.
98
- * Useful for analyzing ticket history and determining if customer is waiting
99
- * for a response.
119
+ * Fetches all messages associated with the record. Useful for analyzing
120
+ * history and determining if the customer is waiting for a response.
100
121
  */
101
- export async function listMessagesCommand(ticketId, opts, globals) {
102
- const id = Number.parseInt(ticketId, 10);
122
+ export async function listMessagesCommand(recordId, opts, globals, model) {
123
+ const spec = threadModel(model);
124
+ const id = Number.parseInt(recordId, 10);
103
125
  if (Number.isNaN(id) || id <= 0) {
104
- throw usageError(`invalid ticket id: ${ticketId}`, "provide a positive integer");
126
+ throw usageError(`invalid ${spec.label} id: ${recordId}`, "provide a positive integer");
105
127
  }
106
- const config = await resolveConfig(globals);
107
- if (!config.url || !config.db || !config.username || !config.apiKey) {
108
- throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
109
- }
110
- const { parseDurationMs } = await import("../duration.js");
111
- const timeoutMs = parseDurationMs(globals.timeout);
112
- const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
113
- // Verify ticket exists
114
- const tickets = (await client.executeKw("helpdesk.ticket", "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
115
- if (!tickets || tickets.length === 0) {
116
- throw notFoundError("ticket_not_found", `helpdesk ticket #${id} does not exist`, "check the ticket id");
128
+ const client = await buildClient(globals);
129
+ // Verify the record exists
130
+ const rows = (await client.executeKw(spec.model, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
131
+ if (!rows || rows.length === 0) {
132
+ throw notFoundError("record_not_found", `${spec.model} #${id} does not exist`, `check the ${spec.label} id`);
117
133
  }
118
134
  // Build domain for messages
119
135
  const domain = [
120
- ["model", "=", "helpdesk.ticket"],
136
+ ["model", "=", spec.model],
121
137
  ["res_id", "=", id],
122
138
  ];
123
139
  // Add optional filters
@@ -141,7 +157,7 @@ export async function listMessagesCommand(ticketId, opts, globals) {
141
157
  }
142
158
  const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 50;
143
159
  if (globals.verbose) {
144
- logVerbose(`Fetching messages for ticket #${id}`, globals.verbose);
160
+ logVerbose(`Fetching messages for ${spec.model} #${id}`, globals.verbose);
145
161
  logVerbose(`Domain: ${JSON.stringify(domain)}`, globals.verbose);
146
162
  }
147
163
  const messages = (await client.executeKw("mail.message", "search_read", [domain], {
@@ -160,16 +176,17 @@ export async function listMessagesCommand(ticketId, opts, globals) {
160
176
  }));
161
177
  if (globals.json) {
162
178
  printJson({
163
- ticket_id: id,
164
- ticket_name: tickets[0].name,
179
+ model: spec.model,
180
+ record_id: id,
181
+ record_name: rows[0].name,
165
182
  message_count: messages.length,
166
183
  messages,
167
184
  });
168
185
  }
169
186
  else {
170
- console.log(`Messages for ticket #${id}: ${tickets[0].name}`);
187
+ console.log(`Messages for ${spec.model} #${id}: ${rows[0].name}`);
171
188
  console.log(`Found ${messages.length} messages\n`);
172
- const rows = messages.map((msg) => {
189
+ const rowsOut = messages.map((msg) => {
173
190
  const author = msg.author_id ? msg.author_id[1] : msg.email_from || "System";
174
191
  const subtype = msg.subtype_id ? msg.subtype_id[1] : "";
175
192
  const bodyPreview = msg.body
@@ -186,6 +203,6 @@ export async function listMessagesCommand(ticketId, opts, globals) {
186
203
  bodyPreview + (msg.body.length > 60 ? "..." : ""),
187
204
  ];
188
205
  });
189
- console.log(formatTable(["ID", "Date", "Type", "Subtype", "Author", "Preview"], rows, !globals.noColor));
206
+ console.log(formatTable(["ID", "Date", "Type", "Subtype", "Author", "Preview"], rowsOut, !globals.noColor));
190
207
  }
191
208
  }
@@ -0,0 +1,199 @@
1
+ import { ModelFields } from "../model.js";
2
+ import { authenticatedClient, showCommand, } from "../resource.js";
3
+ import { createCommand, deleteCommand, parseDecimal, 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 parseBooleanFlag(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
+ const crmTeamsMutation = {
17
+ flags: [
18
+ {
19
+ flags: "--name <name>",
20
+ option: "name",
21
+ field: "name",
22
+ help: "Sales team name",
23
+ requiredOnCreate: true,
24
+ },
25
+ {
26
+ flags: "--team-leader <name|id>",
27
+ option: "teamLeader",
28
+ field: "user_id",
29
+ help: "Team leader (login, email, or name)",
30
+ reference: {
31
+ model: "res.users",
32
+ label: "user",
33
+ searchFields: ["login", "email", "name"],
34
+ },
35
+ },
36
+ {
37
+ flags: "--active <true|false>",
38
+ option: "active",
39
+ field: "active",
40
+ help: "Active flag (default: true on create)",
41
+ parse: parseBooleanFlag,
42
+ },
43
+ {
44
+ flags: "--use-leads <true|false>",
45
+ option: "useLeads",
46
+ field: "use_leads",
47
+ help: "Enable leads",
48
+ parse: parseBooleanFlag,
49
+ },
50
+ {
51
+ flags: "--use-opportunities <true|false>",
52
+ option: "useOpportunities",
53
+ field: "use_opportunities",
54
+ help: "Enable pipeline (opportunities)",
55
+ parse: parseBooleanFlag,
56
+ },
57
+ {
58
+ flags: "--invoiced-target <amount>",
59
+ option: "invoicedTarget",
60
+ field: "invoiced_target",
61
+ help: "Invoicing target",
62
+ parse: parseDecimal,
63
+ },
64
+ {
65
+ flags: "--color <index>",
66
+ option: "color",
67
+ field: "color",
68
+ help: "Color index (kanban)",
69
+ parse: parseDecimal,
70
+ },
71
+ {
72
+ flags: "--sequence <n>",
73
+ option: "sequence",
74
+ field: "sequence",
75
+ help: "Sort sequence",
76
+ parse: parseDecimal,
77
+ },
78
+ ],
79
+ };
80
+ export const crmTeams = {
81
+ model: "crm.team",
82
+ showCommand: "odoo crm teams show",
83
+ defaultListFields: [
84
+ "id",
85
+ "name",
86
+ "user_id",
87
+ "active",
88
+ "is_favorite",
89
+ "create_date",
90
+ ],
91
+ showExtraFields: [
92
+ "company_id",
93
+ "use_leads",
94
+ "use_opportunities",
95
+ "invoiced_target",
96
+ "color",
97
+ "sequence",
98
+ "member_ids",
99
+ "write_date",
100
+ ],
101
+ resolutionHints: {
102
+ user_id: "--team-leader",
103
+ },
104
+ priorityLabels: {},
105
+ pinnedDomain: [],
106
+ references: {
107
+ assignee: {
108
+ model: "res.users",
109
+ label: "team leader",
110
+ field: "user_id",
111
+ searchFields: ["login", "email", "name"],
112
+ },
113
+ },
114
+ mutation: crmTeamsMutation,
115
+ };
116
+ function parseCount(raw, flag) {
117
+ if (!/^\d+$/.test(raw)) {
118
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
119
+ }
120
+ return Number(raw);
121
+ }
122
+ function parseFieldList(raw) {
123
+ const fields = raw
124
+ .split(",")
125
+ .map((f) => f.trim())
126
+ .filter((f) => f !== "");
127
+ if (fields.length === 0) {
128
+ throw usageError("--fields requires at least one field name");
129
+ }
130
+ return fields;
131
+ }
132
+ export async function teamsListCommand(opts, globals) {
133
+ const client = await authenticatedClient(globals);
134
+ if (opts.domain && opts.filter && opts.filter.length > 0) {
135
+ throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
136
+ }
137
+ const limit = parseCount(opts.limit, "--limit");
138
+ const offset = parseCount(opts.offset, "--offset");
139
+ const fieldsMeta = await ModelFields.load(client, "crm.team");
140
+ const domain = [];
141
+ if (opts.archived) {
142
+ domain.push(["active", "=", false]);
143
+ }
144
+ else {
145
+ domain.push(["active", "=", true]);
146
+ }
147
+ if (opts.domain) {
148
+ domain.push(...parseDomain(opts.domain));
149
+ }
150
+ else if (opts.filter && opts.filter.length > 0) {
151
+ domain.push(...buildFilterClauses(opts.filter, fieldsMeta, {
152
+ user_id: "--team-leader",
153
+ }));
154
+ }
155
+ const fields = opts.fields
156
+ ? parseFieldList(opts.fields)
157
+ : crmTeams.defaultListFields;
158
+ for (const field of fields) {
159
+ if (field !== "id" && !fieldsMeta.get(field)) {
160
+ throw usageError(`unknown field ${JSON.stringify(field)} on crm.team`, `run \`odoo crm teams show <id> --all-fields\` to see available fields`);
161
+ }
162
+ }
163
+ const order = opts.order ?? "id asc";
164
+ // active_test:false so Odoo returns both active and archived rows; the
165
+ // domain above does the actual active filtering.
166
+ const kwargs = {
167
+ fields,
168
+ limit,
169
+ offset,
170
+ order,
171
+ context: { active_test: false },
172
+ };
173
+ const [records, count] = await Promise.all([
174
+ client.executeKw("crm.team", "search_read", [domain], kwargs),
175
+ client.executeKw("crm.team", "search_count", [domain], {
176
+ context: { active_test: false },
177
+ }),
178
+ ]);
179
+ if (globals.json) {
180
+ printJson({ model: "crm.team", records, count, limit, offset });
181
+ return;
182
+ }
183
+ const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
184
+ const rows = records.map((rec) => fields.map((f) => humanCell(rec[f])));
185
+ const color = colorEnabled(globals.noColor);
186
+ process.stdout.write(formatTable(headers, rows, color) + "\n");
187
+ }
188
+ export function teamsShowCommand(idArg, opts, globals) {
189
+ return showCommand(crmTeams, idArg, opts, globals);
190
+ }
191
+ export function teamsCreateCommand(opts, globals) {
192
+ return createCommand(crmTeams, opts, globals);
193
+ }
194
+ export function teamsUpdateCommand(idArgs, opts, globals) {
195
+ return updateCommand(crmTeams, idArgs, opts, globals);
196
+ }
197
+ export function teamsDeleteCommand(idArgs, globals) {
198
+ return deleteCommand(crmTeams, idArgs, globals);
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"