@bergetai/odoo-cli 0.1.3 → 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
@@ -28,6 +28,7 @@ odoo version
28
28
  odoo crm leads list --open --limit 20
29
29
  odoo crm leads show 42
30
30
  odoo helpdesk tickets list --team Support --json
31
+ odoo project list --json
31
32
 
32
33
  # Mutations (support --dry-run)
33
34
  odoo crm leads create --name "New lead" --email info@example.com
@@ -47,6 +48,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
47
48
 
48
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
49
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)
50
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`
51
53
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
52
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
package/dist/cli.js CHANGED
@@ -3,7 +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 } from "./commands/notes.js";
6
+ import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
7
+ import { createTaskCommand, projectListCommand, projectShowCommand, } from "./commands/project.js";
7
8
  import { versionCommand } from "./commands/version.js";
8
9
  import { CliError } from "./errors.js";
9
10
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
@@ -79,7 +80,9 @@ Examples:
79
80
  await run(authStatusCommand, program);
80
81
  });
81
82
  const crm = program.command("crm").description("CRM app (crm.lead)");
82
- const leads = crm.command("records").description("leads and opportunities (crm.lead)");
83
+ const leads = crm
84
+ .command("records")
85
+ .description("leads and opportunities (crm.lead)");
83
86
  leads
84
87
  .command("list")
85
88
  .description("search and list leads/opportunities with curated default columns")
@@ -205,6 +208,98 @@ Examples:
205
208
  .action(async (id, opts) => {
206
209
  await run((g) => addNoteCommand(id, opts, g), program);
207
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
+ });
208
303
  return program;
209
304
  }
210
305
  async function run(fn, program) {
@@ -2,6 +2,7 @@ import { OdooClient } from "../client.js";
2
2
  import { resolveConfig } from "../config.js";
3
3
  import { notFoundError, usageError } from "../errors.js";
4
4
  import { printJson, verbose as logVerbose } from "../output.js";
5
+ import { formatTable } from "../table.js";
5
6
  export async function addNoteCommand(ticketId, opts, globals) {
6
7
  const id = Number.parseInt(ticketId, 10);
7
8
  if (Number.isNaN(id) || id <= 0) {
@@ -90,3 +91,101 @@ export async function addNoteCommand(ticketId, opts, globals) {
90
91
  console.log(` Type: Internal note (not sent to customer)`);
91
92
  }
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.3",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"