@bergetai/odoo-cli 0.1.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.
@@ -0,0 +1,211 @@
1
+ import { OdooClient } from "./client.js";
2
+ import { missingValueHint, missingValues, resolveConfig } from "./config.js";
3
+ import { parseDurationMs } from "./duration.js";
4
+ import { notFoundError, usageError } from "./errors.js";
5
+ import { buildFilterClauses, parseDomain } from "./filter.js";
6
+ import { ModelFields, resolveReference, stageScopeDomain, } from "./model.js";
7
+ import { colorEnabled, printJson, verbose as logVerbose } from "./output.js";
8
+ import { formatKeyValues, formatTable, humanCell } from "./table.js";
9
+ export const PRIORITY_VALUES = {
10
+ low: "0",
11
+ medium: "1",
12
+ high: "2",
13
+ urgent: "3",
14
+ };
15
+ /** Parse a --priority flag value into its raw Odoo selection value (§4.5). */
16
+ export function parsePriority(raw, flag = "--priority") {
17
+ const v = PRIORITY_VALUES[raw];
18
+ if (v === undefined) {
19
+ throw usageError(`invalid ${flag} ${JSON.stringify(raw)}`, "expected one of: low, medium, high, urgent");
20
+ }
21
+ return v;
22
+ }
23
+ export async function listCommand(config, opts, globals) {
24
+ const client = await authenticatedClient(globals);
25
+ if (opts.domain && opts.filter && opts.filter.length > 0) {
26
+ throw usageError("--domain and --filter are mutually exclusive", "pick one filtering form (SPEC §4.6)");
27
+ }
28
+ const booleanish = opts;
29
+ if (booleanish.open && booleanish.closed) {
30
+ throw usageError("--open and --closed are mutually exclusive");
31
+ }
32
+ const limit = parseCount(opts.limit, "--limit");
33
+ const offset = parseCount(opts.offset, "--offset");
34
+ // fields_get first: doubles as the module-installed check (§1), so a
35
+ // missing model reports "module not installed" before any resolution.
36
+ const fieldsMeta = await ModelFields.load(client, config.model);
37
+ // Reference resolution happens before reading the collection itself, so
38
+ // an unresolvable name fails fast (§4.3). --team resolves before --stage
39
+ // because stage resolution scopes to the team when both are given (§4.3).
40
+ const domain = [...config.pinnedDomain];
41
+ const refs = config.references;
42
+ let teamId;
43
+ if (opts.team && refs.team) {
44
+ const team = await resolveReference(client, refs.team.model, opts.team, {
45
+ label: refs.team.label,
46
+ });
47
+ teamId = team.id;
48
+ domain.push([refs.team.field, "=", team.id]);
49
+ logVerbose(`resolved --team "${opts.team}" -> [${team.id}, "${team.name}"]`, globals.verbose);
50
+ }
51
+ if (opts.stage && refs.stage) {
52
+ const stageDomain = teamId !== undefined && refs.team
53
+ ? await stageScopeDomain(client, refs.stage.model, refs.stage.scope, teamId)
54
+ : [];
55
+ const stage = await resolveReference(client, refs.stage.model, opts.stage, {
56
+ label: refs.stage.label,
57
+ domain: stageDomain,
58
+ });
59
+ domain.push([refs.stage.field, "=", stage.id]);
60
+ logVerbose(`resolved --stage "${opts.stage}" -> [${stage.id}, "${stage.name}"]`, globals.verbose);
61
+ }
62
+ if (opts.assignee && refs.assignee) {
63
+ const user = await resolveReference(client, refs.assignee.model, opts.assignee, {
64
+ label: refs.assignee.label,
65
+ searchFields: refs.assignee.searchFields ?? ["login", "email", "name"],
66
+ });
67
+ domain.push([refs.assignee.field, "=", user.id]);
68
+ logVerbose(`resolved --assignee "${opts.assignee}" -> [${user.id}, "${user.name}"]`, globals.verbose);
69
+ }
70
+ for (const tag of opts.tag ?? []) {
71
+ if (!refs.tag)
72
+ break;
73
+ // Repeated --tag filters AND: the record must carry all given tags.
74
+ const resolved = await resolveReference(client, refs.tag.model, tag, {
75
+ label: refs.tag.label,
76
+ });
77
+ domain.push([refs.tag.field, "=", resolved.id]);
78
+ logVerbose(`resolved --tag "${tag}" -> [${resolved.id}, "${resolved.name}"]`, globals.verbose);
79
+ }
80
+ if (opts.priority) {
81
+ domain.push(["priority", "=", parsePriority(opts.priority)]);
82
+ }
83
+ if (config.extraFilters) {
84
+ domain.push(...(await config.extraFilters(opts, client)));
85
+ }
86
+ if (opts.domain) {
87
+ domain.push(...parseDomain(opts.domain));
88
+ }
89
+ else if (opts.filter && opts.filter.length > 0) {
90
+ domain.push(...buildFilterClauses(opts.filter, fieldsMeta, config.resolutionHints));
91
+ }
92
+ const fields = opts.fields
93
+ ? parseFieldList(opts.fields)
94
+ : config.defaultListFields;
95
+ validateFields(fields, fieldsMeta, config);
96
+ const order = opts.order ?? "id asc";
97
+ const [records, count] = await Promise.all([
98
+ client.executeKw(config.model, "search_read", [domain], {
99
+ fields,
100
+ limit,
101
+ offset,
102
+ order,
103
+ }),
104
+ client.executeKw(config.model, "search_count", [domain]),
105
+ ]);
106
+ if (globals.json) {
107
+ printJson({ model: config.model, records, count, limit, offset });
108
+ return;
109
+ }
110
+ const headers = fields.map((f) => fieldsMeta.get(f)?.string ?? f);
111
+ const rows = records.map((rec) => fields.map((f) => humanCellWithLabels(config, f, rec[f])));
112
+ process.stdout.write(formatTable(headers, rows, colorEnabled(globals.noColor)) + "\n");
113
+ }
114
+ export async function showCommand(config, idArg, opts, globals) {
115
+ const client = await authenticatedClient(globals);
116
+ const id = parseId(idArg);
117
+ const fieldsMeta = await ModelFields.load(client, config.model);
118
+ let fields;
119
+ if (opts.allFields) {
120
+ fields = undefined; // no fields kwarg => everything readable
121
+ }
122
+ else if (opts.fields) {
123
+ fields = parseFieldList(opts.fields);
124
+ validateFields(fields, fieldsMeta, config);
125
+ }
126
+ else {
127
+ // Curated view: intersect with fields_get so edition/version-dependent
128
+ // fields (e.g. helpdesk.ticket.ticket_type_id) degrade gracefully
129
+ // instead of faulting the read (SPEC §6.3 defers field verification).
130
+ const curated = [
131
+ ...new Set([...config.defaultListFields, ...config.showExtraFields]),
132
+ ];
133
+ fields = curated.filter((f) => f === "id" || fieldsMeta.get(f));
134
+ }
135
+ const rows = (await client.executeKw(config.model, "read", [[id]], {
136
+ ...(fields ? { fields } : {}),
137
+ }));
138
+ if (rows.length === 0) {
139
+ throw notFoundError("record_not_found", `no ${config.model} record with id ${id}`);
140
+ }
141
+ const record = rows[0];
142
+ if (globals.json) {
143
+ printJson({ model: config.model, record });
144
+ return;
145
+ }
146
+ const keys = fields ?? Object.keys(record);
147
+ const pairs = keys.map((k) => [
148
+ fieldsMeta.get(k)?.string ?? k,
149
+ humanCellWithLabels(config, k, record[k]),
150
+ ]);
151
+ process.stdout.write(formatKeyValues(pairs, colorEnabled(globals.noColor)) + "\n");
152
+ }
153
+ export function parseId(raw) {
154
+ if (!/^\d+$/.test(raw)) {
155
+ throw usageError(`invalid record id ${JSON.stringify(raw)}`, "ids are positive integers");
156
+ }
157
+ return Number(raw);
158
+ }
159
+ function parseCount(raw, flag) {
160
+ if (!/^\d+$/.test(raw)) {
161
+ throw usageError(`invalid ${flag} value ${JSON.stringify(raw)}`, "expected a non-negative integer");
162
+ }
163
+ return Number(raw);
164
+ }
165
+ function parseFieldList(raw) {
166
+ const fields = raw
167
+ .split(",")
168
+ .map((f) => f.trim())
169
+ .filter((f) => f !== "");
170
+ if (fields.length === 0) {
171
+ throw usageError("--fields requires at least one field name");
172
+ }
173
+ return fields;
174
+ }
175
+ function validateFields(fields, meta, config) {
176
+ for (const field of fields) {
177
+ if (field === "id")
178
+ continue;
179
+ if (!meta.get(field)) {
180
+ throw usageError(`unknown field ${JSON.stringify(field)} on ${config.model}`, `run \`${config.showCommand} <id> --all-fields\` to see available fields`);
181
+ }
182
+ }
183
+ }
184
+ function humanCellWithLabels(config, field, value) {
185
+ if (field === "priority" && typeof value === "string") {
186
+ return config.priorityLabels[value] ?? value;
187
+ }
188
+ return humanCell(value);
189
+ }
190
+ export async function authenticatedClient(globals) {
191
+ const resolved = await resolveConfig({
192
+ url: globals.url,
193
+ db: globals.db,
194
+ username: globals.username,
195
+ apiKeyFile: globals.apiKeyFile,
196
+ });
197
+ const missing = missingValues(resolved);
198
+ if (missing.length > 0) {
199
+ const details = missing
200
+ .map((k) => ` ${k}: ${missingValueHint(k)}`)
201
+ .join("\n");
202
+ throw usageError(`missing required configuration: ${missing.join(", ")}`, `\n${details}`);
203
+ }
204
+ if (!globals.quiet) {
205
+ process.stderr.write(`Connecting to ${resolved.url} ...\n`);
206
+ }
207
+ return new OdooClient(resolved.url, resolved.db, resolved.username, resolved.apiKey, {
208
+ timeoutMs: parseDurationMs(globals.timeout),
209
+ verbose: globals.verbose,
210
+ });
211
+ }
package/dist/table.js ADDED
@@ -0,0 +1,44 @@
1
+ /** Terminal color helpers (SPEC §7.2: color sparingly, TTY-gated). */
2
+ export const ansi = {
3
+ bold: (s, on) => (on ? `${s}` : s),
4
+ green: (s, on) => (on ? `${s}` : s),
5
+ red: (s, on) => (on ? `${s}` : s),
6
+ };
7
+ /** Render a human cell value from an Odoo-native value (§7.3 raw forms). */
8
+ export function humanCell(value) {
9
+ if (value === false || value === null || value === undefined)
10
+ return "";
11
+ if (Array.isArray(value)) {
12
+ // many2one [id, "Name"]
13
+ if (value.length === 2 && typeof value[0] === "number")
14
+ return String(value[1]);
15
+ // x2many id lists
16
+ return value.join(",");
17
+ }
18
+ if (typeof value === "object")
19
+ return JSON.stringify(value);
20
+ return String(value);
21
+ }
22
+ /**
23
+ * Aligned table when stdout is a TTY; plain tab-separated rows otherwise so
24
+ * grep/awk behave (SPEC §7.2).
25
+ */
26
+ export function formatTable(headers, rows, color) {
27
+ if (process.stdout.isTTY !== true) {
28
+ return rows.map((r) => r.join("\t")).join("\n");
29
+ }
30
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
31
+ const line = (cells) => cells
32
+ .map((c, i) => c.padEnd(widths[i]))
33
+ .join(" ")
34
+ .trimEnd();
35
+ const head = line(headers.map((h) => ansi.bold(h, color)));
36
+ return [head, ...rows.map(line)].join("\n");
37
+ }
38
+ /** key: value block for single-record views. */
39
+ export function formatKeyValues(pairs, color) {
40
+ const width = Math.max(...pairs.map(([k]) => k.length));
41
+ return pairs
42
+ .map(([k, v]) => `${ansi.bold(k.padEnd(width), color)} ${v}`)
43
+ .join("\n");
44
+ }
package/dist/xmlrpc.js ADDED
@@ -0,0 +1,102 @@
1
+ import { createRequire } from "node:module";
2
+ import { Readable } from "node:stream";
3
+ import { CliError, networkError, usageError } from "./errors.js";
4
+ const require = createRequire(import.meta.url);
5
+ const { serializeMethodCall } = require("xmlrpc/lib/serializer.js");
6
+ const Deserializer = require("xmlrpc/lib/deserializer.js");
7
+ /** Redact anything that looks like key material from verbose output. */
8
+ function redact(text) {
9
+ return text.replace(/<string>([^<]{6})[^<]*<\/string>/g, "<string>$1…REDACTED</string>");
10
+ }
11
+ function parseResponse(body) {
12
+ return new Promise((resolve, reject) => {
13
+ const deserializer = new Deserializer("utf8");
14
+ const stream = Readable.from([body]);
15
+ deserializer.deserializeMethodResponse(stream, (error, result) => {
16
+ if (error) {
17
+ reject(error);
18
+ }
19
+ else {
20
+ resolve(result);
21
+ }
22
+ });
23
+ });
24
+ }
25
+ function asFault(error) {
26
+ if (error instanceof Error &&
27
+ typeof error.faultCode === "number") {
28
+ return {
29
+ faultCode: error.faultCode,
30
+ faultString: error.message,
31
+ };
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * One XML-RPC method call against `url`. Exactly one attempt; any transport
37
+ * or server-side fault surfaces as a CliError (exit 6). Callers that can
38
+ * distinguish fault payloads (e.g. the db-missing traceback) inspect
39
+ * `XmlRpcFaultError.faultString` before it is wrapped — see client.ts.
40
+ */
41
+ export class XmlRpcFaultError extends CliError {
42
+ faultCode;
43
+ faultString;
44
+ constructor(faultCode, faultString) {
45
+ super("server_fault", `the server reported an error (fault ${faultCode})`, 6, "retry with --verbose to see the full server response");
46
+ this.name = "XmlRpcFaultError";
47
+ this.faultCode = faultCode;
48
+ this.faultString = faultString;
49
+ }
50
+ }
51
+ export async function callMethod(options, method, params) {
52
+ let body;
53
+ try {
54
+ body = serializeMethodCall(method, params);
55
+ }
56
+ catch (cause) {
57
+ throw usageError(`could not serialize XML-RPC request for ${method}: ${cause.message}`);
58
+ }
59
+ if (options.verbose) {
60
+ process.stderr.write(`--> POST ${options.url} ${method}\n${redact(body)}\n`);
61
+ }
62
+ let response;
63
+ try {
64
+ response = await fetch(options.url, {
65
+ method: "POST",
66
+ headers: {
67
+ "Content-Type": "text/xml",
68
+ Accept: "text/xml",
69
+ },
70
+ body,
71
+ signal: AbortSignal.timeout(options.timeoutMs),
72
+ });
73
+ }
74
+ catch (cause) {
75
+ const err = cause;
76
+ if (err.name === "TimeoutError" || err.name === "AbortError") {
77
+ throw networkError(`request to ${options.url} timed out after ${options.timeoutMs}ms`, "check network connectivity or increase --timeout");
78
+ }
79
+ throw networkError(`could not reach ${options.url}: ${err.cause ? err.cause.message : err.message}`, "check the URL and your network connection");
80
+ }
81
+ const text = await response.text();
82
+ if (!response.ok) {
83
+ throw networkError(`server ${options.url} responded with HTTP ${response.status} ${response.statusText}`, options.url.startsWith("http://")
84
+ ? "the server may require HTTPS"
85
+ : "check that the URL points to a reachable Odoo server");
86
+ }
87
+ if (options.verbose) {
88
+ process.stderr.write(`<-- HTTP ${response.status}\n${redact(text)}\n`);
89
+ }
90
+ let result;
91
+ try {
92
+ result = await parseResponse(text);
93
+ }
94
+ catch (cause) {
95
+ const fault = asFault(cause);
96
+ if (fault) {
97
+ throw new XmlRpcFaultError(fault.faultCode, fault.faultString);
98
+ }
99
+ throw networkError(`could not parse the response from ${options.url} as XML-RPC: ${cause.message}`, "check that the URL points to an Odoo server (expected /xmlrpc/2/common or /xmlrpc/2/object)");
100
+ }
101
+ return result;
102
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@bergetai/odoo-cli",
3
+ "version": "0.1.0",
4
+ "description": "Agent-first CLI for the Odoo External API",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/berget-ai/odoo-cli.git"
11
+ },
12
+ "bugs": "https://github.com/berget-ai/odoo-cli/issues",
13
+ "homepage": "https://berget.ai",
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "test": "npm run build && vitest run",
21
+ "build": "tsc",
22
+ "check": "tsc --noEmit"
23
+ },
24
+ "keywords": [
25
+ "odoo",
26
+ "cli",
27
+ "erp",
28
+ "agents"
29
+ ],
30
+ "author": "Berget AI",
31
+ "license": "MIT",
32
+ "type": "module",
33
+ "bin": {
34
+ "odoo": "dist/cli.js"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^15.0.0",
38
+ "xmlrpc": "^1.3.2"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.2.0",
42
+ "tsx": "^4.23.12",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^4.1.11"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }