@bergetai/odoo-cli 0.5.3 → 0.7.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
@@ -57,6 +57,13 @@ odoo crm teams delete 5 --dry-run
57
57
  # Internal notes (NEVER sends email to customer)
58
58
  odoo helpdesk tickets add-note 398 --body "Internal note text"
59
59
  odoo helpdesk tickets add-note 398 --body-file note.md
60
+ odoo crm records add-note 338 --body "Customer replied: ..." --author 40
61
+
62
+ # Log note correction (message-update / message-delete; comment-type only)
63
+ odoo crm records message-update 270799 --body-file fixed.html --dry-run
64
+ odoo crm records message-update 270799 --body-file fixed.html
65
+ odoo crm records message-delete 253924 258332 --dry-run
66
+ odoo crm records message-delete 253924 258332
60
67
  ```
61
68
 
62
69
  Configuration precedence: flags > environment (`ODOO_URL`, `ODOO_DB`, `ODOO_USERNAME`, `ODOO_API_KEY`) > `$XDG_CONFIG_HOME/odoo/config.json`. The API key is never accepted as a flag value.
@@ -74,7 +81,8 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
74
81
  - 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`, and `upload` (local file → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
75
82
  - 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`
76
83
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
77
- - 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
84
+ - 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 via `res.partner`; ambiguous matches list candidates and never guess)
85
+ - 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-run` support, unknown delete ids skipped
78
86
  - Config sources and precedence with atomic `0600` config writes, XML-RPC transport with timeouts and HTTPS enforcement, and an auth failure taxonomy (db missing / bad credentials / unreachable)
79
87
 
80
88
  ## Compatibility
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { authLoginCommand, authStatusCommand } from "./commands/auth.js";
4
4
  import { leadsCreateCommand, leadsDeleteCommand, leadsListCommand, leadsShowCommand, leadsUpdateCommand, crmLeads, } from "./commands/crm.js";
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
- import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
7
+ import { addNoteCommand, deleteMessageCommand, listMessagesCommand, updateMessageCommand, } from "./commands/notes.js";
8
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";
@@ -139,6 +139,7 @@ Examples:
139
139
  .argument("<id>", "record id")
140
140
  .option("--body <text>", "note body (HTML allowed)")
141
141
  .option("--body-file <path>", "read note body from file ('-' for stdin)")
142
+ .option("--author <email|name|id>", "note author (res.partner; default: your own partner)")
142
143
  .addHelpText("after", `
143
144
  IMPORTANT: This command creates INTERNAL notes only. These notes:
144
145
  • Are NEVER sent to the customer via email
@@ -151,7 +152,8 @@ This is by design to prevent accidental customer emails.
151
152
  Examples:
152
153
  $ odoo crm records add-note 338 --body "Internal note text"
153
154
  $ odoo crm records add-note 338 --body-file note.md
154
- $ cat note.md | odoo crm records add-note 338 --body-file -`)
155
+ $ cat note.md | odoo crm records add-note 338 --body-file -
156
+ $ odoo crm records add-note 338 --body "Kundens svar: ..." --author andreas@berget.ai`)
155
157
  .action(async (id, opts) => {
156
158
  await run((g) => addNoteCommand(id, opts, g, "crm.lead"), program);
157
159
  });
@@ -177,6 +179,41 @@ Examples:
177
179
  .action(async (id, opts) => {
178
180
  await run((g) => listMessagesCommand(id, opts, g, "crm.lead"), program);
179
181
  });
182
+ leads
183
+ .command("message-update")
184
+ .description("update the body of an existing log note (preview with --dry-run)")
185
+ .argument("<id>", "message id (see: records messages <id>)")
186
+ .option("--body <text>", "new body (HTML allowed)")
187
+ .option("--body-file <path>", "read new body from file ('-' for stdin)")
188
+ .addHelpText("after", `
189
+ Log notes (message_type "comment") and "Activities" traces (Meeting/To-Do
190
+ done — for correcting minutes after the fact) can be updated. Emails and other
191
+ notifications are refused by design. The message id and date are preserved.
192
+
193
+ Examples:
194
+ # Preview a formatting repair
195
+ $ odoo crm records message-update 270799 --body-file fixed.html --dry-run
196
+
197
+ # Apply it
198
+ $ odoo crm records message-update 270799 --body-file fixed.html`)
199
+ .action(async (id, opts) => {
200
+ await run((g) => updateMessageCommand(id, opts, g, "crm.lead"), program);
201
+ });
202
+ leads
203
+ .command("message-delete")
204
+ .description("delete log notes, e.g. duplicate logs (preview with --dry-run; unknown ids are skipped)")
205
+ .argument("<id...>", "message ids (see: records messages <id>)")
206
+ .addHelpText("after", `
207
+ Only internal log notes (message_type "comment") can be deleted — emails and
208
+ notifications are refused by design. Deletion is irreversible: run --dry-run
209
+ first and consider consolidating content into the note you keep.
210
+
211
+ Examples:
212
+ $ odoo crm records message-delete 253924 258332 --dry-run
213
+ $ odoo crm records message-delete 253924 258332 --json`)
214
+ .action(async (ids) => {
215
+ await run((g) => deleteMessageCommand(ids, g, "crm.lead"), program);
216
+ });
180
217
  leads
181
218
  .command("schedule")
182
219
  .description("schedule a new activity on a record (preview with --dry-run)")
@@ -356,6 +393,7 @@ the raw form.`)
356
393
  .argument("<id>", "ticket id")
