@bergetai/odoo-cli 0.1.0 → 0.2.0

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,38 @@ 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
+ odoo project list --json
32
+
33
+ # Mutations (support --dry-run)
34
+ odoo crm leads create --name "New lead" --email info@example.com
35
+ odoo crm leads update 42 --stage Won
36
+ odoo crm leads delete 42 --yes
37
+
38
+ # Internal notes (NEVER sends email to customer)
39
+ odoo helpdesk tickets add-note 398 --body "Internal note text"
40
+ odoo helpdesk tickets add-note 398 --body-file note.md
18
41
  ```
19
42
 
20
43
  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
44
 
22
45
  Note: `odoo --version` prints the CLI's own version; `odoo version` queries the server.
23
46
 
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
47
+ ## Features
33
48
 
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
49
+ - 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
50
+ - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
51
+ - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
52
+ - 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`
53
+ - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
54
+ - 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
55
+ - 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
56
 
41
- ## Production-instance notes
57
+ ## Compatibility
42
58
 
43
59
  Validated against a real Odoo 18.0+e instance. Schema realities discovered
44
60
  against the live server:
@@ -52,3 +68,19 @@ against the live server:
52
68
  - `execute_kw` kwargs must be a separate trailing dict param; packing it into
53
69
  the positional-args array makes the server silently ignore it (this caused
54
70
  `fields_get` to return full/empty structs).
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ npm install
76
+ npm run build # compile to dist/
77
+ npm test # build + unit/e2e tests against a fake XML-RPC server
78
+ ```
79
+
80
+ ## Contributing
81
+
82
+ Issues and pull requests are welcome at [github.com/berget-ai/odoo-cli](https://github.com/berget-ai/odoo-cli).
83
+
84
+ ## License
85
+
86
+ [MIT](LICENSE)
package/dist/cli.js CHANGED
@@ -3,6 +3,8 @@ 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, listMessagesCommand, } from "./commands/notes.js";
7
+ import { createTaskCommand, projectListCommand, projectShowCommand, } from "./commands/project.js";
6
8
  import { versionCommand } from "./commands/version.js";
7
9
  import { CliError } from "./errors.js";
8
10
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
@@ -78,15 +80,18 @@ Examples:
78
80
  await run(authStatusCommand, program);
79
81
  });
80
82
  const crm = program.command("crm").description("CRM app (crm.lead)");
81
- const leads = crm.command("leads").description("leads (type=lead)");
83
+ const leads = crm
84
+ .command("records")
85
+ .description("leads and opportunities (crm.lead)");
82
86
  leads
83
87
  .command("list")
84
- .description("search and list leads with curated default columns")
88
+ .description("search and list leads/opportunities with curated default columns")
85
89
  .option("--stage <name|id>", "filter by stage (resolved by name)")
86
90
  .option("--team <name|id>", "filter by sales team (resolved by name)")
87
91
  .option("--assignee <name|id>", "filter by salesperson (login, email, or name)")
88
92
  .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
89
93
  .option("--priority <level>", "filter by priority: low, medium, high, urgent")
94
+ .option("--type <lead|opportunity>", "filter by record type (default: all)")
90
95
  .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
91
96
  .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
92
97
  .option("--fields <a,b,c>", "columns to display")
@@ -98,7 +103,7 @@ Examples:
98
103
  });
99
104
  leads
100
105
  .command("show")
101
- .description("read one lead")
106
+ .description("read one record")
102
107
  .argument("<id>", "record id")
103
108
  .option("--fields <a,b,c>", "fields to display")
104
109
  .option("--all-fields", "display every readable field")
@@ -107,19 +112,19 @@ Examples:
107
112
  });
108
113
  addMutationFlags(leads
109
114
  .command("create")
110
- .description("create a lead (preview with --dry-run)"), crmLeads).action(async (opts) => {
115
+ .description("create a record (preview with --dry-run)"), crmLeads).action(async (opts) => {
111
116
  await run((g) => leadsCreateCommand(opts, g), program);
112
117
  });
113
118
  addMutationFlags(leads
114
119
  .command("update")
115
- .description("update leads (preview with --dry-run)"), crmLeads)
120
+ .description("update records (preview with --dry-run)"), crmLeads)
116
121
  .argument("<id...>", "record ids")
117
122
  .action(async (ids, opts) => {
118
123
  await run((g) => leadsUpdateCommand(ids, opts, g), program);
119
124
  });
120
125
  leads
121
126
  .command("delete")
122
- .description("delete leads (preview with --dry-run; unknown ids are skipped)")
127
+ .description("delete records (preview with --dry-run; unknown ids are skipped)")
123
128
  .argument("<id...>", "record ids")
124
129
  .action(async (ids) => {
125
130
  await run((g) => leadsDeleteCommand(ids, g), program);
@@ -181,6 +186,120 @@ the raw form.`)
181
186
  .action(async (ids) => {
182
187
  await run((g) => ticketsDeleteCommand(ids, g), program);
183
188
  });
189
+ tickets
190
+ .command("add-note")
191
+ .description("add an internal Log Note to a ticket (NEVER sends email to customer)")
192
+ .argument("<id>", "ticket id")
193
+ .option("--body <text>", "note body (HTML allowed)")
194
+ .option("--body-file <path>", "read note body from file ('-' for stdin)")
195
+ .addHelpText("after", `
196
+ IMPORTANT: This command creates INTERNAL notes only. These notes:
197
+ • Are NEVER sent to the customer via email
198
+ • Are only visible to internal users in Odoo
199
+ • Use subtype_id=2 (Note) and message_type=comment
200
+ • Cannot be used for customer-facing communication
201
+
202
+ This is by design to prevent accidental customer emails.
203
+
204
+ Examples:
205
+ $ odoo helpdesk tickets add-note 398 --body "Internal note text"
206
+ $ odoo helpdesk tickets add-note 398 --body-file note.md
207
+ $ cat note.md | odoo helpdesk tickets add-note 398 --body-file -`)
208
+ .action(async (id, opts) => {
209
+ await run((g) => addNoteCommand(id, opts, g), program);
210
+ });
211
+ tickets
212
+ .command("messages")
213
+ .description("list all messages for a ticket (emails, comments, notes)")
214
+ .argument("<id>", "ticket id")
215
+ .option("--limit <n>", "max messages to fetch", "50")
216
+ .option("--message-type <type>", "filter by message type (email, comment, notification)")
217
+ .option("--subtype <id>", "filter by subtype ID")
218
+ .option("--author <id>", "filter by author ID")
219
+ .option("--since <date>", "only messages after this date (YYYY-MM-DD HH:MM:SS)")
220
+ .addHelpText("after", `
221
+ Examples:
222
+ # List all messages for ticket 398
223
+ $ odoo helpdesk tickets messages 398
224
+
225
+ # List only emails from customer
226
+ $ odoo helpdesk tickets messages 398 --message-type email
227
+
228
+ # List only internal notes
229
+ $ odoo helpdesk tickets messages 398 --subtype 2
230
+
231
+ # List messages from last 7 days
232
+ $ odoo helpdesk tickets messages 398 --since "2026-08-14 00:00:00"
233
+
234
+ # JSON output for scripting
235
+ $ odoo helpdesk tickets messages 398 --json`)
236
+ .action(async (id, opts) => {
237
+ await run((g) => listMessagesCommand(id, opts, g), program);
238
+ });
239
+ const project = program
240
+ .command("project")
241
+ .description("Project app (project.project, project.task)");
242
+ project
243
+ .command("list")
244
+ .description("search and list projects with curated default columns")
245
+ .option("--user <name|id>", "filter by project manager (login, email, or name)")
246
+ .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
247
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
248
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
249
+ .option("--fields <a,b,c>", "columns to display")
250
+ .option("--limit <n>", "max records (default: 50)", "50")
251
+ .option("--offset <n>", "skip the first n records", "0")
252
+ .option("--order <order>", 'sort order, e.g. "name asc"', undefined)
253
+ .action(async (opts) => {
254
+ await run((g) => projectListCommand(opts, g), program);
255
+ });
256
+ project
257
+ .command("show")
258
+ .description("read one project")
259
+ .argument("<id>", "project id")
260
+ .option("--fields <a,b,c>", "fields to display")
261
+ .option("--all-fields", "display every readable field")
262
+ .action(async (id, opts) => {
263
+ await run((g) => projectShowCommand(id, opts, g), program);
264
+ });
265
+ const tasks = project.command("tasks").description("project tasks");
266
+ tasks
267
+ .command("create")
268
+ .description("create a task in a project (preview with --dry-run)")
269
+ .requiredOption("--name <name>", "task name")
270
+ .requiredOption("--project <name|id>", "project name or ID")
271
+ .option("--stage <name|id>", "stage name or ID (within project)")
272
+ .option("--description <text>", "task description (HTML allowed)")
273
+ .option("--description-file <path>", "read description from file ('-' for stdin)")
274
+ .option("--tag <name|id>", "tag name or ID; repeatable, creates if missing", collect, [])
275
+ .option("--priority <level>", "priority: 0 (normal), 1 (high)")
276
+ .addHelpText("after", `
277
+ Examples:
278
+ # Create a simple task
279
+ $ odoo project tasks create --name "Fix bug" --project "Product Backlog"
280
+
281
+ # Create task in specific stage with tags
282
+ $ odoo project tasks create \\
283
+ --name "Bug: API timeout" \\
284
+ --project 58 \\
285
+ --stage "New / Ideas" \\
286
+ --tag Bug \\
287
+ --tag API \\
288
+ --description "Customer reports API timeout..."
289
+
290
+ # Create from file
291
+ $ odoo project tasks create \\
292
+ --name "Bug report from ticket #409" \\
293
+ --project "Product Backlog" \\
294
+ --stage "New / Ideas" \\
295
+ --tag Bug \\
296
+ --description-file bug-report.md
297
+
298
+ # Dry run
299
+ $ odoo project tasks create --name "Test" --project 58 --dry-run`)
300
+ .action(async (opts) => {
301
+ await run((g) => createTaskCommand(opts, g), program);
302
+ });
184
303
  return program;
