@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 +46 -16
- package/dist/cli.js +30 -6
- package/dist/commands/crm.js +25 -3
- package/dist/commands/notes.js +92 -0
- package/package.json +1 -1
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
|
-
##
|
|
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
|
-
-
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
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
|
-
##
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/dist/commands/crm.js
CHANGED
|
@@ -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
|
|
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: [
|
|
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
|
+
}
|