@bergetai/odoo-cli 0.2.0 → 0.3.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
@@ -29,6 +29,8 @@ odoo crm leads list --open --limit 20
29
29
  odoo crm leads show 42
30
30
  odoo helpdesk tickets list --team Support --json
31
31
  odoo project list --json
32
+ odoo project tasks list --project "Content Pipeline" --open
33
+ odoo project tasks show 850
32
34
 
33
35
  # Mutations (support --dry-run)
34
36
  odoo crm leads create --name "New lead" --email info@example.com
@@ -49,6 +51,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
49
51
  - 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
52
  - 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
53
  - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
54
+ - Read-only Project task surface (`project tasks list/show`) — `--project` (required on list, resolved by name or id), project-scoped `--stage`, `--assignee` (many2many `user_ids`), `--tag`, `--priority` (0/Low, 1/High), `--open`/`--closed` via the task's `is_closed` flag, `--domain`/`--filter`, table/JSON output with `search_count` envelope, module-not-installed error (exit 4)
52
55
  - 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
56
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
54
57
  - 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
@@ -68,6 +71,12 @@ against the live server:
68
71
  - `execute_kw` kwargs must be a separate trailing dict param; packing it into
69
72
  the positional-args array makes the server silently ignore it (this caused
70
73
  `fields_get` to return full/empty structs).
74
+ - `project.task` has no `user_id` field; assignees are the many2many
75
+ `user_ids`. The `priority` selection is two-valued (0/Low, 1/High), not the
76
+ four-level scale used by CRM/Helpdesk. Tasks carry a direct `is_closed`
77
+ boolean, which `--open`/`--closed` filter on (authoritative, unlike stage
78
+ `fold`). Stages (`project.task.type`) link to projects via the many2many
79
+ `project_ids`, so `--stage` resolves within the `--project`.
71
80
 
72
81
  ## Development
73
82
 
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 { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, ticketsShowCommand, ticketsUpdateCommand, helpdeskTickets, } from "./commands/helpdesk.js";
6
6
  import { addNoteCommand, listMessagesCommand, } from "./commands/notes.js";
7
- import { createTaskCommand, projectListCommand, projectShowCommand, } from "./commands/project.js";
7
+ import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
8
8
  import { versionCommand } from "./commands/version.js";
9
9
  import { CliError } from "./errors.js";
10
10
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
@@ -263,6 +263,34 @@ Examples:
263
263
  await run((g) => projectShowCommand(id, opts, g), program);
264
264
  });
265
265
  const tasks = project.command("tasks").description("project tasks");
266
+ tasks
267
+ .command("list")
268
+ .description("search and list tasks in a project with curated columns")
269
+ .requiredOption("--project <name|id>", "project name or ID")
270
+ .option("--stage <name|id>", "filter by stage (resolved within the project)")
271
+ .option("--assignee <name|id>", "filter by assignee (login, email, or name)")
272
+ .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
273
+ .option("--priority <level>", "filter by priority: 0 (low/normal) or 1 (high)")
274
+ .option("--open", "only open tasks (is_closed = false)")
275
+ .option("--closed", "only closed tasks (is_closed = true)")
276
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
277
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
278
+ .option("--fields <a,b,c>", "columns to display")
279
+ .option("--limit <n>", "max records (default: 50)", "50")
280
+ .option("--offset <n>", "skip the first n records", "0")
281
+ .option("--order <order>", 'sort order, e.g. "priority desc"', undefined)
282
+ .action(async (opts) => {
283
+ await run((g) => tasksListCommand(opts, g), program);
284
+ });
285
+ tasks
286
+ .command("show")
287
+ .description("read one task")
288
+ .argument("<id>", "task id")
289
+ .option("--fields <a,b,c>", "fields to display")
290
+ .option("--all-fields", "display every readable field")
291
+ .action(async (id, opts) => {
292
+ await run((g) => tasksShowCommand(id, opts, g), program);
293
+ });
266
294
  tasks
267
295
  .command("create")
268
296
  .description("create a task in a project (preview with --dry-run)")
@@ -1,8 +1,11 @@
1
1
  import { OdooClient } from "../client.js";
2
2
  import { resolveConfig } from "../config.js";
3
3
  import { notFoundError, usageError } from "../errors.js";