185
304
  }
186
305
  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,191 @@
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
+ 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");
10
+ }
11
+ // Read body from file if specified
12
+ let body = opts.body;
13
+ if (opts.bodyFile) {
14
+ const fs = await import("node:fs/promises");
15
+ try {
16
+ body = await fs.readFile(opts.bodyFile, "utf-8");
17
+ }
18
+ catch (error) {
19
+ throw usageError(`failed to read body file: ${opts.bodyFile}`, error instanceof Error ? error.message : String(error));
20
+ }
21
+ }
22
+ if (!body || body.trim().length === 0) {
23
+ throw usageError("note body cannot be empty", "provide --body or --body-file");
24
+ }
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");
28
+ }
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");
36
+ }
37
+ // Create internal Log Note
38
+ // CRITICAL: These parameters ensure the note is INTERNAL ONLY
39
+ // - subtype_id=2: "Note" subtype (internal, not sent to customer)
40
+ // - message_type="comment": Internal comment (not email)
41
+ // - author_id: Use the authenticated user's partner_id
42
+ const noteParams = {
43
+ model: "helpdesk.ticket",
44
+ res_id: id,
45
+ body: body,
46
+ message_type: "comment",
47
+ subtype_id: 2, // Note subtype = internal only, NEVER sends email
48
+ };
49
+ if (globals.verbose) {
50
+ logVerbose(`Creating internal note for ticket #${id} (${tickets[0].name})`, globals.verbose);
51
+ logVerbose(`Note parameters: ${JSON.stringify(noteParams, null, 2)}`, globals.verbose);
52
+ }
53
+ if (globals.dryRun) {
54
+ const result = {
55
+ dry_run: true,
56
+ ticket_id: id,
57
+ ticket_name: tickets[0].name,
58
+ note_type: "internal_log_note",
59
+ will_send_email: false,
60
+ body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
61
+ body_length: body.length,
62
+ };
63
+ if (globals.json) {
64
+ printJson(result);
65
+ }
66
+ else {
67
+ console.log(`[DRY RUN] Would create internal Log Note:`);
68
+ console.log(` Ticket: #${id} — ${tickets[0].name}`);
69
+ console.log(` Type: Internal note (never sent to customer)`);
70
+ console.log(` Body length: ${body.length} characters`);
71
+ console.log(` Body preview: ${result.body_preview}`);
72
+ }
73
+ return;
74
+ }
75
+ const noteId = (await client.executeKw("mail.message", "create", [
76
+ [noteParams],
77
+ ]));
78
+ const result = {
79
+ note_id: noteId,
80
+ ticket_id: id,
81
+ ticket_name: tickets[0].name,
82
+ note_type: "internal_log_note",
83
+ email_sent: false,
84
+ };
85
+ if (globals.json) {
86
+ printJson(result);
87
+ }
88
+ else {
89
+ console.log(`✓ Internal Log Note created (ID: ${noteId})`);
90
+ console.log(` Ticket: #${id} — ${tickets[0].name}`);
91
+ console.log(` Type: Internal note (not sent to customer)`);
92
+ }
93
+ }
94
+ /**
95
+ * List messages for a helpdesk ticket.
96
+ *
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.
100
+ */
101
+ export async function listMessagesCommand(ticketId, opts, globals) {
102
+ const id = Number.parseInt(ticketId, 10);
103
+ if (Number.isNaN(id) || id <= 0) {
104
+ throw usageError(`invalid ticket id: ${ticketId}`, "provide a positive integer");
105
+ }
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");
117
+ }
118
+ // Build domain for messages
119
+ const domain = [
120
+ ["model", "=", "helpdesk.ticket"],
121
+ ["res_id", "=", id],
122
+ ];
123
+ // Add optional filters
124
+ if (opts.messageType) {
125
+ domain.push(["message_type", "=", opts.messageType]);
126
+ }
127
+ if (opts.subtype) {
128
+ const subtypeId = Number.parseInt(opts.subtype, 10);
129
+ if (!Number.isNaN(subtypeId)) {
130
+ domain.push(["subtype_id", "=", subtypeId]);
131
+ }
132
+ }
133
+ if (opts.author) {
134
+ const authorId = Number.parseInt(opts.author, 10);
135
+ if (!Number.isNaN(authorId)) {
136
+ domain.push(["author_id", "=", authorId]);
137
+ }
138
+ }
139
+ if (opts.since) {
140
+ domain.push(["date", ">=", opts.since]);
141
+ }
142
+ const limit = opts.limit ? Number.parseInt(opts.limit, 10) : 50;
143
+ if (globals.verbose) {
144
+ logVerbose(`Fetching messages for ticket #${id}`, globals.verbose);
145
+ logVerbose(`Domain: ${JSON.stringify(domain)}`, globals.verbose);
146
+ }
147
+ const messages = (await client.executeKw("mail.message", "search_read", [domain], {
148
+ fields: [
149
+ "id",
150
+ "date",
151
+ "author_id",
152
+ "body",
153
+ "message_type",
154
+ "subtype_id",
155
+ "email_from",
156
+ "partner_ids",
157
+ ],
158
+ limit,
159
+ order: "date asc",
160
+ }));
161
+ if (globals.json) {
162
+ printJson({
163
+ ticket_id: id,
164
+ ticket_name: tickets[0].name,
165
+ message_count: messages.length,
166
+ messages,
167
+ });
168
+ }
169
+ else {
170
+ console.log(`Messages for ticket #${id}: ${tickets[0].name}`);
171
+ console.log(`Found ${messages.length} messages\n`);
172
+ const rows = messages.map((msg) => {
173
+ const author = msg.author_id ? msg.author_id[1] : msg.email_from || "System";
174
+ const subtype = msg.subtype_id ? msg.subtype_id[1] : "";
175
+ const bodyPreview = msg.body
176
+ .replace(/<[^>]*>/g, "") // Strip HTML
177
+ .replace(/\s+/g, " ")
178
+ .trim()
179
+ .substring(0, 60);
180
+ return [
181
+ msg.id.toString(),
182
+ msg.date,
183
+ msg.message_type,
184
+ subtype,
185
+ author,
186
+ bodyPreview + (msg.body.length > 60 ? "..." : ""),
187
+ ];
188
+ });
189
+ console.log(formatTable(["ID", "Date", "Type", "Subtype", "Author", "Preview"], rows, !globals.noColor));
190
+ }
191
+ }
@@ -0,0 +1,190 @@
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
+ import { listCommand, showCommand, } from "../resource.js";
6
+ /**
7
+ * Create a task in an Odoo project.
8
+ *
9
+ * Useful for converting support tickets into backlog items.
10
+ */
11
+ export const projectProjects = {
12
+ model: "project.project",
13
+ showCommand: "odoo project show",
14
+ defaultListFields: ["id", "name", "user_id", "create_date"],
15
+ showExtraFields: ["partner_id", "tag_ids", "active", "write_date"],
16
+ resolutionHints: {
17
+ user_id: "--user",
18
+ tag_ids: "--tag",
19
+ },
20
+ priorityLabels: {},
21
+ pinnedDomain: [],
22
+ references: {
23
+ assignee: {
24
+ model: "res.users",
25
+ label: "project manager",
26
+ field: "user_id",
27
+ searchFields: ["login", "email", "name"],
28
+ },
29
+ tag: { model: "project.tags", label: "tag", field: "tag_ids" },
30
+ },
31
+ };
32
+ export function projectListCommand(opts, globals) {
33
+ // --user maps onto the engine's generic assignee reference (user_id)
34
+ return listCommand(projectProjects, { ...opts, assignee: opts.user }, globals);
35
+ }
36
+ export function projectShowCommand(idArg, opts, globals) {
37
+ return showCommand(projectProjects, idArg, opts, globals);
38
+ }
39
+ export async function createTaskCommand(opts, globals) {
40
+ if (!opts.name || opts.name.trim().length === 0) {
41
+ throw usageError("task name cannot be empty", "provide --name");
42
+ }
43
+ if (!opts.project) {
44
+ throw usageError("project is required", "provide --project <name|id>");
45
+ }
46
+ const config = await resolveConfig(globals);
47
+ if (!config.url || !config.db || !config.username || !config.apiKey) {
48
+ throw usageError("missing configuration", "run 'odoo auth login' or set ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_API_KEY");
49
+ }
50
+ const { parseDurationMs } = await import("../duration.js");
51
+ const timeoutMs = parseDurationMs(globals.timeout);
52
+ const client = new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
53
+ // Resolve project ID
54
+ let projectId;
55
+ const projectIdNum = Number.parseInt(opts.project, 10);
56
+ if (!Number.isNaN(projectIdNum)) {
57
+ projectId = projectIdNum;
58
+ }
59
+ else {
60
+ // Search by name
61
+ const projects = (await client.executeKw("project.project", "search_read", [[["name", "=", opts.project]]], { fields: ["id", "name"], limit: 1 }));
62
+ if (!projects || projects.length === 0) {
63
+ throw notFoundError("project_not_found", `project "${opts.project}" not found`, "check the project name or use project ID");
64
+ }
65
+ projectId = projects[0].id;
66
+ }
67
+ // Resolve stage ID if provided
68
+ let stageId;
69
+ if (opts.stage) {
70
+ const stageIdNum = Number.parseInt(opts.stage, 10);
71
+ if (!Number.isNaN(stageIdNum)) {
72
+ stageId = stageIdNum;
73
+ }
74
+ else {
75
+ // Search by name within project
76
+ const stages = (await client.executeKw("project.task.type", "search_read", [
77
+ [
78
+ ["name", "=", opts.stage],
79
+ ["project_ids", "in", [projectId]],
80
+ ],
81
+ ], { fields: ["id", "name"], limit: 1 }));
82
+ if (!stages || stages.length === 0) {
83
+ throw notFoundError("stage_not_found", `stage "${opts.stage}" not found in project ${projectId}`, "check the stage name or use stage ID");
84
+ }
85
+ stageId = stages[0].id;
86
+ }
87
+ }
88
+ // Resolve tag IDs if provided
89
+ const tagIds = [];
90
+ if (opts.tag && opts.tag.length > 0) {
91
+ for (const tagName of opts.tag) {
92
+ const tagIdNum = Number.parseInt(tagName, 10);
93
+ if (!Number.isNaN(tagIdNum)) {
94
+ tagIds.push(tagIdNum);
95
+ }
96
+ else {
97
+ // Search by name
98
+ const tags = (await client.executeKw("project.tags", "search_read", [[["name", "=", tagName]]], { fields: ["id", "name"], limit: 1 }));
99
+ if (tags && tags.length > 0) {
100
+ tagIds.push(tags[0].id);
101
+ }
102
+ else {
103
+ // Create tag if it doesn't exist
104
+ if (globals.verbose) {
105
+ logVerbose(`Tag "${tagName}" not found, creating it`, globals.verbose);
106
+ }
107
+ const newTagId = (await client.executeKw("project.tags", "create", [
108
+ [{ name: tagName }],
109
+ ]));
110
+ tagIds.push(newTagId);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ // Read description from file if specified
116
+ let description = opts.description || "";
117
+ if (opts.descriptionFile) {
118
+ const fs = await import("node:fs/promises");
119
+ try {
120
+ description = await fs.readFile(opts.descriptionFile, "utf-8");
121
+ }
122
+ catch (error) {
123
+ throw usageError(`failed to read description file: ${opts.descriptionFile}`, error instanceof Error ? error.message : String(error));
124
+ }
125
+ }
126
+ // Build task values
127
+ const taskValues = {
128
+ name: opts.name,
129
+ project_id: projectId,
130
+ description: description,
131
+ };
132
+ if (stageId) {
133
+ taskValues.stage_id = stageId;
134
+ }
135
+ if (tagIds.length > 0) {
136
+ taskValues.tag_ids = [[6, 0, tagIds]]; // Replace all tags
137
+ }
138
+ if (opts.priority) {
139
+ taskValues.priority = opts.priority;
140
+ }
141
+ if (globals.verbose) {
142
+ logVerbose(`Creating task: ${JSON.stringify(taskValues, null, 2)}`, globals.verbose);
143
+ }
144
+ if (globals.dryRun) {
145
+ const result = {
146
+ dry_run: true,
147
+ name: opts.name,
148
+ project_id: projectId,
149
+ stage_id: stageId,
150
+ tag_ids: tagIds,
151
+ description_length: description.length,
152
+ };
153
+ if (globals.json) {
154
+ printJson(result);
155
+ }
156
+ else {
157
+ console.log(`[DRY RUN] Would create task:`);
158
+ console.log(` Name: ${opts.name}`);
159
+ console.log(` Project: ${projectId}`);
160
+ if (stageId)
161
+ console.log(` Stage: ${stageId}`);
162
+ if (tagIds.length > 0)
163
+ console.log(` Tags: ${tagIds.join(", ")}`);
164
+ console.log(` Description: ${description.length} characters`);
165
+ }
166
+ return;
167
+ }
168
+ const taskId = (await client.executeKw("project.task", "create", [
169
+ [taskValues],
170
+ ]));
171
+ const result = {
172
+ task_id: taskId,
173
+ name: opts.name,
174
+ project_id: projectId,
175
+ stage_id: stageId,
176
+ tag_ids: tagIds,
177
+ };
178
+ if (globals.json) {
179
+ printJson(result);
180
+ }
181
+ else {
182
+ console.log(`✓ Task created (ID: ${taskId})`);
183
+ console.log(` Name: ${opts.name}`);
184
+ console.log(` Project: ${projectId}`);
185
+ if (stageId)
186
+ console.log(` Stage: ${stageId}`);
187
+ if (tagIds.length > 0)
188
+ console.log(` Tags: ${tagIds.join(", ")}`);
189
+ }
190
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"