@bergetai/odoo-cli 0.7.3 → 0.8.1

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
@@ -40,7 +40,9 @@ odoo project tasks list --project "Content Pipeline" --open
40
40
  odoo project tasks show 850
41
41
  odoo documents tree --count
42
42
  odoo documents records list --folder "Product Backlog" --limit 20
43
+ odoo documents records list --folder "Projects > Product Backlog" # nested path
43
44
  odoo documents records show 25
45
+ odoo documents records download 25 --out ./spec.pdf
44
46
 
45
47
  # Mutations (support --dry-run)
46
48
  odoo crm leads create --name "New lead" --email info@example.com
@@ -78,7 +80,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
78
80
  - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
79
81
  - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
80
82
  - 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)
81
- - Documents surface (`documents`) — `tree` (hierarchical folder listing with `--count`/`--depth`/`--folder`), `records list/show` (folders excluded by default, `--type folder|binary|url|all`, `--folder`/`--owner`/`--partner`/`--tag`), `folders list`, `records create` (folder via `--folder-type folder`, link via `--link <url>`), `records update` (move via `--folder`), `records delete`, and `upload` (local file → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
83
+ - Documents surface (`documents`) — `tree` (hierarchical folder listing with `--count`/`--depth`/`--folder`), `records list/show` (folders excluded by default, `--type folder|binary|url|all`, `--folder`/`--owner`/`--partner`/`--tag`), `folders list`, `records create` (folder via `--folder-type folder`, link via `--link <url>`), `records update` (move via `--folder`), `records delete`, `upload` (local file → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes), and `records download <id>` (binary document → local file, `--out <path>`/`--force`, `--out -` streams to stdout). Everywhere a `--folder` is accepted, folders resolve by name, numeric id, or nested `'Parent > Child'` path (segments resolved within their parent, which disambiguates repeated folder names). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
82
84
  - 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`
83
85
  - `--dry-run` for all mutations — full validation, resolution, and before/after diffs without mutating
84
86
  - 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. Optional `--author <email|name|id>` attributes the note to the partner who actually sent the logged message (resolved via `res.partner`; ambiguous matches list candidates and never guess)
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { ticketsCreateCommand, ticketsDeleteCommand, ticketsListCommand, tickets
7
7
  import { addNoteCommand, deleteMessageCommand, listMessagesCommand, updateMessageCommand, } from "./commands/notes.js";
8
8
  import { activitiesListCommand, activityDoneCommand, activityUpdateCommand, scheduleCommand, } from "./commands/activities.js";
9
9
  import { createTaskCommand, projectListCommand, projectShowCommand, tasksListCommand, tasksShowCommand, } from "./commands/project.js";
10
- import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, } from "./commands/documents.js";
10
+ import { documentsDocuments, documentsListCommand, foldersListCommand, documentsShowCommand, documentsCreateCommand, documentsUpdateCommand, documentsDeleteCommand, treeCommand, uploadCommand, downloadCommand, } from "./commands/documents.js";
11
11
  import { versionCommand } from "./commands/version.js";
12
12
  import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
13
13
  import { CliError } from "./errors.js";
@@ -576,7 +576,7 @@ Examples:
576
576
  .command("list")
577
577
  .description("search and list documents (folders excluded by default)")
578
578
  .option("--type <type>", "record type: document (default), folder, or all")
579
- .option("--folder <name|id>", "only records directly in this folder (resolved by name)")
579
+ .option("--folder <name|id|path>", "only records directly in this folder (name, id, or 'Parent > Child' path)")
580
580
  .option("--owner <name|id>", "filter by owner (login, email, or name)")
581
581
  .option("--partner <name|id>", "filter by linked contact")
582
582
  .option("--tag <name|id>", "filter by tag; repeatable, AND-ed", collect, [])
@@ -598,6 +598,15 @@ Examples:
598
598
  .action(async (id, opts) => {
599
599
  await run((g) => documentsShowCommand(id, opts, g), program);
600
600
  });
601
+ records
602
+ .command("download")
603
+ .description("download a document's file content to disk")
604
+ .argument("<id>", "record id")
605
+ .option("--out <path>", "output path (default: document name in cwd; '-' for stdout)")
606
+ .option("--force", "overwrite an existing output file")
607
+ .action(async (id, opts) => {
608
+ await run((g) => downloadCommand(id, opts, g), program);
609
+ });
601
610
  addMutationFlags(records
602
611
  .command("create")
603
612
  .description("create a folder or URL link (preview with --dry-run; upload files via 'documents upload')"), documentsDocuments).action(async (opts) => {
@@ -631,7 +640,7 @@ Examples:
631
640
  documents
632
641
  .command("tree")
633
642
  .description("print the folder tree with document counts")
634
- .option("--folder <name|id>", "start from this folder (default: roots)")
643
+ .option("--folder <name|id|path>", "start from this folder (default: roots; name, id, or 'Parent > Child' path)")
635
644
  .option("--depth <n>", "max depth", "10")
636
645
  .option("--count", "show the number of documents per folder")
637
646
  .action(async (opts) => {
@@ -642,7 +651,7 @@ Examples:
642
651
  .description("upload a local file as a document (preview with --dry-run)")
643
652
  .requiredOption("--file <path>", "local file path ('-' for stdin)")
644
653
  .option("--name <name>", "document name (default: file basename)")
645
- .option("--folder <name|id>", "target folder (resolved by name)")
654
+ .option("--folder <name|id|path>", "target folder (name, id, or 'Parent > Child' path)")
646
655
  .option("--owner <name|id>", "owner (login, email, or name)")
647
656
  .option("--partner <name|id>", "linked contact")
648
657
  .option("--tag <name|id>", "tag; repeatable, AND-ed", collect, [])
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, extname, resolve } from "node:path";
4
- import { usageError } from "../errors.js";
4
+ import { notFoundError, usageError } from "../errors.js";
5
5
  import { ModelFields, resolveReference } from "../model.js";
6
6
  import { createCommand, deleteCommand, updateCommand, } from "../mutation.js";
7
7
  import { colorEnabled, printJson } from "../output.js";
@@ -19,10 +19,10 @@ const documentsMutation = {
19
19
  requiredOnCreate: true,
20
20
  },
21
21
  {
22
- flags: "--folder <name|id>",
22
+ flags: "--folder <name|id|path>",
23
23
  option: "folder",
24
24
  field: "folder_id",
25
- help: "Parent folder",
25
+ help: "Parent folder (name, id, or 'Parent > Child' path)",
26
26
  reference: { model: "documents.document", label: "folder" },
27
27
  },
28
28
  {
@@ -391,12 +391,88 @@ export async function uploadCommand(opts, globals) {
391
391
  const line = `Uploaded documents.document ${id} (${name})`;
392
392
  process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
393
393
  }
394
- /** Resolve a folder by name or id (must be type=folder). */
394
+ export async function downloadCommand(idArg, opts, globals) {
395
+ const client = await authenticatedClient(globals);
396
+ await ModelFields.load(client, "documents.document"); // module-installed check
397
+ if (!/^\d+$/.test(idArg)) {
398
+ throw usageError(`invalid document id ${JSON.stringify(idArg)}`, "expected a numeric id (see 'documents records list')");
399
+ }
400
+ const id = Number(idArg);
401
+ const rows = (await client.executeKw("documents.document", "read", [
402
+ [id],
403
+ ["name", "type", "datas", "mimetype"],
404
+ ]));
405
+ if (rows.length === 0) {
406
+ throw notFoundError("reference_not_found", `no document with id ${id}`);
407
+ }
408
+ const rec = rows[0];
409
+ if (rec.type === "folder") {
410
+ throw usageError(`document ${id} "${rec.name}" is a folder`, "download expects a binary document (folders excluded by default in 'records list')");
411
+ }
412
+ if (rec.type !== "binary" || rec.datas === false || rec.datas === "") {
413
+ throw usageError(`document ${id} "${rec.name}" has no file content (type: ${rec.type})`);
414
+ }
415
+ const buffer = Buffer.from(rec.datas, "base64");
416
+ // '--out -' streams raw bytes to stdout (for piping).
417
+ if (opts.out === "-") {
418
+ process.stdout.write(buffer);
419
+ return;
420
+ }
421
+ const outPath = resolve(opts.out ?? rec.name);
422
+ try {
423
+ await stat(outPath);
424
+ if (!opts.force) {
425
+ throw usageError(`file already exists: ${outPath}`, "pass --force to overwrite");
426
+ }
427
+ }
428
+ catch (err) {
429
+ if (err.code !== "ENOENT")
430
+ throw err;
431
+ }
432
+ await writeFile(outPath, buffer);
433
+ if (globals.json) {
434
+ printJson({
435
+ model: "documents.document",
436
+ action: "download",
437
+ id,
438
+ name: rec.name,
439
+ path: outPath,
440
+ file_size: buffer.length,
441
+ mimetype: rec.mimetype,
442
+ });
443
+ return;
444
+ }
445
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
446
+ const line = `Downloaded documents.document ${id} → ${outPath} (${buffer.length} bytes)`;
447
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
448
+ }
449
+ /**
450
+ * Resolve a folder by name, id, or "Parent > Child > ..." path
451
+ * (must be type=folder). Path segments are resolved within their parent,
452
+ * which disambiguates repeated folder names (e.g. per-customer subfolders).
453
+ */
395
454
  async function resolveFolderId(client, input) {
396
- const resolved = await resolveReference(client, "documents.document", input, {
397
- label: "folder",
398
- domain: FOLDER_DOMAIN,
399
- });
400
- return resolved.id;
455
+ const segments = input
456
+ .split(">")
457
+ .map((s) => s.trim())
458
+ .filter((s) => s.length > 0);
459
+ if (segments.length <= 1) {
460
+ const resolved = await resolveReference(client, "documents.document", input, {
461
+ label: "folder",
462
+ domain: FOLDER_DOMAIN,
463
+ });
464
+ return resolved.id;
465
+ }
466
+ let parentId = false;
467
+ let id = 0;
468
+ for (const segment of segments) {
469
+ const resolved = await resolveReference(client, "documents.document", segment, {
470
+ label: parentId === false ? "root folder" : `folder under ${parentId}`,
471
+ domain: [...FOLDER_DOMAIN, ["folder_id", "=", parentId]],
472
+ });
473
+ id = resolved.id;
474
+ parentId = id;
475
+ }
476
+ return id;
401
477
  }
402
478
  export { formatTable, humanCell };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"