4
- import { printJson, verbose as logVerbose } from "../output.js";
5
- import { listCommand, showCommand, } from "../resource.js";
4
+ import { buildFilterClauses, parseDomain } from "../filter.js";
5
+ import { ModelFields, resolveReference } from "../model.js";
6
+ import { colorEnabled, printJson, verbose as logVerbose } from "../output.js";
7
+ import { authenticatedClient, listCommand, parseId, showCommand, } from "../resource.js";
8
+ import { formatKeyValues, formatTable, humanCell } from "../table.js";
6
9
  /**
7
10
  * Create a task in an Odoo project.
8
11
  *
@@ -188,3 +191,219 @@ export async function createTaskCommand(opts, globals) {
188
191
  console.log(` Tags: ${tagIds.join(", ")}`);
189
192
  }
190
193
  }
194
+ /**
195
+ * project.task diverges from the shared list/show engine (resource.ts) in
196
+ * three ways that make a custom command cleaner than engine surgery:
197
+ * - assignee is user_ids (many2many), not user_id (many2one)
198
+ * - priority is a 2-value selection (0/Low, 1/High), not the engine's
199
+ * hardcoded low/medium/high/urgent map
200
+ * - stages scope via project_ids (many2many) on project.task.type, not
201
+ * team_id/team_ids
202
+ * The resolution/ordering mirrors listCommand (resource.ts) and the project
203
+ * resolution mirrors createTaskCommand above.
204
+ */
205
+ const TASK_DEFAULT_LIST_FIELDS = [
206
+ "id",
207
+ "name",
208
+ "stage_id",
209
+ "user_ids",
210
+ "priority",
211
+ "date_deadline",
212
+ "is_closed",
213
+ ];
214
+ const TASK_SHOW_EXTRA_FIELDS = [
215
+ "description",
216
+ "partner_id",
217
+ "tag_ids",
218
+ "parent_id",
219
+ "milestone_id",
220
+ "active",
221
+ "create_date",
222
+ "write_date",
223
+ ];
224
+ const TASK_PRIORITY_LABELS = {
225
+ "0": "Low",
226
+ "1": "High",
227
+ };
228
+ /** Parse a task --priority into its raw Odoo selection value. */
229
+ function parseTaskPriority(raw) {
230
+ const v = raw.toLowerCase();
231
+ if (v === "0" || v === "normal" || v === "low")
232
+ return "0";
233
+ if (v === "1" || v === "high")
234
+ return "1";
235
+ throw usageError(`invalid --priority ${JSON.stringify(raw)}`, "expected one of: 0 (normal/low), 1 (high)");
236
+ }
237
+ function parseCount(raw, flag) {
238
+ if (!/^\d+$/.test(raw)) {
239
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
240
+ }
241
+ return Number(raw);
242
+ }
243
+ function parseFieldList(raw) {
244
+ const fields = raw
245
+ .split(",")
246
+ .map((f) => f.trim())
247
+ .filter((f) => f !== "");
248
+ if (fields.length === 0) {
249
+ throw usageError("--fields requires at least one field name");
250
+ }
251
+ return fields;
252
+ }
253
+ /** Resolve a project by numeric id or exact name match (mirrors create). */
254
+ async function resolveProjectId(client, input) {
255
+ const num = Number.parseInt(input, 10);
256
+ if (!Number.isNaN(num))
257
+ return num;
258
+ const projects = (await client.executeKw("project.project", "search_read", [[["name", "=", input]]], { fields: ["id", "name"], limit: 1 }));
259
+ if (!projects || projects.length === 0) {
260
+ throw notFoundError("project_not_found", `project "${input}" not found`, "check the project name or use project ID");
261
+ }
262
+ return projects[0].id;
263
+ }
264
+ export async function tasksListCommand(opts, globals) {
265
+ const client = await authenticatedClient(globals);
266
+ if (opts.domain && opts.filter && opts.filter.length > 0) {
267
+ throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form");
268
+ }
269
+ if (opts.open && opts.closed) {
270
+ throw usageError("--open and --closed are mutually exclusive");
271
+ }
272
+ const limit = parseCount(opts.limit, "--limit");
273
+ const offset = parseCount(opts.offset, "--offset");
274
+ // fields_get first: doubles as the module-installed check (exit 4).
275
+ const fieldsMeta = await ModelFields.load(client, "project.task");
276
+ // project_ids (many2many) on project.task.type scopes stage resolution.
277
+ const projectId = await resolveProjectId(client, opts.project);
278
+ logVerbose(`resolved --project "${opts.project}" -> ${projectId}`, globals.verbose);
279
+ const domain = [["project_id", "=", projectId]];
280
+ if (opts.stage) {
281
+ const stageIdNum = Number.parseInt(opts.stage, 10);
282
+ let stageId;
283
+ if (!Number.isNaN(stageIdNum)) {
284
+ stageId = stageIdNum;
285
+ }
286
+ else {
287
+ const stages = (await client.executeKw("project.task.type", "search_read", [
288
+ [
289
+ ["name", "=", opts.stage],
290
+ ["project_ids", "in", [projectId]],
291
+ ],
292
+ ], { fields: ["id", "name"], limit: 1 }));
293
+ if (!stages || stages.length === 0) {
294
+ throw notFoundError("stage_not_found", `stage "${opts.stage}" not found in project ${projectId}`, "check the stage name or use stage ID");
295
+ }
296
+ stageId = stages[0].id;
297
+ }
298
+ domain.push(["stage_id", "=", stageId]);
299
+ logVerbose(`resolved --stage "${opts.stage}" -> ${stageId}`, globals.verbose);
300
+ }
301
+ if (opts.assignee) {
302
+ const user = await resolveReference(client, "res.users", opts.assignee, {
303
+ label: "assignee",
304
+ searchFields: ["login", "email", "name"],
305
+ });
306
+ // many2many membership
307
+ domain.push(["user_ids", "in", [user.id]]);
308
+ logVerbose(`resolved --assignee "${opts.assignee}" -> [${user.id}, "${user.name}"]`, globals.verbose);
309
+ }
310
+ for (const tag of opts.tag ?? []) {
311
+ const resolved = await resolveReference(client, "project.tags", tag, {
312
+ label: "tag",
313
+ });
314
+ domain.push(["tag_ids", "=", resolved.id]);
315
+ logVerbose(`resolved --tag "${tag}" -> [${resolved.id}, "${resolved.name}"]`, globals.verbose);
316
+ }
317
+ if (opts.priority) {
318
+ domain.push(["priority", "=", parseTaskPriority(opts.priority)]);
319
+ }
320
+ if (opts.open)
321
+ domain.push(["is_closed", "=", false]);
322
+ if (opts.closed)
323
+ domain.push(["is_closed", "=", true]);
324
+ if (opts.domain) {
325
+ domain.push(...parseDomain(opts.domain));
326
+ }
327
+ else if (opts.filter && opts.filter.length > 0) {
328
+ domain.push(...buildFilterClauses(opts.filter, fieldsMeta, {
329
+ stage_id: "--stage",
330
+ user_ids: "--assignee",
331
+ tag_ids: "--tag",
332
+ }));
333
+ }
334
+ const fields = opts.fields
335
+ ? parseFieldList(opts.fields)
336
+ : TASK_DEFAULT_LIST_FIELDS;
337
+ for (const field of fields) {
338
+ if (field !== "id" && !fieldsMeta.get(field)) {
339
+ throw usageError(`unknown field ${JSON.stringify(field)} on project.task`, `run \`odoo project tasks show <id> --all-fields\` to see available fields`);
340
+ }
341
+ }
342
+ const order = opts.order ?? "id asc";
343
+ const [records, count] = await Promise.all([
344
+ client.executeKw("project.task", "search_read", [domain], {
345
+ fields,
346
+ limit,
347
+ offset,
348
+ order,
349
+ }),
350
+ client.executeKw("project.task", "search_count", [
351
+ domain,
352
+ ]),
353
+ ]);
354
+ if (globals.json) {
355
+ printJson({ model: "project.task", records, count, limit, offset });
356
+ return;
357
+ }
358
+ const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
359
+ const rows = records.map((rec) => fields.map((f) => f === "priority" && typeof rec[f] === "string"
360
+ ? (TASK_PRIORITY_LABELS[rec[f]] ?? String(rec[f]))
361
+ : humanCell(rec[f])));
362
+ const color = colorEnabled(globals.noColor);
363
+ process.stdout.write(formatTable(headers, rows, color) + "\n");
364
+ }
365
+ export async function tasksShowCommand(idArg, opts, globals) {
366
+ const client = await authenticatedClient(globals);
367
+ const id = parseId(idArg);
368
+ const fieldsMeta = await ModelFields.load(client, "project.task");
369
+ let fields;
370
+ if (opts.allFields) {
371
+ fields = undefined; // no fields kwarg => everything readable
372
+ }
373
+ else if (opts.fields) {
374
+ fields = parseFieldList(opts.fields);
375
+ for (const field of fields) {
376
+ if (field !== "id" && !fieldsMeta.get(field)) {
377
+ throw usageError(`unknown field ${JSON.stringify(field)} on project.task`, `run \`odoo project tasks show <id> --all-fields\` to see available fields`);
378
+ }
379
+ }
380
+ }
381
+ else {
382
+ // Curated view: intersect with fields_get so edition-dependent fields
383
+ // degrade gracefully instead of faulting the read.
384
+ const curated = [
385
+ ...new Set([...TASK_DEFAULT_LIST_FIELDS, ...TASK_SHOW_EXTRA_FIELDS]),
386
+ ];
387
+ fields = curated.filter((f) => f === "id" || fieldsMeta.get(f));
388
+ }
389
+ const rows = (await client.executeKw("project.task", "read", [[id]], {
390
+ ...(fields ? { fields } : {}),
391
+ }));
392
+ if (rows.length === 0) {
393
+ throw notFoundError("record_not_found", `no project.task record with id ${id}`);
394
+ }
395
+ const record = rows[0];
396
+ if (globals.json) {
397
+ printJson({ model: "project.task", record });
398
+ return;
399
+ }
400
+ const keys = fields ?? Object.keys(record);
401
+ const pairs = keys.map((k) => [
402
+ fieldsMeta.get(k)?.string ?? k,
403
+ k === "priority" && typeof record[k] === "string"
404
+ ? (TASK_PRIORITY_LABELS[record[k]] ?? String(record[k]))
405
+ : humanCell(record[k]),
406
+ ]);
407
+ const color = colorEnabled(globals.noColor);
408
+ process.stdout.write(formatKeyValues(pairs, color) + "\n");
409
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"