@bergetai/odoo-cli 0.1.0 → 0.1.3

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
@@ -4,6 +4,14 @@ Agent-first CLI for the [Odoo External API](https://www.odoo.com/documentation/1
4
4
 
5
5
  Every command is non-interactive, scriptable, and supports `--json` for stable machine-readable output.
6
6
 
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g @bergetai/odoo-cli
11
+ ```
12
+
13
+ Requires Node.js 20 or later.
14
+
7
15
  ## Usage
8
16
 
9
17
  ```bash
@@ -15,30 +23,36 @@ odoo auth status --json
15
23
 
16
24
  # Unauthenticated connectivity check
17
25
  odoo version
26
+
27
+ # List and inspect records
28
+ odoo crm leads list --open --limit 20
29
+ odoo crm leads show 42
30
+ odoo helpdesk tickets list --team Support --json
31
+
32
+ # Mutations (support --dry-run)
33
+ odoo crm leads create --name "New lead" --email info@example.com
34
+ odoo crm leads update 42 --stage Won
35
+ odoo crm leads delete 42 --yes
36
+
37
+ # Internal notes (NEVER sends email to customer)
38
+ odoo helpdesk tickets add-note 398 --body "Internal note text"
39
+ odoo helpdesk tickets add-note 398 --body-file note.md
18
40
  ```
19
41
 
20
42
  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.
21
43
 
22
44
  Note: `odoo --version` prints the CLI's own version; `odoo version` queries the server.
23
45
 
24
- ## Development
25
-
26
- ```bash
27
- npm install
28
- npm run build # compile to dist/
29
- npm test # build + unit/e2e tests against a fake XML-RPC server
30
- ```
31
-
32
- ## Status
46
+ ## Features
33
47
 
34
- - [x] Skeleton, transport, and auth — command tree, global flags, config sources and precedence with atomic `0600` config writes, XML-RPC transport with timeouts and HTTPS enforcement, `odoo version`, `odoo auth login`, `odoo auth status` with the auth failure taxonomy (db missing / bad credentials / unreachable)
35
- - [x] 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
36
- - [x] Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
37
- - [x] `--dry-run` machinery for mutations — full validation, resolution, and before/after diffs without mutating
38
- - [x] 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`
39
- - [ ] Polish and packaging
48
+ - 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
49
+ - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
50
+ - 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`
51
+ - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
52
+ - 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
53
+ - 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)
40
54
 
41
- ## Production-instance notes
55
+ ## Compatibility
42
56
 
43
57
  Validated against a real Odoo 18.0+e instance. Schema realities discovered
44
58
  against the live server:
@@ -52,3 +66,19 @@ against the live server:
52
66
  - `execute_kw` kwargs must be a separate trailing dict param; packing it into
53
67
  the positional-args array makes the server silently ignore it (this caused
54
68
  `fields_get` to return full/empty structs).
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ npm install
74
+ npm run build # compile to dist/
75
+ npm test # build + unit/e2e tests against a fake XML-RPC server
76
+ ```
77
+
78
+ ## Contributing
79
+
80
+ Issues and pull requests are welcome at [github.com/berget-ai/odoo-cli](https://github.com/berget-ai/odoo-cli).
81
+
82
+ ## License
83
+
84
+ [MIT](LICENSE)
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, ticketsShowCommand, ticketsUpdateCommand, helpdeskTickets, } from "./commands/helpdesk.js";
6
+ import { addNoteCommand } from "./commands/notes.js";
6
7
  import { versionCommand } from "./commands/version.js";
7
8
  import { CliError } from "./errors.js";
8
9
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
@@ -78,15 +79,16 @@ Examples:
78
79
  await run(authStatusCommand, program);
79
80
  });
80
81
  const crm = program.command("crm").description("CRM app (crm.lead)");
81
- const leads = crm.command("leads").description("leads (type=lead)");
82
+ const leads = crm.command("records").description("leads and opportunities (crm.lead)");
82
83
  leads
83
84
  .command("list")
84
- .description("search and list leads with curated default columns")
85
+ .description("search and list leads/opportunities with curated default columns")
85
86
  .option("--stage <name|id>", "filter by stage (resolved by name)")
86
87
  .option("--team <name|id>", "filter by sales team (resolved by name)")
87
88
  .option("--assignee <name|id>", "filter by salesperson (login, email, or name)")
88
89
  .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
89
90
  .option("--priority <level>", "filter by priority: low, medium, high, urgent")
91
+ .option("--type <lead|opportunity>", "filter by record type (default: all)")
90
92
  .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
91
93
  .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
92
94
  .option("--fields <a,b,c>", "columns to display")
@@ -98,7 +100,7 @@ Examples:
98
100
  });
99
101
  leads
100
102
  .command("show")
101
- .description("read one lead")
103
+ .description("read one record")
102
104
  .argument("<id>", "record id")