357
394
  .option("--body <text>", "note body (HTML allowed)")
358
395
  .option("--body-file <path>", "read note body from file ('-' for stdin)")
396
+ .option("--author <email|name|id>", "note author (res.partner; default: your own partner)")
359
397
  .addHelpText("after", `
360
398
  IMPORTANT: This command creates INTERNAL notes only. These notes:
361
399
  • Are NEVER sent to the customer via email
@@ -368,7 +406,8 @@ This is by design to prevent accidental customer emails.
368
406
  Examples:
369
407
  $ odoo helpdesk tickets add-note 398 --body "Internal note text"
370
408
  $ odoo helpdesk tickets add-note 398 --body-file note.md
371
- $ cat note.md | odoo helpdesk tickets add-note 398 --body-file -`)
409
+ $ cat note.md | odoo helpdesk tickets add-note 398 --body-file -
410
+ $ odoo helpdesk tickets add-note 398 --body "Kundens svar: ..." --author kund@example.com`)
372
411
  .action(async (id, opts) => {
373
412
  await run((g) => addNoteCommand(id, opts, g, "helpdesk.ticket"), program);
374
413
  });
@@ -400,6 +439,38 @@ Examples:
400
439
  .action(async (id, opts) => {
401
440
  await run((g) => listMessagesCommand(id, opts, g, "helpdesk.ticket"), program);
402
441
  });
442
+ tickets
443
+ .command("message-update")
444
+ .description("update the body of an existing log note (preview with --dry-run)")
445
+ .argument("<id>", "message id (see: tickets messages <id>)")
446
+ .option("--body <text>", "new body (HTML allowed)")
447
+ .option("--body-file <path>", "read new body from file ('-' for stdin)")
448
+ .addHelpText("after", `
449
+ Log notes (message_type "comment") and "Activities" traces (Meeting/To-Do
450
+ done — for correcting minutes after the fact) can be updated. Emails and other
451
+ notifications are refused by design. The message id and date are preserved.
452
+
453
+ Examples:
454
+ $ odoo helpdesk tickets message-update 270799 --body-file fixed.html --dry-run
455
+ $ odoo helpdesk tickets message-update 270799 --body-file fixed.html`)
456
+ .action(async (id, opts) => {
457
+ await run((g) => updateMessageCommand(id, opts, g, "helpdesk.ticket"), program);
458
+ });
459
+ tickets
460
+ .command("message-delete")
461
+ .description("delete log notes, e.g. duplicate logs (preview with --dry-run; unknown ids are skipped)")
462
+ .argument("<id...>", "message ids (see: tickets messages <id>)")
463
+ .addHelpText("after", `
464
+ Only internal log notes (message_type "comment") can be deleted — emails and
465
+ notifications are refused by design. Deletion is irreversible: run --dry-run
466
+ first and consider consolidating content into the note you keep.
467
+
468
+ Examples:
469
+ $ odoo helpdesk tickets message-delete 253924 258332 --dry-run
470
+ $ odoo helpdesk tickets message-delete 253924 258332 --json`)
471
+ .action(async (ids) => {
472
+ await run((g) => deleteMessageCommand(ids, g, "helpdesk.ticket"), program);
473
+ });
403
474
  const project = program
404
475
  .command("project")
405
476
  .description("Project app (project.project, project.task)");
@@ -1,6 +1,7 @@
1
1
  import { OdooClient } from "../client.js";
2
2
  import { resolveConfig } from "../config.js";
3
3
  import { notFoundError, usageError } from "../errors.js";
4
+ import { resolveReference } from "../model.js";
4
5
  import { printJson, verbose as logVerbose } from "../output.js";
5
6
  import { formatTable } from "../table.js";
6
7
  const THREAD_MODELS = {
@@ -55,6 +56,21 @@ export async function addNoteCommand(recordId, opts, globals, model) {
55
56
  if (!rows || rows.length === 0) {
56
57
  throw notFoundError("record_not_found", `${spec.model} #${id} does not exist`, `check the ${spec.label} id`);
