niiko-cli 0.1.0 → 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +89 -31
  3. package/dist/niiko.js +155 -155
  4. package/package.json +18 -5
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vorluno
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation
7
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
8
+ to permit persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions
11
+ of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
14
+ TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
15
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
16
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
17
+ DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,39 +1,97 @@
1
1
  # niiko-cli
2
2
 
3
- La API de acciones de niiko desde la terminal. **Generada** del mismo plan que los SDK, la referencia y el
4
- servidor MCP (plan `9a8b80d285ff`): una acción nueva aparece al regenerar, y este repositorio no se edita a mano.
3
+ The niiko actions API from the terminal. One binary, four commands, and the outcome in the exit code.
4
+
5
+ [![npm](https://img.shields.io/npm/v/niiko-cli?style=flat-square&color=0969da)](https://www.npmjs.com/package/niiko-cli) [![node](https://img.shields.io/badge/node-%3E%3D20-2C6440?style=flat-square)](https://nodejs.org) ![dependencies](https://img.shields.io/badge/dependencies-0-2C6440?style=flat-square) ![license](https://img.shields.io/badge/license-MIT-0969da?style=flat-square) [![generated](https://img.shields.io/badge/generated_from_plan-6c3240a7b22d-555?style=flat-square)](https://developers.niiko.org)
6
+
7
+ **A CLI that knows the actions it was written for is stale the day a new one opens.** This one is generated
8
+ from the same plan as the SDKs, the reference and the MCP server (plan `6c3240a7b22d`): when an action opens,
9
+ `niiko actions` lists it and `niiko run` accepts it — nobody edits this repository by hand.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g niiko-cli
15
+ ```
16
+
17
+ Node 20 or newer. No runtime dependencies — `fetch`, `fs` and `os` ship with Node.
18
+
19
+ ## Use
5
20
 
6
21
  ```bash
7
- npm i -g niiko-cli
8
- niiko login --key nk_… # guarda la llave (0600) en ~/.config/niiko/config.json
9
- niiko actions # las acciones abiertas, con su propósito
22
+ niiko login --key nk_… # saves the key (mode 0600) to ~/.config/niiko/config.json
23
+ niiko actions # what is open today, with its purpose
10
24
  niiko describe miira.lead_create
11
25
  niiko run miira.lead_create --input '{ … }'
12
26
  ```
13
27
 
14
- La salida es siempre JSON. El desenlace va en el **código de salida**: `0` hecho · `2` negada (el motivo, con
15
- nombre, va en la salida) · `3` esperando una firma humana · `1` error de la CLI o del transporte. Así
16
- `niiko run && siguiente` hace lo correcto sin parsear nada.
17
-
18
- La llave también se lee de `NIIKO_API_KEY` (y la URL de `NIIKO_URL`), para scripts que no deban tocar la
19
- configuración de la persona. Cada `run` manda una `Idempotency-Key` (o la que le pases con
20
- `--idempotency-key`): reintentar lo mismo no duplica el efecto.
21
-
22
- ## Las acciones de hoy
23
-
24
- - `miira.lead_create` — Crea un lead nuevo en el CRM del workspace a partir de sus datos de contacto. Si ya existe uno que encaja, no lo duplica: contesta `ambiguous` con los candidatos.
25
- - `crm.call_logged` — Anota en la ficha de un cliente, dicho por su nombre, lo que se habló en una llamada; opcionalmente deja creado el seguimiento con su fecha. No lee nada ni llama a nadie.
26
- - `crm.owner_assigned` — Cambia de quién es un cliente, diciendo el nombre del cliente y el nombre (o correo) del miembro del equipo. Si alguno de los dos es ambiguo, se niega con la lista.
27
- - `crm.stage_moved` — Mueve el negocio abierto de un cliente a otra etapa del pipeline, diciendo el nombre del cliente y el de la etapa. No crea negocios: sin uno abierto se niega, y con varios se niega con la lista.
28
- - `kiipu.invoice_proposed` Deja preparada una factura como BORRADOR para un cliente dicho por su nombre, con sus líneas e impuestos. NO la emite, NO la numera y NO cuenta como deuda: una persona la revisa y la emite en Kiipu. No crea el cliente si no existe.
29
- - `crm.task_created` Crea un recordatorio (tarea con fecha y hora) sobre un cliente dicho por su nombre. No anota una llamada: para eso está crm.call_logged. No crea el cliente si no existe.
30
- - `crm.deal_created` Abre un negocio nuevo en el pipeline para un cliente dicho por su nombre, con título, valor opcional en USD, etapa opcional (por nombre; sin ella, la primera) y responsable opcional. No comprueba si ya tiene otros abiertos: devuelve cuántos quedan para que se vea un duplicado. No lo gana ni lo pierde: eso es crm.stage_moved.
31
- - `crm.note_added` — Guarda una nota en la ficha de un cliente dicho por su nombre: algo que hay que saber la próxima vez, sin llamada ni fecha. Para una llamada está crm.call_logged; para un recordatorio con fecha, crm.task_created.
32
- - `crm.contact_added` Añade una persona (nombre, y opcionalmente correo, teléfono y cargo) a la ficha de un cliente dicho por su nombre. No la hace contacto principal ni crea el cliente. Si ya había alguien con ese correo o teléfono, lo dice en la respuesta pero no lo impide.
33
- - `miira.broadcast_quoted` Presupuesta mandar el MISMO mensaje de WhatsApp a varios clientes dichos por su nombre (hasta 50). NO envía nada: dice, por cliente, si le llega el texto tal cual (ventana de 24 h abierta, gratis), si hace falta una plantilla aprobada y cuánto cuesta, o por qué no se le puede escribir. Devuelve un presupuesto firmado que vale 15 minutos; para enviar, llama a miira.broadcast_sent con él. Enséñale el presupuesto a la persona antes.
34
- - `miira.broadcast_sent` — Envía la difusión de WhatsApp presupuestada por miira.broadcast_quoted, exactamente a quienes y como dijo el presupuesto. Si algo cambió (ventana, consentimiento, tarifa) se niega con el presupuesto nuevo para confirmarlo otra vez. Cuesta dinero cuando hay plantillas: no lo llames sin que la persona haya visto el coste.
35
- - `kiipu.draft_voided` Anula un BORRADOR de factura (uno creado con kiipu.invoice_proposed y todavía no emitido), por su id o por el nombre del cliente si es su único borrador. No anula facturas emitidas: eso es de una persona en Kiipu.
36
- - `kiipu.payment_reported` — Deja en la cola de aprobación de Kiipu el aviso de que un cliente (por su nombre) pagó cierto monto de una factura abierta. NO aplica el pago ni toca saldos: una persona lo revisa contra el banco y lo aplica. Si el cliente tiene varias facturas abiertas hay que decir el número.
37
-
38
- Hacen falta **dos** permisos para cada una: una llave con su alcance, y que el dueño del workspace la haya
39
- encendido. Referencia completa: https://developers.niiko.org
28
+ Output is always JSON. **The outcome is the exit code**, so a script never has to parse anything:
29
+
30
+ | Exit code | Meaning |
31
+ |---|---|
32
+ | `0` | `done` the action happened; `output` is in stdout. |
33
+ | `2` | `refused` it did not happen; `reason` names why and `detail.message` says what to do. |
34
+ | `3` | `pending_approval` it is waiting for a human signature and may complete hours later. |
35
+ | `1` | An error of the CLI itself or of the transport. |
36
+
37
+ ```bash
38
+ niiko run crm.note_added --input '{ "client": "Casa Mestiza", "note": "Prefers WhatsApp." }' && echo "saved"
39
+ ```
40
+
41
+ > [!NOTE]
42
+ > Every `run` sends an `Idempotency-Key` (or the one you pass with `--idempotency-key`), and echoes it in the
43
+ > output even when the server does not. Retrying the same key never runs the action twice so if the terminal
44
+ > died before you read the reply, run it again with the key you got.
45
+
46
+ The key is also read from `NIIKO_API_KEY` and the URL from `NIIKO_URL`, in that order after `--key`/`--url`,
47
+ for scripts that must not touch a person's configuration. The key is never printed.
48
+
49
+ ## The actions today (13)
50
+
51
+ | Action | What it does |
52
+ |---|---|
53
+ | `miira.lead_create` | Creates a new lead in the workspace CRM from its contact details. If a matching one already exists it is not duplicated: the reply is `ambiguous` with the candidates. |
54
+ | `crm.call_logged` | Logs what was discussed in a call on a client's record, naming the client; optionally creates the follow-up with its date. Reads nothing and calls no one. |
55
+ | `crm.owner_assigned` | Changes who owns a client, naming the client and the team member (by name or email). If either is ambiguous it refuses with the list. |
56
+ | `crm.stage_moved` | Moves a client's open deal to another pipeline stage, naming the client and the stage. Creates no deals: with no open deal it refuses, and with several it refuses with the list. |
57
+ | `kiipu.invoice_proposed` | Prepares an invoice as a DRAFT for a client named by name, with its lines and taxes. Does NOT issue it, does NOT number it and does NOT count as debt: a person reviews and issues it in Kiipu. Does not create the client if it does not exist. |
58
+ | `crm.task_created` | Creates a reminder (a task with date and time) on a client named by name. Does not log a call: that is crm.call_logged. Does not create the client if it does not exist. |
59
+ | `crm.deal_created` | Opens a new deal in the pipeline for a client named by name, with a title, an optional value in USD, an optional stage (by name; without it, the first one) and an optional owner. Does not check for other open deals: it returns how many remain so a duplicate is visible. Does not win or lose it: that is crm.stage_moved. |
60
+ | `crm.note_added` | Saves a note on a client's record, naming the client: something to know next time, with no call and no date. For a call use crm.call_logged; for a dated reminder, crm.task_created. |
61
+ | `crm.contact_added` | Adds a person (name, and optionally email, phone and role) to a client's record, naming the client. Does not make them the primary contact and does not create the client. If someone with that email or phone already existed, the reply says so but does not block it. |
62
+ | `miira.broadcast_quoted` | Quotes sending the SAME WhatsApp message to several clients named by name (up to 50). Sends NOTHING: per client, it says whether the text goes as-is (24-hour window open, free), whether an approved template is needed and what it costs, or why that client cannot be messaged. Returns a signed quote valid for 15 minutes; to send, call miira.broadcast_sent with it. Show the quote to the person first. |
63
+ | `miira.broadcast_sent` | Sends the WhatsApp broadcast quoted by miira.broadcast_quoted, exactly to whom and how the quote said. If anything changed (window, consent, rate) it refuses with a new quote to confirm again. Costs money when templates are involved: do not call it without the person having seen the cost. |
64
+ | `kiipu.draft_voided` | Voids a DRAFT invoice (one created with kiipu.invoice_proposed and not yet issued), by its id or by the client's name when it is their only draft. Does not void issued invoices: that is for a person in Kiipu. |
65
+ | `kiipu.payment_reported` | Leaves in the Kiipu approval queue the notice that a client (by name) paid a given amount of an open invoice. Does NOT apply the payment and touches no balances: a person checks it against the bank and applies it. If the client has several open invoices the number must be given. |
66
+
67
+ > [!IMPORTANT]
68
+ > Each one needs **two** permissions: a key with that action's scope, **and** the workspace owner having switched
69
+ > the permission on (Administration → Permissions). Holding the key is not enough — they are two decisions made by
70
+ > different people.
71
+
72
+ ## What it does not do
73
+
74
+ | Not included | Why |
75
+ |---|---|
76
+ | **Client-side validation** | The server validates, and its refusal travels with a name. A client that validates differently is the drift all of this avoids. |
77
+ | **Reads or pagination** | This API exposes verbs, not rows. There is nothing to list. |
78
+ | **OAuth** | The CLI belongs to whoever holds a key. AI clients connect through the [MCP server](https://github.com/vorluno/niiko-mcp-server) instead. |
79
+
80
+ ## Support
81
+
82
+ | | |
83
+ |---|---|
84
+ | **Versioning** | Semantic. Every visible change is in the [changelog](https://developers.niiko.org/#changelog) of the API reference. |
85
+ | **Regeneration** | This repository is generated from the niiko action manifest. When an action opens or a contract changes, a new version is published — nothing here is edited by hand. |
86
+ | **Issues** | [vorluno/niiko-cli/issues](https://github.com/vorluno/niiko-cli/issues) — a fault of the API itself starts at [developers.niiko.org](https://developers.niiko.org). |
87
+ | **Security** | `security@vorluno.dev` — first response within 48 hours, patch or plan within 7 days. |
88
+
89
+ ## Related
90
+
91
+ - **[@vorluno/niiko-sdk](https://github.com/vorluno/niiko-sdk-typescript)** — the TypeScript client, same plan.
92
+ - **[niiko (Python)](https://github.com/vorluno/niiko-sdk-python)** — the Python client, same plan.
93
+ - **[n8n-nodes-niiko](https://github.com/vorluno/n8n-nodes-niiko)** — the same actions as an n8n node.
94
+ - **[niiko-mcp-server](https://github.com/vorluno/niiko-mcp-server)** — the same actions as MCP tools, for Claude and other AI clients.
95
+ - **[developers.niiko.org](https://developers.niiko.org)** — the API reference this is generated alongside.
96
+
97
+ <sub>Built and maintained by <a href="https://vorluno.dev">Vorluno</a>, a software studio in Panama, and generated from <a href="https://niiko.org">niiko</a>'s production action manifest — the same one the server enforces. MIT.</sub>
package/dist/niiko.js CHANGED
@@ -1,23 +1,23 @@
1
1
  #!/usr/bin/env node
2
- // GENERADO POR `tooling/build-cli.ts` del monorepo de niiko — NO SE EDITA A MANO.
3
- // Plan `9a8b80d285ff`. Una acción nueva aparece aquí al regenerar; ninguna se añade editando este archivo.
2
+ // GENERATED by `tooling/build-cli.ts` in the niiko monorepo DO NOT EDIT BY HAND.
3
+ // Plan `6c3240a7b22d`. A new action shows up here on regeneration; none is added by editing this file.
4
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
- /** El catálogo, tal como estaba en el plan `9a8b80d285ff`. */
8
- const CATALOGO = [
7
+ /** The catalog, exactly as it was in plan `6c3240a7b22d`. */
8
+ const CATALOG = [
9
9
  {
10
- "accion": "miira.lead_create",
10
+ "action": "miira.lead_create",
11
11
  "version": 1,
12
- "alcance": "miira.lead_create@1",
13
- "proposito": "Crea un lead nuevo en el CRM del workspace a partir de sus datos de contacto. Si ya existe uno que encaja, no lo duplica: contesta `ambiguous` con los candidatos.",
14
- "motivos": [
12
+ "scope": "miira.lead_create@1",
13
+ "purpose": "Creates a new lead in the workspace CRM from its contact details. If a matching one already exists it is not duplicated: the reply is `ambiguous` with the candidates.",
14
+ "refusalReasons": [
15
15
  "honeypot",
16
16
  "invalid_email",
17
17
  "disposable_email",
18
18
  "invalid_identity"
19
19
  ],
20
- "entrada": {
20
+ "input": {
21
21
  "type": "object",
22
22
  "properties": {
23
23
  "submissionId": {
@@ -84,7 +84,7 @@ const CATALOGO = [
84
84
  "source"
85
85
  ]
86
86
  },
87
- "salida": {
87
+ "output": {
88
88
  "type": "object",
89
89
  "properties": {
90
90
  "outcome": {
@@ -131,17 +131,17 @@ const CATALOGO = [
131
131
  }
132
132
  },
133
133
  {
134
- "accion": "crm.call_logged",
134
+ "action": "crm.call_logged",
135
135
  "version": 1,
136
- "alcance": "crm.call_logged@1",
137
- "proposito": "Anota en la ficha de un cliente, dicho por su nombre, lo que se habló en una llamada; opcionalmente deja creado el seguimiento con su fecha. No lee nada ni llama a nadie.",
138
- "motivos": [
136
+ "scope": "crm.call_logged@1",
137
+ "purpose": "Logs what was discussed in a call on a client's record, naming the client; optionally creates the follow-up with its date. Reads nothing and calls no one.",
138
+ "refusalReasons": [
139
139
  "cliente_no_encontrado",
140
140
  "cliente_ambiguo",
141
141
  "demasiados_clientes",
142
142
  "sin_permiso"
143
143
  ],
144
- "entrada": {
144
+ "input": {
145
145
  "type": "object",
146
146
  "properties": {
147
147
  "client": {
@@ -178,7 +178,7 @@ const CATALOGO = [
178
178
  "summary"
179
179
  ]
180
180
  },
181
- "salida": {
181
+ "output": {
182
182
  "type": "object",
183
183
  "properties": {
184
184
  "clientId": {
@@ -210,11 +210,11 @@ const CATALOGO = [
210
210
  }
211
211
  },
212
212
  {
213
- "accion": "crm.owner_assigned",
213
+ "action": "crm.owner_assigned",
214
214
  "version": 1,
215
- "alcance": "crm.owner_assigned@1",
216
- "proposito": "Cambia de quién es un cliente, diciendo el nombre del cliente y el nombre (o correo) del miembro del equipo. Si alguno de los dos es ambiguo, se niega con la lista.",
217
- "motivos": [
215
+ "scope": "crm.owner_assigned@1",
216
+ "purpose": "Changes who owns a client, naming the client and the team member (by name or email). If either is ambiguous it refuses with the list.",
217
+ "refusalReasons": [
218
218
  "cliente_no_encontrado",
219
219
  "cliente_ambiguo",
220
220
  "demasiados_clientes",
@@ -222,7 +222,7 @@ const CATALOGO = [
222
222
  "persona_ambigua",
223
223
  "sin_permiso"
224
224
  ],
225
- "entrada": {
225
+ "input": {
226
226
  "type": "object",
227
227
  "properties": {
228
228
  "client": {
@@ -241,7 +241,7 @@ const CATALOGO = [
241
241
  "owner"
242
242
  ]
243
243
  },
244
- "salida": {
244
+ "output": {
245
245
  "type": "object",
246
246
  "properties": {
247
247
  "clientId": {
@@ -266,11 +266,11 @@ const CATALOGO = [
266
266
  }
267
267
  },
268
268
  {
269
- "accion": "crm.stage_moved",
269
+ "action": "crm.stage_moved",
270
270
  "version": 1,
271
- "alcance": "crm.stage_moved@1",
272
- "proposito": "Mueve el negocio abierto de un cliente a otra etapa del pipeline, diciendo el nombre del cliente y el de la etapa. No crea negocios: sin uno abierto se niega, y con varios se niega con la lista.",
273
- "motivos": [
271
+ "scope": "crm.stage_moved@1",
272
+ "purpose": "Moves a client's open deal to another pipeline stage, naming the client and the stage. Creates no deals: with no open deal it refuses, and with several it refuses with the list.",
273
+ "refusalReasons": [
274
274
  "cliente_no_encontrado",
275
275
  "cliente_ambiguo",
276
276
  "demasiados_clientes",
@@ -281,7 +281,7 @@ const CATALOGO = [
281
281
  "ya_en_esa_etapa",
282
282
  "sin_permiso"
283
283
  ],
284
- "entrada": {
284
+ "input": {
285
285
  "type": "object",
286
286
  "properties": {
287
287
  "client": {
@@ -304,7 +304,7 @@ const CATALOGO = [
304
304
  "stage"
305
305
  ]
306
306
  },
307
- "salida": {
307
+ "output": {
308
308
  "type": "object",
309
309
  "properties": {
310
310
  "dealId": {
@@ -337,17 +337,17 @@ const CATALOGO = [
337
337
  }
338
338
  },
339
339
  {
340
- "accion": "kiipu.invoice_proposed",
340
+ "action": "kiipu.invoice_proposed",
341
341
  "version": 1,
342
- "alcance": "kiipu.invoice_proposed@1",
343
- "proposito": "Deja preparada una factura como BORRADOR para un cliente dicho por su nombre, con sus líneas e impuestos. NO la emite, NO la numera y NO cuenta como deuda: una persona la revisa y la emite en Kiipu. No crea el cliente si no existe.",
344
- "motivos": [
342
+ "scope": "kiipu.invoice_proposed@1",
343
+ "purpose": "Prepares an invoice as a DRAFT for a client named by name, with its lines and taxes. Does NOT issue it, does NOT number it and does NOT count as debt: a person reviews and issues it in Kiipu. Does not create the client if it does not exist.",
344
+ "refusalReasons": [
345
345
  "cliente_no_encontrado",
346
346
  "cliente_ambiguo",
347
347
  "demasiados_clientes",
348
348
  "sin_permiso"
349
349
  ],
350
- "entrada": {
350
+ "input": {
351
351
  "type": "object",
352
352
  "properties": {
353
353
  "client": {
@@ -406,7 +406,7 @@ const CATALOGO = [
406
406
  "lines"
407
407
  ]
408
408
  },
409
- "salida": {
409
+ "output": {
410
410
  "type": "object",
411
411
  "properties": {
412
412
  "invoiceId": {
@@ -455,17 +455,17 @@ const CATALOGO = [
455
455
  }
456
456
  },
457
457
  {
458
- "accion": "crm.task_created",
458
+ "action": "crm.task_created",
459
459
  "version": 1,
460
- "alcance": "crm.task_created@1",
461
- "proposito": "Crea un recordatorio (tarea con fecha y hora) sobre un cliente dicho por su nombre. No anota una llamada: para eso está crm.call_logged. No crea el cliente si no existe.",
462
- "motivos": [
460
+ "scope": "crm.task_created@1",
461
+ "purpose": "Creates a reminder (a task with date and time) on a client named by name. Does not log a call: that is crm.call_logged. Does not create the client if it does not exist.",
462
+ "refusalReasons": [
463
463
  "cliente_no_encontrado",
464
464
  "cliente_ambiguo",
465
465
  "demasiados_clientes",
466
466
  "sin_permiso"
467
467
  ],
468
- "entrada": {
468
+ "input": {
469
469
  "type": "object",
470
470
  "properties": {
471
471
  "client": {
@@ -489,7 +489,7 @@ const CATALOGO = [
489
489
  "dueAt"
490
490
  ]
491
491
  },
492
- "salida": {
492
+ "output": {
493
493
  "type": "object",
494
494
  "properties": {
495
495
  "clientId": {
@@ -514,11 +514,11 @@ const CATALOGO = [
514
514
  }
515
515
  },
516
516
  {
517
- "accion": "crm.deal_created",
517
+ "action": "crm.deal_created",
518
518
  "version": 1,
519
- "alcance": "crm.deal_created@1",
520
- "proposito": "Abre un negocio nuevo en el pipeline para un cliente dicho por su nombre, con título, valor opcional en USD, etapa opcional (por nombre; sin ella, la primera) y responsable opcional. No comprueba si ya tiene otros abiertos: devuelve cuántos quedan para que se vea un duplicado. No lo gana ni lo pierde: eso es crm.stage_moved.",
521
- "motivos": [
519
+ "scope": "crm.deal_created@1",
520
+ "purpose": "Opens a new deal in the pipeline for a client named by name, with a title, an optional value in USD, an optional stage (by name; without it, the first one) and an optional owner. Does not check for other open deals: it returns how many remain so a duplicate is visible. Does not win or lose it: that is crm.stage_moved.",
521
+ "refusalReasons": [
522
522
  "cliente_no_encontrado",
523
523
  "cliente_ambiguo",
524
524
  "demasiados_clientes",
@@ -528,7 +528,7 @@ const CATALOGO = [
528
528
  "persona_ambigua",
529
529
  "sin_permiso"
530
530
  ],
531
- "entrada": {
531
+ "input": {
532
532
  "type": "object",
533
533
  "properties": {
534
534
  "client": {
@@ -561,7 +561,7 @@ const CATALOGO = [
561
561
  "title"
562
562
  ]
563
563
  },
564
- "salida": {
564
+ "output": {
565
565
  "type": "object",
566
566
  "properties": {
567
567
  "dealId": {
@@ -617,17 +617,17 @@ const CATALOGO = [
617
617
  }
618
618
  },
619
619
  {
620
- "accion": "crm.note_added",
620
+ "action": "crm.note_added",
621
621
  "version": 1,
622
- "alcance": "crm.note_added@1",
623
- "proposito": "Guarda una nota en la ficha de un cliente dicho por su nombre: algo que hay que saber la próxima vez, sin llamada ni fecha. Para una llamada está crm.call_logged; para un recordatorio con fecha, crm.task_created.",
624
- "motivos": [
622
+ "scope": "crm.note_added@1",
623
+ "purpose": "Saves a note on a client's record, naming the client: something to know next time, with no call and no date. For a call use crm.call_logged; for a dated reminder, crm.task_created.",
624
+ "refusalReasons": [
625
625
  "cliente_no_encontrado",
626
626
  "cliente_ambiguo",
627
627
  "demasiados_clientes",
628
628
  "sin_permiso"
629
629
  ],
630
- "entrada": {
630
+ "input": {
631
631
  "type": "object",
632
632
  "properties": {
633
633
  "client": {
@@ -646,7 +646,7 @@ const CATALOGO = [
646
646
  "note"
647
647
  ]
648
648
  },
649
- "salida": {
649
+ "output": {
650
650
  "type": "object",
651
651
  "properties": {
652
652
  "clientId": {
@@ -667,17 +667,17 @@ const CATALOGO = [
667
667
  }
668
668
  },
669
669
  {
670
- "accion": "crm.contact_added",
670
+ "action": "crm.contact_added",
671
671
  "version": 1,
672
- "alcance": "crm.contact_added@1",
673
- "proposito": "Añade una persona (nombre, y opcionalmente correo, teléfono y cargo) a la ficha de un cliente dicho por su nombre. No la hace contacto principal ni crea el cliente. Si ya había alguien con ese correo o teléfono, lo dice en la respuesta pero no lo impide.",
674
- "motivos": [
672
+ "scope": "crm.contact_added@1",
673
+ "purpose": "Adds a person (name, and optionally email, phone and role) to a client's record, naming the client. Does not make them the primary contact and does not create the client. If someone with that email or phone already existed, the reply says so but does not block it.",
674
+ "refusalReasons": [
675
675
  "cliente_no_encontrado",
676
676
  "cliente_ambiguo",
677
677
  "demasiados_clientes",
678
678
  "sin_permiso"
679
679
  ],
680
- "entrada": {
680
+ "input": {
681
681
  "type": "object",
682
682
  "properties": {
683
683
  "client": {
@@ -711,7 +711,7 @@ const CATALOGO = [
711
711
  "name"
712
712
  ]
713
713
  },
714
- "salida": {
714
+ "output": {
715
715
  "type": "object",
716
716
  "properties": {
717
717
  "clientId": {
@@ -736,16 +736,16 @@ const CATALOGO = [
736
736
  }
737
737
  },
738
738
  {
739
- "accion": "miira.broadcast_quoted",
739
+ "action": "miira.broadcast_quoted",
740
740
  "version": 1,
741
- "alcance": "miira.broadcast_quoted@1",
742
- "proposito": "Presupuesta mandar el MISMO mensaje de WhatsApp a varios clientes dichos por su nombre (hasta 50). NO envía nada: dice, por cliente, si le llega el texto tal cual (ventana de 24 h abierta, gratis), si hace falta una plantilla aprobada y cuánto cuesta, o por qué no se le puede escribir. Devuelve un presupuesto firmado que vale 15 minutos; para enviar, llama a miira.broadcast_sent con él. Enséñale el presupuesto a la persona antes.",
743
- "motivos": [
741
+ "scope": "miira.broadcast_quoted@1",
742
+ "purpose": "Quotes sending the SAME WhatsApp message to several clients named by name (up to 50). Sends NOTHING: per client, it says whether the text goes as-is (24-hour window open, free), whether an approved template is needed and what it costs, or why that client cannot be messaged. Returns a signed quote valid for 15 minutes; to send, call miira.broadcast_sent with it. Show the quote to the person first.",
743
+ "refusalReasons": [
744
744
  "plantilla_invalida",
745
745
  "tarifas_vencidas",
746
746
  "sin_permiso"
747
747
  ],
748
- "entrada": {
748
+ "input": {
749
749
  "type": "object",
750
750
  "properties": {
751
751
  "clients": {
@@ -779,7 +779,7 @@ const CATALOGO = [
779
779
  "message"
780
780
  ]
781
781
  },
782
- "salida": {
782
+ "output": {
783
783
  "type": "object",
784
784
  "properties": {
785
785
  "rows": {
@@ -907,11 +907,11 @@ const CATALOGO = [
907
907
  }
908
908
  },
909
909
  {
910
- "accion": "miira.broadcast_sent",
910
+ "action": "miira.broadcast_sent",
911
911
  "version": 1,
912
- "alcance": "miira.broadcast_sent@1",
913
- "proposito": "Envía la difusión de WhatsApp presupuestada por miira.broadcast_quoted, exactamente a quienes y como dijo el presupuesto. Si algo cambió (ventana, consentimiento, tarifa) se niega con el presupuesto nuevo para confirmarlo otra vez. Cuesta dinero cuando hay plantillas: no lo llames sin que la persona haya visto el coste.",
914
- "motivos": [
912
+ "scope": "miira.broadcast_sent@1",
913
+ "purpose": "Sends the WhatsApp broadcast quoted by miira.broadcast_quoted, exactly to whom and how the quote said. If anything changed (window, consent, rate) it refuses with a new quote to confirm again. Costs money when templates are involved: do not call it without the person having seen the cost.",
914
+ "refusalReasons": [
915
915
  "presupuesto_invalido",
916
916
  "presupuesto_vencido",
917
917
  "presupuesto_cambiado",
@@ -919,7 +919,7 @@ const CATALOGO = [
919
919
  "tarifas_vencidas",
920
920
  "sin_permiso"
921
921
  ],
922
- "entrada": {
922
+ "input": {
923
923
  "type": "object",
924
924
  "properties": {
925
925
  "quote": {
@@ -932,7 +932,7 @@ const CATALOGO = [
932
932
  "quote"
933
933
  ]
934
934
  },
935
- "salida": {
935
+ "output": {
936
936
  "type": "object",
937
937
  "properties": {
938
938
  "queued": {
@@ -977,11 +977,11 @@ const CATALOGO = [
977
977
  }
978
978
  },
979
979
  {
980
- "accion": "kiipu.draft_voided",
980
+ "action": "kiipu.draft_voided",
981
981
  "version": 1,
982
- "alcance": "kiipu.draft_voided@1",
983
- "proposito": "Anula un BORRADOR de factura (uno creado con kiipu.invoice_proposed y todavía no emitido), por su id o por el nombre del cliente si es su único borrador. No anula facturas emitidas: eso es de una persona en Kiipu.",
984
- "motivos": [
982
+ "scope": "kiipu.draft_voided@1",
983
+ "purpose": "Voids a DRAFT invoice (one created with kiipu.invoice_proposed and not yet issued), by its id or by the client's name when it is their only draft. Does not void issued invoices: that is for a person in Kiipu.",
984
+ "refusalReasons": [
985
985
  "cliente_no_encontrado",
986
986
  "cliente_ambiguo",
987
987
  "demasiados_clientes",
@@ -990,7 +990,7 @@ const CATALOGO = [
990
990
  "no_es_borrador",
991
991
  "sin_permiso"
992
992
  ],
993
- "entrada": {
993
+ "input": {
994
994
  "type": "object",
995
995
  "properties": {
996
996
  "invoiceId": {
@@ -1005,7 +1005,7 @@ const CATALOGO = [
1005
1005
  },
1006
1006
  "required": []
1007
1007
  },
1008
- "salida": {
1008
+ "output": {
1009
1009
  "type": "object",
1010
1010
  "properties": {
1011
1011
  "invoiceId": {
@@ -1034,11 +1034,11 @@ const CATALOGO = [
1034
1034
  }
1035
1035
  },
1036
1036
  {
1037
- "accion": "kiipu.payment_reported",
1037
+ "action": "kiipu.payment_reported",
1038
1038
  "version": 1,
1039
- "alcance": "kiipu.payment_reported@1",
1040
- "proposito": "Deja en la cola de aprobación de Kiipu el aviso de que un cliente (por su nombre) pagó cierto monto de una factura abierta. NO aplica el pago ni toca saldos: una persona lo revisa contra el banco y lo aplica. Si el cliente tiene varias facturas abiertas hay que decir el número.",
1041
- "motivos": [
1039
+ "scope": "kiipu.payment_reported@1",
1040
+ "purpose": "Leaves in the Kiipu approval queue the notice that a client (by name) paid a given amount of an open invoice. Does NOT apply the payment and touches no balances: a person checks it against the bank and applies it. If the client has several open invoices the number must be given.",
1041
+ "refusalReasons": [
1042
1042
  "cliente_no_encontrado",
1043
1043
  "cliente_ambiguo",
1044
1044
  "demasiados_clientes",
@@ -1047,7 +1047,7 @@ const CATALOGO = [
1047
1047
  "factura_no_encontrada",
1048
1048
  "sin_permiso"
1049
1049
  ],
1050
- "entrada": {
1050
+ "input": {
1051
1051
  "type": "object",
1052
1052
  "properties": {
1053
1053
  "client": {
@@ -1070,7 +1070,7 @@ const CATALOGO = [
1070
1070
  "amountUsd"
1071
1071
  ]
1072
1072
  },
1073
- "salida": {
1073
+ "output": {
1074
1074
  "type": "object",
1075
1075
  "properties": {
1076
1076
  "submissionId": {
@@ -1115,33 +1115,33 @@ const CATALOGO = [
1115
1115
  }
1116
1116
  }
1117
1117
  ];
1118
- const RUTA_CONFIG = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "niiko", "config.json");
1119
- function leerConfig() {
1120
- if (!existsSync(RUTA_CONFIG))
1118
+ const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "niiko", "config.json");
1119
+ function readConfig() {
1120
+ if (!existsSync(CONFIG_PATH))
1121
1121
  return null;
1122
1122
  try {
1123
- const c = JSON.parse(readFileSync(RUTA_CONFIG, "utf8"));
1123
+ const c = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
1124
1124
  return typeof c.url === "string" && typeof c.apiKey === "string" ? { url: c.url, apiKey: c.apiKey } : null;
1125
1125
  }
1126
1126
  catch {
1127
1127
  return null;
1128
1128
  }
1129
1129
  }
1130
- /** La llave: `--key`, o `NIIKO_API_KEY`, o la guardada. En ese orden, para que un script pueda mandar sin tocar
1131
- * la configuración de la persona. Nunca se imprime. */
1132
- function credenciales(args) {
1133
- const guardada = leerConfig();
1134
- const url = args.get("url") ?? process.env.NIIKO_URL ?? guardada?.url ?? "https://niiko.org";
1135
- const apiKey = args.get("key") ?? process.env.NIIKO_API_KEY ?? guardada?.apiKey;
1130
+ /** The key: `--key`, then `NIIKO_API_KEY`, then the saved one. In that order, so a script can send without
1131
+ * touching the person's configuration. It is never printed. */
1132
+ function credentials(args) {
1133
+ const saved = readConfig();
1134
+ const url = args.get("url") ?? process.env.NIIKO_URL ?? saved?.url ?? "https://niiko.org";
1135
+ const apiKey = args.get("key") ?? process.env.NIIKO_API_KEY ?? saved?.apiKey;
1136
1136
  return apiKey ? { url: url.replace(/\/+$/, ""), apiKey } : null;
1137
1137
  }
1138
- function salir(codigo, cuerpo) {
1139
- process.stdout.write(JSON.stringify(cuerpo, null, 2) + "\n");
1140
- process.exit(codigo);
1138
+ function exit(code, body) {
1139
+ process.stdout.write(JSON.stringify(body, null, 2) + "\n");
1140
+ process.exit(code);
1141
1141
  }
1142
- /** `--a b` y `--a=b`. Lo que no empieza por `--` es posicional. */
1143
- function parsear(argv) {
1144
- const posicionales = [];
1142
+ /** `--a b` and `--a=b`. Anything not starting with `--` is positional. */
1143
+ function parse(argv) {
1144
+ const positional = [];
1145
1145
  const args = new Map();
1146
1146
  for (let i = 0; i < argv.length; i++) {
1147
1147
  const a = argv[i];
@@ -1155,109 +1155,109 @@ function parsear(argv) {
1155
1155
  args.set(k, "");
1156
1156
  }
1157
1157
  else
1158
- posicionales.push(a);
1158
+ positional.push(a);
1159
1159
  }
1160
- return { posicionales, args };
1160
+ return { positional, args };
1161
1161
  }
1162
- function leerEntrada(args) {
1163
- const crudo = args.get("input");
1164
- let texto;
1165
- if (crudo === undefined) {
1162
+ function readInput(args) {
1163
+ const raw = args.get("input");
1164
+ let text;
1165
+ if (raw === undefined) {
1166
1166
  if (process.stdin.isTTY)
1167
- salir(1, { error: "falta la entrada: --input '{…}', --input @archivo, o por stdin" });
1168
- texto = readFileSync(0, "utf8");
1167
+ exit(1, { error: "missing input: --input '{…}', --input @file, or stdin" });
1168
+ text = readFileSync(0, "utf8");
1169
1169
  }
1170
- else if (crudo.startsWith("@"))
1171
- texto = readFileSync(crudo.slice(1), "utf8");
1170
+ else if (raw.startsWith("@"))
1171
+ text = readFileSync(raw.slice(1), "utf8");
1172
1172
  else
1173
- texto = crudo;
1173
+ text = raw;
1174
1174
  try {
1175
- return JSON.parse(texto);
1175
+ return JSON.parse(text);
1176
1176
  }
1177
1177
  catch {
1178
- return salir(1, { error: "la entrada no es JSON válido" });
1178
+ return exit(1, { error: "input is not valid JSON" });
1179
1179
  }
1180
1180
  }
1181
1181
  function uuid() {
1182
1182
  return globalThis.crypto.randomUUID();
1183
1183
  }
1184
- const AYUDA = `niiko — la API de acciones de niiko desde la terminal (plan 9a8b80d285ff)
1184
+ const HELP = `niiko — the niiko actions API from the terminal (plan 6c3240a7b22d)
1185
1185
 
1186
- niiko login --url https://niiko.org --key nk_… guarda la llave en ${RUTA_CONFIG}
1187
- niiko actions las acciones abiertas, con su propósito
1188
- niiko describe <accion> entrada, salida y motivos de una acción
1189
- niiko run <accion> --input '{…}' ejerce una acción (también --input @archivo o stdin)
1186
+ niiko login --url https://niiko.org --key nk_… saves the key to ${CONFIG_PATH}
1187
+ niiko actions the open actions, with their purpose
1188
+ niiko describe <action> input, output and refusal reasons of one action
1189
+ niiko run <action> --input '{…}' exercises an action (also --input @file or stdin)
1190
1190
  [--idempotency-key K] [--key nk_…] [--url …]
1191
1191
 
1192
- Códigos de salida de \`run\`: 0 hecho · 2 negada (el motivo va en la salida) · 3 esperando una firma · 1 error.
1193
- La llave también se lee de NIIKO_API_KEY y la URL de NIIKO_URL.`;
1192
+ Exit codes of \`run\`: 0 done · 2 refused (the reason is in the output) · 3 waiting for a signature · 1 error.
1193
+ The key is also read from NIIKO_API_KEY and the URL from NIIKO_URL.`;
1194
1194
  async function main() {
1195
- const { posicionales, args } = parsear(process.argv.slice(2));
1196
- const cmd = posicionales[0];
1195
+ const { positional, args } = parse(process.argv.slice(2));
1196
+ const cmd = positional[0];
1197
1197
  if (!cmd || cmd === "help" || args.has("help")) {
1198
- process.stdout.write(AYUDA + "\n");
1198
+ process.stdout.write(HELP + "\n");
1199
1199
  process.exit(0);
1200
1200
  }
1201
1201
  if (cmd === "login") {
1202
1202
  const apiKey = args.get("key") ?? process.env.NIIKO_API_KEY;
1203
1203
  if (!apiKey)
1204
- salir(1, { error: "falta --key nk_… (o NIIKO_API_KEY)" });
1204
+ exit(1, { error: "missing --key nk_… (or NIIKO_API_KEY)" });
1205
1205
  const url = (args.get("url") ?? process.env.NIIKO_URL ?? "https://niiko.org").replace(/\/+$/, "");
1206
- mkdirSync(join(RUTA_CONFIG, ".."), { recursive: true });
1207
- writeFileSync(RUTA_CONFIG, JSON.stringify({ url, apiKey }, null, 2) + "\n", { mode: 0o600 });
1208
- // Se dice DÓNDE quedó y con qué prefijo, nunca la llave entera: la terminal es un historial.
1209
- salir(0, { guardado: RUTA_CONFIG, url, key: apiKey.slice(0, 7) + "…" });
1206
+ mkdirSync(join(CONFIG_PATH, ".."), { recursive: true });
1207
+ writeFileSync(CONFIG_PATH, JSON.stringify({ url, apiKey }, null, 2) + "\n", { mode: 0o600 });
1208
+ // Say WHERE it went and the prefix, never the whole key: a terminal is a history.
1209
+ exit(0, { saved: CONFIG_PATH, url, key: apiKey.slice(0, 7) + "…" });
1210
1210
  }
1211
1211
  if (cmd === "actions") {
1212
- salir(0, CATALOGO.map((a) => ({ accion: a.accion, version: a.version, alcance: a.alcance, proposito: a.proposito })));
1212
+ exit(0, CATALOG.map((a) => ({ action: a.action, version: a.version, scope: a.scope, purpose: a.purpose })));
1213
1213
  }
1214
1214
  if (cmd === "describe") {
1215
- const id = posicionales[1];
1216
- const a = CATALOGO.find((x) => x.accion === id);
1215
+ const id = positional[1];
1216
+ const a = CATALOG.find((x) => x.action === id);
1217
1217
  if (!a)
1218
- salir(1, { error: `no existe la acción \`${id}\``, disponibles: CATALOGO.map((x) => x.accion) });
1219
- salir(0, a);
1218
+ exit(1, { error: `unknown action \`${id}\``, available: CATALOG.map((x) => x.action) });
1219
+ exit(0, a);
1220
1220
  }
1221
1221
  if (cmd === "run") {
1222
- const id = posicionales[1];
1223
- const a = CATALOGO.find((x) => x.accion === id);
1222
+ const id = positional[1];
1223
+ const a = CATALOG.find((x) => x.action === id);
1224
1224
  if (!a)
1225
- salir(1, { error: `no existe la acción \`${id}\``, disponibles: CATALOGO.map((x) => x.accion) });
1226
- const cred = credenciales(args);
1225
+ exit(1, { error: `unknown action \`${id}\``, available: CATALOG.map((x) => x.action) });
1226
+ const cred = credentials(args);
1227
1227
  if (!cred)
1228
- salir(1, { error: "sin llave: niiko login --key nk_…, o NIIKO_API_KEY, o --key" });
1229
- const entrada = leerEntrada(args);
1230
- const clave = args.get("idempotency-key") ?? uuid();
1228
+ exit(1, { error: "no key: niiko login --key nk_…, or NIIKO_API_KEY, or --key" });
1229
+ const input = readInput(args);
1230
+ const key = args.get("idempotency-key") ?? uuid();
1231
1231
  let r;
1232
1232
  try {
1233
- r = await fetch(`${cred.url}/api/v1/actions/${a.accion}`, {
1233
+ r = await fetch(`${cred.url}/api/v1/actions/${a.action}`, {
1234
1234
  method: "POST",
1235
1235
  headers: {
1236
1236
  Authorization: `Bearer ${cred.apiKey}`,
1237
1237
  "Content-Type": "application/json",
1238
- // Siempre con clave: sin ella un reintento por timeout duplica el efecto, y el servidor lo exige.
1239
- "Idempotency-Key": clave,
1240
- "User-Agent": "niiko-cli/9a8b80d285ff",
1238
+ // Always with a key: without one a retry after a timeout duplicates the effect, and the server requires it.
1239
+ "Idempotency-Key": key,
1240
+ "User-Agent": "niiko-cli/0.1.1",
1241
1241
  },
1242
- body: JSON.stringify(entrada),
1242
+ body: JSON.stringify(input),
1243
1243
  });
1244
1244
  }
1245
1245
  catch (e) {
1246
- salir(1, { error: "no se pudo conectar", detalle: String(e), url: cred.url });
1246
+ exit(1, { error: "could not connect", detail: String(e), url: cred.url });
1247
1247
  }
1248
- const cuerpo = await r.json().catch(() => null);
1249
- const status = cuerpo?.status;
1250
- // La llave de idempotencia viaja en la salida aunque el servidor no la eche: es lo que permite reintentar
1251
- // EXACTAMENTE lo mismo si la terminal murió antes de leer la respuesta.
1252
- const salida = typeof cuerpo === "object" && cuerpo !== null ? { ...cuerpo, idempotencyKey: cuerpo.idempotencyKey ?? clave } : { http: r.status, cuerpo };
1248
+ const body = await r.json().catch(() => null);
1249
+ const status = body?.status;
1250
+ // The idempotency key travels in the output even if the server does not echo it: it is what lets you retry
1251
+ // EXACTLY the same thing if the terminal died before reading the reply.
1252
+ const out = typeof body === "object" && body !== null ? { ...body, idempotencyKey: body.idempotencyKey ?? key } : { http: r.status, body };
1253
1253
  if (status === "done")
1254
- salir(0, salida);
1254
+ exit(0, out);
1255
1255
  if (status === "pending_approval")
1256
- salir(3, salida);
1256
+ exit(3, out);
1257
1257
  if (status === "refused")
1258
- salir(2, salida);
1259
- salir(1, { error: `respuesta inesperada (HTTP ${r.status})`, ...salida });
1258
+ exit(2, out);
1259
+ exit(1, { error: `unexpected response (HTTP ${r.status})`, ...out });
1260
1260
  }
1261
- salir(1, { error: `comando desconocido: ${cmd}`, ayuda: "niiko help" });
1261
+ exit(1, { error: `unknown command: ${cmd}`, help: "niiko help" });
1262
1262
  }
1263
- main().catch((e) => salir(1, { error: String(e) }));
1263
+ main().catch((e) => exit(1, { error: String(e) }));
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "niiko-cli",
3
- "version": "0.1.0",
4
- "description": "La API de acciones de niiko desde la terminal. Generada del mismo plan que los SDK.",
3
+ "version": "0.1.1",
4
+ "description": "The niiko actions API from the terminal. Generated from the same plan as the SDKs.",
5
+ "keywords": [
6
+ "niiko",
7
+ "cli",
8
+ "crm",
9
+ "whatsapp",
10
+ "invoicing",
11
+ "actions-api"
12
+ ],
5
13
  "license": "MIT",
6
14
  "type": "module",
7
15
  "bin": {
@@ -9,7 +17,8 @@
9
17
  },
10
18
  "files": [
11
19
  "dist",
12
- "README.md"
20
+ "README.md",
21
+ "LICENSE"
13
22
  ],
14
23
  "engines": {
15
24
  "node": ">=20"
@@ -24,7 +33,11 @@
24
33
  },
25
34
  "repository": {
26
35
  "type": "git",
27
- "url": "https://github.com/vorluno/niiko-cli"
36
+ "url": "git+https://github.com/vorluno/niiko-cli.git"
37
+ },
38
+ "homepage": "https://developers.niiko.org",
39
+ "bugs": {
40
+ "url": "https://github.com/vorluno/niiko-cli/issues"
28
41
  },
29
- "homepage": "https://developers.niiko.org"
42
+ "author": "Vorluno (https://vorluno.dev)"
30
43
  }