103
105
  .option("--fields <a,b,c>", "fields to display")
104
106
  .option("--all-fields", "display every readable field")
@@ -107,19 +109,19 @@ Examples:
107
109
  });
108
110
  addMutationFlags(leads
109
111
  .command("create")
110
- .description("create a lead (preview with --dry-run)"), crmLeads).action(async (opts) => {
112
+ .description("create a record (preview with --dry-run)"), crmLeads).action(async (opts) => {
111
113
  await run((g) => leadsCreateCommand(opts, g), program);
112
114
  });
113
115
  addMutationFlags(leads
114
116
  .command("update")
115
- .description("update leads (preview with --dry-run)"), crmLeads)
117
+ .description("update records (preview with --dry-run)"), crmLeads)
116
118
  .argument("<id...>", "record ids")
117
119
  .action(async (ids, opts) => {
118
120
  await run((g) => leadsUpdateCommand(ids, opts, g), program);
119
121
  });
120
122
  leads
121
123
  .command("delete")
122
- .description("delete leads (preview with --dry-run; unknown ids are skipped)")
124
+ .description("delete records (preview with --dry-run; unknown ids are skipped)")
123
125
  .argument("<id...>", "record ids")
124
126
  .action(async (ids) => {
125
127
  await run((g) => leadsDeleteCommand(ids, g), program);
@@ -181,6 +183,28 @@ the raw form.`)
181
183
  .action(async (ids) => {
182
184
  await run((g) => ticketsDeleteCommand(ids, g), program);
183
185
  });
186
+ tickets
187
+ .command("add-note")
188
+ .description("add an internal Log Note to a ticket (NEVER sends email to customer)")
189
+ .argument("<id>", "ticket id")
190
+ .option("--body <text>", "note body (HTML allowed)")
191
+ .option("--body-file <path>", "read note body from file ('-' for stdin)")
192
+ .addHelpText("after", `
193
+ IMPORTANT: This command creates INTERNAL notes only. These notes:
194
+ • Are NEVER sent to the customer via email
195
+ • Are only visible to internal users in Odoo
196
+ • Use subtype_id=2 (Note) and message_type=comment
197
+ • Cannot be used for customer-facing communication
198
+
199
+ This is by design to prevent accidental customer emails.
200
+
201
+ Examples:
202
+ $ odoo helpdesk tickets add-note 398 --body "Internal note text"
203
+ $ odoo helpdesk tickets add-note 398 --body-file note.md
204
+ $ cat note.md | odoo helpdesk tickets add-note 398 --body-file -`)
205
+ .action(async (id, opts) => {
206
+ await run((g) => addNoteCommand(id, opts, g), program);
207
+ });
184
208
  return program;
185
209
  }
186
210
  async function run(fn, program) {
@@ -1,8 +1,8 @@
1
+ import { usageError } from "../errors.js";
1
2
  import { createCommand, deleteCommand, parseDate, parseDecimal, parsePriorityFlag, parseProbability, updateCommand, } from "../mutation.js";
2
3
  import { listCommand, showCommand, } from "../resource.js";
3
4
  /** CRM create/update field→flag mapping (§5.3). */
4
5
  const crmLeadsMutation = {
5
- createPinnedValues: { type: "lead" },
6
6
  flags: [
7
7
  {
8
8
  flags: "--name <name>",
@@ -115,6 +115,19 @@ const crmLeadsMutation = {
115
115
  help: "Lead medium",
116
116
  reference: { model: "utm.medium", label: "medium" },
117
117
  },
118
+ {
119
+ flags: "--type <lead|opportunity>",
120
+ option: "type",
121
+ field: "type",
122
+ help: "Record type: lead (default) or opportunity",
123
+ parse: (raw, flagName) => {
124
+ const v = raw.toLowerCase();
125
+ if (v !== "lead" && v !== "opportunity") {
126
+ throw usageError(`invalid ${flagName} ${JSON.stringify(raw)}`, "expected: lead or opportunity");
127
+ }
128
+ return v;
129
+ },
130
+ },
118
131
  {
119
132
  flags: "--description <text>",
120
133
  option: "description",
@@ -125,7 +138,7 @@ const crmLeadsMutation = {
125
138
  };
126
139
  export const crmLeads = {
127
140
  model: "crm.lead",
128
- showCommand: "odoo crm leads show",
141
+ showCommand: "odoo crm records show",
129
142
  defaultListFields: [
130
143
  "id",
131
144
  "name",
@@ -161,7 +174,7 @@ export const crmLeads = {
161
174
  "2": "High",
162
175
  "3": "Very High",
163
176
  },
164
- pinnedDomain: [["type", "=", "lead"]],
177
+ pinnedDomain: [],
165
178
  references: {
166
179
  team: { model: "crm.team", label: "team", field: "team_id" },
167
180
  stage: {
@@ -180,6 +193,15 @@ export const crmLeads = {
180
193
  },
181
194
  // Phase 3 (§4.4, §4.2, §5.4): dry-run plans for create/update/delete.
182
195
  mutation: crmLeadsMutation,
196
+ // --type filter: lead (default), opportunity, or all
197
+ extraFilters: (opts) => {
198
+ const o = opts;
199
+ if (o.type === "lead" || o.type === "opportunity") {
200
+ return Promise.resolve([["type", "=", o.type]]);
201
+ }
202
+ // default: no filter (both leads and opportunities)
203
+ return Promise.resolve([]);
204
+ },
183
205
  };
184
206
  export function leadsListCommand(opts, globals) {
185
207
  return listCommand(crmLeads, opts, globals);
@@ -0,0 +1,92 @@
1
+ import { OdooClient } from "../client.js";
2
+ import { resolveConfig } from "../config.js";
3
+ import { notFoundError, usageError } from "../errors.js";
4
+ import { printJson, verbose as logVerbose } from "../output.js";
5
+ export async function addNoteCommand(ticketId, opts, globals) {
6
+ const id = Number.parseInt(ticketId, 10);
7
+ if (Number.isNaN(id) || id <= 0) {
8
+ throw usageError(`invalid ticket id: ${ticketId}`, "provide a positive integer");
9
+ }
10
+ // Read body from file if specified
11
+ let body = opts.body;
12
+ if (opts.bodyFile) {
13
+ const fs = await import("node:fs/promises");
14
+ try {
15
+ body = await fs.readFile(opts.bodyFile, "utf-8");
16
+ }
17
+ catch (error) {
18
+ throw usageError(`failed to read body file: ${opts.bodyFile}`, error instanceof Error ? error.message : String(error));
19
+ }
20
+ }
21
+ if (!body || body.trim().length === 0) {
22
+ throw usageError("note body cannot be empty", "provide --body or --body-file");
23
+ }
24
+ const config = await resolveConfig(globals);
25
+ if (!config.url || !config.db || !config.username || !config.apiKey) {
26
+ throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
27
+ }
28
+ const { parseDurationMs } = await import("../duration.js");
29
+ const timeoutMs = parseDurationMs(globals.timeout);
30
+ const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
31
+ // Verify ticket exists
32
+ const tickets = (await client.executeKw("helpdesk.ticket", "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
33
+ if (!tickets || tickets.length === 0) {
34
+ throw notFoundError("ticket_not_found", `helpdesk ticket #${id} does not exist`, "check the ticket id");
35
+ }
36
+ // Create internal Log Note
37
+ // CRITICAL: These parameters ensure the note is INTERNAL ONLY
38
+ // - subtype_id=2: "Note" subtype (internal, not sent to customer)
39
+ // - message_type="comment": Internal comment (not email)
40
+ // - author_id: Use the authenticated user's partner_id
41
+ const noteParams = {
42
+ model: "helpdesk.ticket",
43
+ res_id: id,
44
+ body: body,
45
+ message_type: "comment",
46
+ subtype_id: 2, // Note subtype = internal only, NEVER sends email
47
+ };
48
+ if (globals.verbose) {
49
+ logVerbose(`Creating internal note for ticket #${id} (${tickets[0].name})`, globals.verbose);
50
+ logVerbose(`Note parameters: ${JSON.stringify(noteParams, null, 2)}`, globals.verbose);
51
+ }
52
+ if (globals.dryRun) {
53
+ const result = {
54
+ dry_run: true,
55
+ ticket_id: id,
56
+ ticket_name: tickets[0].name,
57
+ note_type: "internal_log_note",
58
+ will_send_email: false,
59
+ body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
60
+ body_length: body.length,
61
+ };
62
+ if (globals.json) {
63
+ printJson(result);
64
+ }
65
+ else {
66
+ console.log(`[DRY RUN] Would create internal Log Note:`);
67
+ console.log(` Ticket: #${id} — ${tickets[0].name}`);
68
+ console.log(` Type: Internal note (never sent to customer)`);
69
+ console.log(` Body length: ${body.length} characters`);
70
+ console.log(` Body preview: ${result.body_preview}`);
71
+ }
72
+ return;
73
+ }
74
+ const noteId = (await client.executeKw("mail.message", "create", [
75
+ [noteParams],
76
+ ]));
77
+ const result = {
78
+ note_id: noteId,
79
+ ticket_id: id,
80
+ ticket_name: tickets[0].name,
81
+ note_type: "internal_log_note",
82
+ email_sent: false,
83
+ };
84
+ if (globals.json) {
85
+ printJson(result);
86
+ }
87
+ else {
88
+ console.log(`✓ Internal Log Note created (ID: ${noteId})`);
89
+ console.log(` Ticket: #${id} — ${tickets[0].name}`);
90
+ console.log(` Type: Internal note (not sent to customer)`);
91
+ }
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"