57
58
  }
59
+ // Optional: attribute the note to the person who actually sent the
60
+ // logged message (e.g. a forwarded customer email). author_id is a
61
+ // res.partner — resolve by exact email, name, or numeric id. Without
62
+ // --author Odoo defaults to the authenticated user's partner.
63
+ let author;
64
+ if (opts.author) {
65
+ author = await resolveReference(client, "res.partner", opts.author, {
66
+ label: "author",
67
+ searchFields: ["name"],
68
+ exactFields: ["email"],
69
+ });
70
+ if (globals.verbose) {
71
+ logVerbose(`resolved --author "${opts.author}" -> [${author.id}, "${author.name}"]`, globals.verbose);
72
+ }
73
+ }
58
74
  // Create internal Log Note
59
75
  // CRITICAL: These parameters ensure the note is INTERNAL ONLY
60
76
  // - subtype_id=2: "Note" subtype (internal, not sent to customer)
@@ -66,6 +82,9 @@ export async function addNoteCommand(recordId, opts, globals, model) {
66
82
  message_type: "comment",
67
83
  subtype_id: 2, // Note subtype = internal only, NEVER sends email
68
84
  };
85
+ if (author) {
86
+ noteParams.author_id = author.id;
87
+ }
69
88
  if (globals.verbose) {
70
89
  logVerbose(`Creating internal note for ${spec.model} #${id} (${rows[0].name})`, globals.verbose);
71
90
  logVerbose(`Note parameters: ${JSON.stringify(noteParams, null, 2)}`, globals.verbose);
@@ -78,6 +97,7 @@ export async function addNoteCommand(recordId, opts, globals, model) {
78
97
  record_name: rows[0].name,
79
98
  note_type: "internal_log_note",
80
99
  will_send_email: false,
100
+ author: author ? { id: author.id, name: author.name } : null,
81
101
  body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
82
102
  body_length: body.length,
83
103
  };
@@ -88,6 +108,9 @@ export async function addNoteCommand(recordId, opts, globals, model) {
88
108
  console.log(`[DRY RUN] Would create internal Log Note:`);
89
109
  console.log(` Record: ${spec.model} #${id} — ${rows[0].name}`);
90
110
  console.log(` Type: Internal note (never sent to customer)`);
111
+ if (author) {
112
+ console.log(` Author: ${author.name} (${author.id})`);
113
+ }
91
114
  console.log(` Body length: ${body.length} characters`);
92
115
  console.log(` Body preview: ${result.body_preview}`);
93
116
  }
@@ -103,6 +126,7 @@ export async function addNoteCommand(recordId, opts, globals, model) {
103
126
  record_name: rows[0].name,
104
127
  note_type: "internal_log_note",
105
128
  email_sent: false,
129
+ author: author ? { id: author.id, name: author.name } : null,
106
130
  };
107
131
  if (globals.json) {
108
132
  printJson(result);
@@ -111,6 +135,167 @@ export async function addNoteCommand(recordId, opts, globals, model) {
111
135
  console.log(`✓ Internal Log Note created (ID: ${noteId})`);
112
136
  console.log(` Record: ${spec.model} #${id} — ${rows[0].name}`);
113
137
  console.log(` Type: Internal note (not sent to customer)`);
138
+ if (author) {
139
+ console.log(` Author: ${author.name} (${author.id})`);
140
+ }
141
+ }
142
+ }
143
+ /**
144
+ * Read a mail.message and enforce the safety rules shared by
145
+ * updateMessageCommand and deleteMessageCommand:
146
+ * - the message must exist
147
+ * - it must belong to the command's thread model (crm.lead / helpdesk.ticket)
148
+ * - for updates: it must be a comment (log note) OR an "Activities"
149
+ * notification (the "Meeting done"/"To-Do done" trace, whose embedded
150
+ * note may need correction after the activity is marked done)
151
+ * - for deletes: strictly comments — deleting activity traces would
152
+ * corrupt reporting (the done-message is all that remains of an activity)
153
+ */
154
+ async function readLogNote(client, messageId, spec, opts = {}) {
155
+ const rows = (await client.executeKw("mail.message", "read", [[messageId]], {
156
+ fields: ["id", "model", "res_id", "body", "message_type", "subtype_id", "date"],
157
+ }));
158
+ if (!rows || rows.length === 0) {
159
+ throw notFoundError("message_not_found", `mail.message #${messageId} does not exist`, "check the message id (see: records messages <id>)");
160
+ }
161
+ const msg = rows[0];
162
+ if (msg.model !== spec.model) {
163
+ throw usageError(`mail.message #${messageId} belongs to ${msg.model}, not ${spec.model}`, `use the ${msg.model === "helpdesk.ticket" ? "helpdesk tickets" : "crm records"} command for this message`);
164
+ }
165
+ const subtypeName = Array.isArray(msg.subtype_id) ? msg.subtype_id[1] : undefined;
166
+ const isComment = msg.message_type === "comment";
167
+ const isActivityTrace = msg.message_type === "notification" && subtypeName === "Activities";
168
+ if (opts.forDelete ? !isComment : !(isComment || isActivityTrace)) {
169
+ const allowed = opts.forDelete
170
+ ? 'only "comment" (log notes) can be deleted'
171
+ : 'only "comment" (log notes) and "Activities" traces can be modified';
172
+ throw usageError(`mail.message #${messageId} has type "${msg.message_type}"${subtypeName ? ` / subtype "${subtypeName}"` : ""} — ${allowed}`, "emails and system notifications are left untouched by design");
173
+ }
174
+ return msg;
175
+ }
176
+ function bodyPreview(body, max = 80) {
177
+ const text = body
178
+ .replace(/<[^>]*>/g, "")
179
+ .replace(/\s+/g, " ")
180
+ .trim();
181
+ return text.length > max ? `${text.substring(0, max)}...` : text;
182
+ }
183
+ /**
184
+ * Update the body of an existing internal log note (mail.message).
185
+ *
186
+ * Use case: repair formatting (escaped HTML, markdown, platform noise) or
187
+ * correct a note posted with wrong content. The message id and date are
188
+ * preserved — only the body changes.
189
+ */
190
+ export async function updateMessageCommand(messageId, opts, globals, model) {
191
+ const spec = threadModel(model);
192
+ const id = Number.parseInt(messageId, 10);
193
+ if (Number.isNaN(id) || id <= 0) {
194
+ throw usageError(`invalid message id: ${messageId}`, "provide a positive integer");
195
+ }
196
+ const body = await readBody(opts);
197
+ const client = await buildClient(globals);
198
+ const msg = await readLogNote(client, id, spec);
199
+ if (globals.dryRun) {
200
+ const result = {
201
+ dry_run: true,
202
+ message_id: id,
203
+ model: msg.model,
204
+ res_id: msg.res_id,
205
+ old_body_preview: bodyPreview(msg.body),
206
+ new_body_preview: bodyPreview(body),
207
+ new_body_length: body.length,
208
+ };
209
+ if (globals.json) {
210
+ printJson(result);
211
+ }
212
+ else {
213
+ console.log(`[DRY RUN] Would update log note #${id} on ${msg.model} #${msg.res_id}:`);
214
+ console.log(` Old: ${result.old_body_preview}`);
215
+ console.log(` New: ${result.new_body_preview}`);
216
+ }
217
+ return;
218
+ }
219
+ await client.executeKw("mail.message", "write", [[id], { body }]);
220
+ const result = {
221
+ message_id: id,
222
+ model: msg.model,
223
+ res_id: msg.res_id,
224
+ updated: true,
225
+ };
226
+ if (globals.json) {
227
+ printJson(result);
228
+ }
229
+ else {
230
+ console.log(`✓ Log note #${id} updated on ${msg.model} #${msg.res_id}`);
231
+ }
232
+ }
233
+ /**
234
+ * Delete internal log notes (mail.message) — e.g. duplicate logs of the
235
+ * same dialog. Unknown ids are skipped with a warning. Only comment-type
236
+ * messages can be deleted.
237
+ */
238
+ export async function deleteMessageCommand(messageIds, globals, model) {
239
+ const spec = threadModel(model);
240
+ const client = await buildClient(globals);
241
+ const toDelete = [];
242
+ const skipped = [];
243
+ for (const raw of messageIds) {
244
+ const id = Number.parseInt(raw, 10);
245
+ if (Number.isNaN(id) || id <= 0) {
246
+ skipped.push({ id: raw, reason: "invalid id" });
247
+ continue;
248
+ }
249
+ try {
250
+ toDelete.push(await readLogNote(client, id, spec, { forDelete: true }));
251
+ }
252
+ catch (error) {
253
+ if (error instanceof Error && "exitCode" in error && error.exitCode === 4) {
254
+ skipped.push({ id: raw, reason: "not found" });
255
+ continue;
256
+ }
257
+ throw error;
258
+ }
259
+ }
260
+ if (globals.dryRun) {
261
+ const result = {
262
+ dry_run: true,
263
+ would_delete: toDelete.map((m) => ({
264
+ message_id: m.id,
265
+ res_id: m.res_id,
266
+ date: m.date,
267
+ body_preview: bodyPreview(m.body),
268
+ })),
269
+ skipped,
270
+ };
271
+ if (globals.json) {
272
+ printJson(result);
273
+ }
274
+ else {
275
+ console.log(`[DRY RUN] Would delete ${toDelete.length} log note(s):`);
276
+ for (const m of toDelete) {
277
+ console.log(` #${m.id} (${m.date}) ${bodyPreview(m.body)}`);
278
+ }
279
+ for (const s of skipped) {
280
+ console.log(` skipped #${s.id}: ${s.reason}`);
281
+ }
282
+ }
283
+ return;
284
+ }
285
+ const deleted = [];
286
+ for (const m of toDelete) {
287
+ await client.executeKw("mail.message", "unlink", [[m.id]]);
288
+ deleted.push(m.id);
289
+ }
290
+ const result = { deleted, skipped };
291
+ if (globals.json) {
292
+ printJson(result);
293
+ }
294
+ else {
295
+ console.log(`✓ Deleted ${deleted.length} log note(s): ${deleted.join(", ") || "none"}`);
296
+ for (const s of skipped) {
297
+ console.log(` skipped #${s.id}: ${s.reason}`);
298
+ }
114
299
  }
115
300
  }
116
301
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.5.3",
3
+ "version": "0.7.1",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"