operx-cli 0.1.0__py3-none-any.whl

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.
operx_cli/output.py ADDED
@@ -0,0 +1,102 @@
1
+ """How answers are shown: a table for people, JSON for scripts, and CSV the
2
+ way OperX's own exports write it (backend/utils/spanish_csv.py), so it opens
3
+ right in a Spanish Excel.
4
+
5
+ Results go to stdout and notes about them to stderr, so
6
+ `operx equipos listar --csv > equipos.csv` keeps only the rows.
7
+ """
8
+
9
+ import csv
10
+ import io
11
+ import json
12
+ import re
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ FORMATS = ("tabla", "json", "csv")
19
+ BOM = ""
20
+ # Excel runs a cell that starts with one of these as a formula.
21
+ FORMULA_STARTS = ("=", "+", "-", "@", "\t", "\r")
22
+ # The API sends money and quantities as text: "24.5000".
23
+ DECIMAL_TEXT = re.compile(r"^-?\d+\.\d+$")
24
+ # A table sent to a file or another program isn't cut to a screen's width.
25
+ WIDTH_OFF_SCREEN = 500
26
+
27
+
28
+ def cell_text(value):
29
+ if value is None:
30
+ return ""
31
+ if isinstance(value, bool):
32
+ return "Sí" if value else "No"
33
+ if isinstance(value, list):
34
+ return ", ".join(cell_text(item) for item in value)
35
+ if isinstance(value, dict):
36
+ for key in ("name", "title", "full_name", "label"):
37
+ if key in value:
38
+ return cell_text(value[key])
39
+ return json.dumps(value, ensure_ascii=False)
40
+ return str(value)
41
+
42
+
43
+ def excel_cell(value):
44
+ text = cell_text(value)
45
+ if DECIMAL_TEXT.match(text):
46
+ return text.rstrip("0").rstrip(".").replace(".", ",")
47
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
48
+ return text.replace(".", ",")
49
+ if text.startswith(FORMULA_STARTS):
50
+ return "'" + text
51
+ return text
52
+
53
+
54
+ def csv_text(columns, rows):
55
+ buffer = io.StringIO()
56
+ buffer.write(BOM)
57
+ writer = csv.writer(buffer, delimiter=";", lineterminator="\n")
58
+ writer.writerow([label for _key, label in columns])
59
+ for row in rows:
60
+ writer.writerow([excel_cell(row.get(key)) for key, _label in columns])
61
+ return buffer.getvalue()
62
+
63
+
64
+ def json_text(data):
65
+ return json.dumps(data, ensure_ascii=False, indent=2)
66
+
67
+
68
+ def print_rows(columns, rows, output_format):
69
+ """A list: the chosen columns in a table or CSV, every field in JSON."""
70
+ if output_format == "json":
71
+ typer.echo(json_text(rows))
72
+ return
73
+ if output_format == "csv":
74
+ typer.echo(csv_text(columns, rows), nl=False)
75
+ return
76
+ if not rows:
77
+ typer.echo("No hay resultados.")
78
+ return
79
+ table = Table(show_lines=False)
80
+ for _key, label in columns:
81
+ table.add_column(label)
82
+ for row in rows:
83
+ table.add_row(*[cell_text(row.get(key)) for key, _label in columns])
84
+ console = Console()
85
+ if not console.is_terminal:
86
+ console = Console(width=WIDTH_OFF_SCREEN)
87
+ console.print(table)
88
+
89
+
90
+ def print_record(record, output_format):
91
+ """One record: each field on its line, or the JSON as it came."""
92
+ if output_format != "tabla":
93
+ typer.echo(json_text(record))
94
+ return
95
+ width = max((len(key) for key in record), default=0)
96
+ for key, value in record.items():
97
+ typer.echo(f"{key.ljust(width)} {cell_text(value)}")
98
+
99
+
100
+ def note(message):
101
+ """A line for the person, never mixed into the results."""
102
+ typer.echo(message, err=True)
operx_cli/settings.py ADDED
@@ -0,0 +1,90 @@
1
+ """Where the CLI keeps each profile's address and token.
2
+
3
+ A profile ("principal" unless another is named) is one API and one token:
4
+ production, staging, or a second company. Its address goes in a small JSON
5
+ file in the user's settings folder; its token goes in the system's keychain,
6
+ never in a file. On a server without a keychain, OPERX_TOKEN and
7
+ OPERX_DIRECCION take their place, and they win over what's saved.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import keyring
15
+ import typer
16
+ from keyring.errors import KeyringError, PasswordDeleteError
17
+
18
+ DEFAULT_PROFILE = "principal"
19
+ DEFAULT_ADDRESS = "https://api.operx.pro"
20
+ KEYRING_SERVICE = "operx-cli"
21
+
22
+ NO_KEYCHAIN = (
23
+ "Este equipo no tiene un llavero del sistema donde guardar el token. "
24
+ "Ponlo en la variable de entorno OPERX_TOKEN."
25
+ )
26
+
27
+
28
+ class SettingsError(Exception):
29
+ pass
30
+
31
+
32
+ def settings_file():
33
+ folder = os.environ.get("OPERX_CONFIG_DIR") or typer.get_app_dir("operx")
34
+ return Path(folder) / "config.json"
35
+
36
+
37
+ def _read():
38
+ path = settings_file()
39
+ if not path.exists():
40
+ return {"perfiles": {}}
41
+ return json.loads(path.read_text(encoding="utf-8"))
42
+
43
+
44
+ def _write(content):
45
+ path = settings_file()
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ path.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8")
48
+
49
+
50
+ def profile_name(option):
51
+ """The --perfil option, else OPERX_PERFIL, else "principal"."""
52
+ return option or os.environ.get("OPERX_PERFIL") or DEFAULT_PROFILE
53
+
54
+
55
+ def address_of(profile):
56
+ from_environment = os.environ.get("OPERX_DIRECCION")
57
+ if from_environment:
58
+ return from_environment.rstrip("/")
59
+ saved = _read()["perfiles"].get(profile, {}).get("direccion")
60
+ return saved or DEFAULT_ADDRESS
61
+
62
+
63
+ def save_address(profile, address):
64
+ content = _read()
65
+ content["perfiles"].setdefault(profile, {})["direccion"] = address.rstrip("/")
66
+ _write(content)
67
+
68
+
69
+ def token_of(profile):
70
+ from_environment = os.environ.get("OPERX_TOKEN")
71
+ if from_environment:
72
+ return from_environment.strip()
73
+ try:
74
+ return keyring.get_password(KEYRING_SERVICE, profile)
75
+ except KeyringError:
76
+ return None
77
+
78
+
79
+ def save_token(profile, token):
80
+ try:
81
+ keyring.set_password(KEYRING_SERVICE, profile, token)
82
+ except KeyringError as error:
83
+ raise SettingsError(NO_KEYCHAIN) from error
84
+
85
+
86
+ def forget_token(profile):
87
+ try:
88
+ keyring.delete_password(KEYRING_SERVICE, profile)
89
+ except (PasswordDeleteError, KeyringError):
90
+ pass
@@ -0,0 +1,179 @@
1
+ """Reading a company's own Excel or CSV for `operx personas importar`.
2
+
3
+ The API takes rows already mapped to Personas' fields (backend/people/
4
+ import_views.py), so this module finds each column by the names people give
5
+ it ("Nombres", "Fecha de ingreso", "Celular"), whatever their case or
6
+ accents, and writes each value the way the API reads it: dates as
7
+ YYYY-MM-DD (text dates are day first, as written in Latin America), contract
8
+ names as their keys, codes and phone numbers as text.
9
+ """
10
+
11
+ import csv
12
+ import io
13
+ import unicodedata
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from openpyxl import load_workbook
18
+
19
+ from .values import iso_date
20
+
21
+
22
+ class SpreadsheetError(Exception):
23
+ pass
24
+
25
+
26
+ # Each field the import takes, and the column names that mean it (already
27
+ # normalized: lower case, no accents). "sede" is a place's name: the command
28
+ # finds its id (branch_id) in the API.
29
+ PEOPLE_COLUMNS = {
30
+ "first_name": ["nombre", "nombres", "primer nombre"],
31
+ "last_name": ["apellido", "apellidos"],
32
+ "employee_code": ["codigo", "codigo de empleado", "codigo empleado", "id empleado"],
33
+ "position": ["cargo", "puesto"],
34
+ "contract_type": ["tipo de contrato", "contrato", "tipo contrato"],
35
+ "contract_end_date": ["fin de contrato", "fecha fin de contrato", "fecha de terminacion", "vence contrato"],
36
+ "hire_date": ["fecha de ingreso", "ingreso", "fecha ingreso", "fecha de contratacion"],
37
+ "phone_work": ["telefono", "celular", "movil", "telefono de trabajo"],
38
+ "emergency_contact_name": ["contacto de emergencia", "nombre contacto de emergencia", "nombre contacto emergencia"],
39
+ "emergency_contact_phone": ["telefono de emergencia", "telefono contacto de emergencia", "telefono contacto emergencia"],
40
+ "sede": ["sede", "sucursal"],
41
+ "reports_to_id": [],
42
+ "branch_id": [],
43
+ }
44
+ DATE_FIELDS = ("hire_date", "contract_end_date")
45
+ CONTRACT_NAMES = {
46
+ "termino indefinido": "indefinite", "indefinido": "indefinite",
47
+ "termino fijo": "fixed", "fijo": "fixed",
48
+ "obra o labor": "project", "obra labor": "project", "obra": "project",
49
+ "aprendizaje": "apprenticeship",
50
+ "prestacion de servicios": "services", "servicios": "services",
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class PeopleFile:
56
+ rows: list = field(default_factory=list) # each person, as the API takes them
57
+ lines: list = field(default_factory=list) # each row's line in the file
58
+ mapping: dict = field(default_factory=dict) # column → field
59
+ ignored: list = field(default_factory=list) # columns nobody maps
60
+
61
+
62
+ def normalized(text):
63
+ """'Fecha de Ingreso:' → 'fecha de ingreso'."""
64
+ without_accents = "".join(
65
+ char for char in unicodedata.normalize("NFKD", str(text)) if not unicodedata.combining(char)
66
+ )
67
+ kept = "".join(char if char.isalnum() or char.isspace() else " " for char in without_accents)
68
+ return " ".join(kept.split()).casefold()
69
+
70
+
71
+ def _is_empty(value):
72
+ return value is None or (isinstance(value, str) and not value.strip())
73
+
74
+
75
+ def _rows_from_xlsx(path, sheet_name):
76
+ workbook = load_workbook(path, read_only=True, data_only=True)
77
+ if sheet_name and sheet_name not in workbook.sheetnames:
78
+ raise SpreadsheetError(f"El archivo no tiene la hoja «{sheet_name}». Tiene: {', '.join(workbook.sheetnames)}.")
79
+ sheet = workbook[sheet_name] if sheet_name else workbook.worksheets[0]
80
+ return [list(row) for row in sheet.iter_rows(values_only=True)]
81
+
82
+
83
+ def _rows_from_csv(path):
84
+ raw = Path(path).read_bytes()
85
+ try:
86
+ text = raw.decode("utf-8-sig")
87
+ except UnicodeDecodeError:
88
+ # What a Spanish Excel writes when it saves "CSV".
89
+ text = raw.decode("cp1252")
90
+ first_line = text.split("\n", 1)[0]
91
+ delimiter = ";" if first_line.count(";") >= first_line.count(",") else ","
92
+ return [list(row) for row in csv.reader(io.StringIO(text), delimiter=delimiter)]
93
+
94
+
95
+ def read_table(path, sheet_name=None):
96
+ """The header (the first row with anything in it) and every other row
97
+ that isn't empty, each with its line number in the file."""
98
+ path = Path(path)
99
+ if not path.exists():
100
+ raise SpreadsheetError(f"No existe el archivo {path}.")
101
+ if path.suffix.lower() == ".csv":
102
+ raw_rows = _rows_from_csv(path)
103
+ elif path.suffix.lower() in (".xlsx", ".xlsm"):
104
+ raw_rows = _rows_from_xlsx(path, sheet_name)
105
+ else:
106
+ raise SpreadsheetError("El archivo debe ser .xlsx o .csv (en Excel: Guardar como › Libro de Excel).")
107
+
108
+ header = None
109
+ rows = []
110
+ for line, cells in enumerate(raw_rows, start=1):
111
+ if all(_is_empty(cell) for cell in cells):
112
+ continue
113
+ if header is None:
114
+ header = [str(cell).strip() if not _is_empty(cell) else "" for cell in cells]
115
+ continue
116
+ values = {name: cells[index] if index < len(cells) else None for index, name in enumerate(header) if name}
117
+ rows.append((line, values))
118
+ if header is None:
119
+ raise SpreadsheetError("El archivo está vacío.")
120
+ return [name for name in header if name], rows
121
+
122
+
123
+ def _column_mapping(header, overrides):
124
+ known = {}
125
+ for field_name, names in PEOPLE_COLUMNS.items():
126
+ for name in [field_name, *names]:
127
+ known[normalized(name)] = field_name
128
+
129
+ mapping = {}
130
+ for override in overrides or []:
131
+ column, _equals, field_name = override.partition("=")
132
+ column, field_name = column.strip(), field_name.strip()
133
+ if field_name not in PEOPLE_COLUMNS:
134
+ raise SpreadsheetError(
135
+ f"«{field_name}» no es un campo de la importación. Los campos son: {', '.join(PEOPLE_COLUMNS)}."
136
+ )
137
+ if column not in header:
138
+ raise SpreadsheetError(f"La columna «{column}» no está en el archivo.")
139
+ mapping[column] = field_name
140
+
141
+ for column in header:
142
+ field_name = known.get(normalized(column))
143
+ if column not in mapping and field_name and field_name not in mapping.values():
144
+ mapping[column] = field_name
145
+ return mapping
146
+
147
+
148
+ def _plain_text(value):
149
+ """1001.0 → '1001': Excel keeps codes and phone numbers as numbers."""
150
+ if isinstance(value, float) and value.is_integer():
151
+ return str(int(value))
152
+ return str(value).strip()
153
+
154
+
155
+ def _api_value(field_name, value):
156
+ if field_name in DATE_FIELDS:
157
+ return iso_date(value)
158
+ if field_name == "contract_type":
159
+ return CONTRACT_NAMES.get(normalized(value), _plain_text(value))
160
+ return _plain_text(value)
161
+
162
+
163
+ def people_rows(path, overrides=None, sheet_name=None):
164
+ header, rows = read_table(path, sheet_name)
165
+ mapping = _column_mapping(header, overrides)
166
+ if "first_name" not in mapping.values():
167
+ raise SpreadsheetError(
168
+ "No encontré la columna del nombre. Llámala «Nombres» o indícala con --mapa \"Tu columna=first_name\"."
169
+ )
170
+ result = PeopleFile(mapping=mapping, ignored=[column for column in header if column not in mapping])
171
+ for line, values in rows:
172
+ person = {}
173
+ for column, field_name in mapping.items():
174
+ value = values.get(column)
175
+ if not _is_empty(value):
176
+ person[field_name] = _api_value(field_name, value)
177
+ result.rows.append(person)
178
+ result.lines.append(line)
179
+ return result
operx_cli/values.py ADDED
@@ -0,0 +1,59 @@
1
+ """Numbers and dates the way people in Latin America write them, turned into
2
+ what the API reads: "73,4" → "73.4", "03/02/2026" → "2026-02-03" (the day
3
+ comes first)."""
4
+
5
+ from datetime import date, datetime
6
+ from decimal import Decimal, InvalidOperation
7
+
8
+ DATE_FORMATS = ("%d/%m/%Y", "%d-%m-%Y", "%d.%m.%Y", "%Y-%m-%d")
9
+ DATETIME_FORMATS = ("%d/%m/%Y %H:%M", "%d-%m-%Y %H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S")
10
+
11
+
12
+ def decimal_text(text):
13
+ """'1.234,5' and '1,234.5' → '1234.5': the separator that comes last is
14
+ the decimal one."""
15
+ text = str(text).strip()
16
+ if "," in text and "." in text:
17
+ decimal_mark = "," if text.rfind(",") > text.rfind(".") else "."
18
+ thousands_mark = "." if decimal_mark == "," else ","
19
+ text = text.replace(thousands_mark, "").replace(decimal_mark, ".")
20
+ else:
21
+ text = text.replace(",", ".")
22
+ try:
23
+ Decimal(text)
24
+ except InvalidOperation:
25
+ raise ValueError(f"«{text}» no es un número.") from None
26
+ return text
27
+
28
+
29
+ def iso_date(value):
30
+ """A date as YYYY-MM-DD. Text nobody can read stays as it is: the API
31
+ answers what's wrong with it."""
32
+ if isinstance(value, datetime):
33
+ return value.date().isoformat()
34
+ if isinstance(value, date):
35
+ return value.isoformat()
36
+ text = str(value).strip()
37
+ for date_format in DATE_FORMATS:
38
+ try:
39
+ return datetime.strptime(text, date_format).date().isoformat()
40
+ except ValueError:
41
+ continue
42
+ return text
43
+
44
+
45
+ def iso_datetime(text):
46
+ """A moment with this computer's time zone: '03/02/2026 14:05' →
47
+ '2026-02-03T14:05:00-05:00'."""
48
+ text = str(text).strip()
49
+ for datetime_format in DATETIME_FORMATS:
50
+ try:
51
+ moment = datetime.strptime(text, datetime_format)
52
+ except ValueError:
53
+ continue
54
+ return moment.astimezone().isoformat(timespec="seconds")
55
+ raise ValueError(f"«{text}» no es una fecha con hora. Escríbela así: 03/02/2026 14:05.")
56
+
57
+
58
+ def now_iso():
59
+ return datetime.now().astimezone().isoformat(timespec="seconds")
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: operx-cli
3
+ Version: 0.1.0
4
+ Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: typer>=0.27
8
+ Requires-Dist: httpx>=0.28
9
+ Requires-Dist: keyring>=25
10
+ Requires-Dist: openpyxl>=3.1
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=8; extra == "test"
13
+
14
+ # operx: OperX desde la terminal
15
+
16
+ `operx` usa la API de OperX con tu token personal: lo mismo que puedes hacer
17
+ en la app, desde una terminal, un script o una tarea programada. Actúa como
18
+ tú, con tu rol, y solo en tu empresa.
19
+
20
+ ## Instalar
21
+
22
+ Necesitas Python 3.10 o más nuevo.
23
+
24
+ ```sh
25
+ pipx install ./cli # desde esta carpeta del repositorio
26
+ operx --version
27
+ ```
28
+
29
+ ## Entrar
30
+
31
+ 1. En la app, abre el menú de tu cuenta › **Acceso por API** y crea un token.
32
+ Elige *Lectura y escritura* si vas a crear o cambiar datos.
33
+ 2. Guárdalo en tu equipo:
34
+
35
+ ```sh
36
+ operx entrar # pega el token; no se muestra
37
+ operx cuenta # quién eres, tu empresa y tu rol
38
+ ```
39
+
40
+ El token queda en el llavero del sistema (en Windows, el Administrador de
41
+ credenciales), nunca en un archivo. En un servidor sin llavero, usa variables
42
+ de entorno: `OPERX_TOKEN` (el token) y, si no es producción, `OPERX_DIRECCION`.
43
+
44
+ Para otra dirección u otra empresa, usa un perfil:
45
+
46
+ ```sh
47
+ operx --perfil pruebas entrar --direccion https://…
48
+ operx --perfil pruebas ordenes listar
49
+ ```
50
+
51
+ `operx salir` borra el token de este equipo; para que deje de funcionar,
52
+ revócalo en Acceso por API.
53
+
54
+ ## Comandos
55
+
56
+ ```sh
57
+ operx buscar "bomba" # como la barra de búsqueda de la app
58
+
59
+ operx ordenes listar --estado en-progreso --asignada-a yo
60
+ operx ordenes ver 1234
61
+ operx ordenes crear --titulo "Cambio de rodamiento" --equipo BOM-04 --prioridad alta --vence 30/10/2026
62
+ operx ordenes estado 1234 completada
63
+ operx ordenes asignar 1234 "Pedro Pérez"
64
+ operx ordenes comentar 1234 "Llegó el repuesto"
65
+
66
+ operx equipos listar --buscar compresor
67
+ operx equipos ver BOM-04
68
+ operx equipos crear --nombre "Bomba 4" --codigo BOM-04 --lugar "Planta Norte"
69
+ operx clientes listar
70
+ operx clientes crear --nombre "Acme S.A.S." --nit 900123456
71
+ operx clientes crear --nombre "Filtros Ltda" --rol proveedor
72
+ operx lugares listar
73
+
74
+ operx inventario existencias --buscar filtro
75
+ operx inventario entrada --articulo FIL-010 --lugar "Bodega central" --cantidad 10 --costo-unitario 1.500,50
76
+ operx inventario salida --articulo FIL-010 --lugar "Bodega central" --cantidad 2 --motivo Consumo
77
+
78
+ operx lecturas puntos BOM-04
79
+ operx lecturas registrar --punto 12 --valor 73,4
80
+
81
+ operx personas importar empleados.xlsx # revisa el archivo, no guarda
82
+ operx personas importar empleados.xlsx --confirmar # guarda si ninguna fila tiene errores
83
+ ```
84
+
85
+ `operx --help` y `operx ordenes --help` muestran todas las opciones.
86
+
87
+ - Los equipos, lugares, clientes, artículos y personas se nombran por su
88
+ código o su nombre exacto. Si dos se llaman igual, `operx` lo dice y pide
89
+ el id.
90
+ - Los números y las fechas se escriben como en la app: `73,4`, `1.500,50`,
91
+ `30/10/2026`, `22/10/2026 08:00`.
92
+ - Las listas muestran la primera página; `--todas` las trae todas.
93
+ - `--json` da todo lo que responde la API; `--csv` da un CSV que Excel abre
94
+ bien (`operx equipos listar --todas --csv > equipos.csv`).
95
+
96
+ ### Antes de escribir
97
+
98
+ Todo lo que crea o cambia algo pregunta antes. En un script o una tarea
99
+ programada no hay a quién preguntar: agrega `--si`. Cada escritura queda en
100
+ el historial del token (Acceso por API › el token › Actividad) marcada como
101
+ hecha desde la CLI.
102
+
103
+ ### Importar personas
104
+
105
+ La primera columna con el nombre basta. `operx` reconoce las columnas
106
+ Nombres, Apellidos, Código, Cargo, Tipo de contrato, Fecha de ingreso, Fin de
107
+ contrato, Celular, Sede y las de contacto de emergencia, con o sin tildes.
108
+ Una columna con otro nombre se indica con `--mapa "Tu columna=campo"`.
109
+ Nadie recibe acceso a la app al importarlo: las invitaciones se envían en
110
+ Usuarios y acceso.
111
+
112
+ ### Cualquier otra llamada
113
+
114
+ Lo que no tiene comando propio se hace con `operx api`, con las direcciones
115
+ de Acceso por API › Documentación (sin `/api/v1/`):
116
+
117
+ ```sh
118
+ operx api GET pm/schedules/ -q search=compresor
119
+ operx api POST contacts/ -d @contacto.json
120
+ operx api GET work-orders/export/ -o ordenes.csv
121
+ operx api POST work-orders/1234/attachments/ -a file=foto.jpg -d '{"file_type": "photo"}'
122
+ ```
123
+
124
+ En Git Bash, una dirección que empieza por `/` se vuelve una ruta de Windows:
125
+ escríbela sin la barra inicial, como arriba.
126
+
127
+ ## Para desarrollar
128
+
129
+ ```sh
130
+ cd cli
131
+ python -m venv .venv && .venv/Scripts/python -m pip install -e ".[test]"
132
+ .venv/Scripts/python -m pytest
133
+ ```
134
+
135
+ `operx_cli/client.py` es lo único que habla con la red; el servidor MCP lo
136
+ usará igual.
@@ -0,0 +1,23 @@
1
+ operx_cli/__init__.py,sha256=uQFILki-hzkuCkdJzw-PgqIdv0Vagxr-W6YD3nHIhOg,185
2
+ operx_cli/app.py,sha256=fo6BlcQ6LQUGlAxy-e8PSMXNGjwc2rh7CQkDCl1XnwA,2788
3
+ operx_cli/client.py,sha256=eVtKKyMk_Hski3M7py343RQFc0kcYq3D5wsrpaBszn4,5064
4
+ operx_cli/lookup.py,sha256=oU1wh6_UnllhiQNXMYk4Xx6FJ12KAsTRBcjXNM83eiY,1541
5
+ operx_cli/output.py,sha256=LHzqmxWQB9MtTWrxXsFIEnMgsPvLewnZWoyBRRXfxyU,3191
6
+ operx_cli/settings.py,sha256=o-eWeZLINiXcjo2ww9FrXF7LWMmDzF6EzPLG2Pkp7fY,2527
7
+ operx_cli/spreadsheet.py,sha256=KKk1qvJE7QFck1JHK79z-2EpXgelefNoA4MrYD1eBp8,7275
8
+ operx_cli/values.py,sha256=IKGm-xGver_pU179i4Q_VIhaId3lTeU2Rcb2LC0btwE,2099
9
+ operx_cli/commands/__init__.py,sha256=TyBLGCZdfsGPg8l5U_4DKgaGPJxWE5TepeOXMQ7IL4Q,82
10
+ operx_cli/commands/account.py,sha256=jxkl95CqMkmtwA8gH1Nh8hJuSkM3adHiBArnI9uV9Cw,2729
11
+ operx_cli/commands/common.py,sha256=6KP2ffN9_sxsHRk6tddojr5FJNgwPQ6A7UgJ1CwFogQ,3703
12
+ operx_cli/commands/inventario.py,sha256=DmVqEQTQ-CRljGlQm28YK103Ct17SMF7sbuonpCY6WA,4277
13
+ operx_cli/commands/lecturas.py,sha256=DjTk11MPgIGwgY6df6CpuHRhwIYkED1VGeKP8-qSVy8,2440
14
+ operx_cli/commands/ordenes.py,sha256=7JXOVYkcEMmM8luPhZHmQrZk7bthilfZrPlYZmEYcMc,7847
15
+ operx_cli/commands/personas.py,sha256=ZNzx6lve33h4OSimwbHzUFe6KO0a0STRI9UReDo2pi0,4507
16
+ operx_cli/commands/raw.py,sha256=Rt2peqxCqgZ8b7EHo4lepXFPR_g9U8C5cI5hBzLmi4w,4528
17
+ operx_cli/commands/registros.py,sha256=j8YM7eC3DDyNgtKebgDQf7b1vfsOzIir0HEwmIjpf0k,7907
18
+ operx_cli/commands/search.py,sha256=YSugPxWUC7TuNnUbP2ccGmJE0nd4Oiw3SANMLA9p1no,741
19
+ operx_cli-0.1.0.dist-info/METADATA,sha256=ZYDIiPIBfI_M2786UUpqpmP_oWvxXlV5Nj6-Hxh5jxU,4925
20
+ operx_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
21
+ operx_cli-0.1.0.dist-info/entry_points.txt,sha256=eP1Jr75H5WYGYRByAYb_vi-seM_bnyWm046g3LBlqjs,45
22
+ operx_cli-0.1.0.dist-info/top_level.txt,sha256=n-uXOP0Havuey-ATo4rkU-FQxPDqYXhysCIbgDc-7Tg,10
23
+ operx_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ operx = operx_cli.app:main
@@ -0,0 +1 @@
1
+ operx_cli