talo-hub 0.1.2 → 0.1.4

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
@@ -1,104 +1,107 @@
1
- # talo-hub
2
-
3
- **Cobrá con transferencias bancarias en Argentina, con acreditación y conciliación automáticas.** Desde la terminal, tu código o tu agente de IA, con [Talo Pay](https://talo.com.ar).
4
-
5
- Cada cobro tiene su propio **CVU y alias**. Tu cliente transfiere desde cualquier banco o billetera, Talo lo acredita y vos sabés al instante qué pedido se pagó: sin comprobantes, sin revisar el home banking y sin conciliar a mano.
6
-
7
- ```bash
8
- npx talo-hub login # conectá tu cuenta Talo (sandbox, sin plata real)
9
- npx talo-hub payments create --amount 15000 # → CVU y alias para que te transfieran
10
- npx talo-hub sandbox pay <payment_id> # simulá la transferencia del cliente
11
- npx talo-hub events listen # → payment.paid (muestra los últimos y espera los nuevos)
12
- ```
13
-
14
- Sin instalar nada: alcanza con Node 20 o más nuevo. Documentación: **https://talopartnerhub.vercel.app/docs**
15
-
16
- ## Por qué talo-hub
17
-
18
- - **Un CVU por cobro:** cada pago se identifica solo. Se acabó el "mandame el comprobante".
19
- - **Confirmación automática:** te enterás cuando la plata entra, por terminal, webhook firmado o tu agente.
20
- - **Sandbox real:** simulás transferencias exactas, de menos (UNDERPAID) o de más (OVERPAID) y probás todo el flujo sin mover plata.
21
- - **Hecho para agentes de IA:** servidor MCP con OAuth, skills listas y docs en Markdown. Tu agente integra, prueba y lleva a producción.
22
- - **Seguro:** keys separadas para sandbox y producción, con permisos `read`, `charge` (cobra, no reembolsa) o `full`.
23
-
24
- ## Para agentes de IA
25
-
26
- Conectá Claude Code, Cursor o Claude Desktop a tu cuenta Talo. Con OAuth, la key nunca pasa por el chat:
27
-
28
- ```bash
29
- claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp
30
- # después, en Claude Code: /mcp → talo → Authenticate
31
- ```
32
-
33
- ```bash
34
- npx talo-hub mcp --client cursor # o --client desktop; --with-key para usar tu key
35
- npx talo-hub skills install # skills de Talo en .claude/skills
36
- ```
37
-
38
- El agente puede crear cobros, simular transferencias, registrar y probar su webhook, correr el checklist de producción (`check_go_live`) y leer la documentación (`read_docs`).
39
-
40
- ### Instructions for AI agents
41
-
42
- - Purpose: accept ARS bank-transfer payments in Argentina on the user's Talo Pay account. Each payment returns a `cvu` and `alias`; status goes `PENDING` → `SUCCESS` (or `UNDERPAID` / `OVERPAID` / `EXPIRED`).
43
- - Connect: MCP at `https://talopartnerhub.vercel.app/api/mcp` (OAuth), or this CLI after the user runs `npx talo-hub login`. A person must authorize Talo once per environment; everything else you can do.
44
- - Keys: `tph_test_…` sandbox (test money: test freely), `tph_live_…` production (real money: confirm refunds and amount changes with the user). Scopes `read` < `charge` < `full`; give servers `charge`. Store project keys in the server's env (`TALO_HUB_API_KEY`), never in code or chat; exporting it in your own shell replaces the login key. `login --production` does not make production the default: pass `--production` or run `use production`.
45
- - Machine-readable: every command takes `--json` (JSON on stdout, human messages on stderr) and exits non-zero on failure, `doctor` and `webhooks test` included. Errors with `--json`: `{"error":{"code","message","status"}}`, with stable codes such as `not_logged_in`, `invalid_amount`, `invalid_hub`, `env_key_mismatch`, `insufficient_scope` (plus `key_scope` and `required_scope`), `talo_reconnect_required`, `network_error`, `invalid_response`. `status` is the hub's HTTP status, or null when the CLI found the error itself or couldn't reach the hub. `events listen --json` prints one event per line; the last few existing events come first with `"replayed": true`. Docs as Markdown: https://talopartnerhub.vercel.app/llms.txt, `/docs/<page>.md`, OpenAPI at `/openapi.json`.
46
- - Amounts: pass `--amount 15000` or `15000.50`. Don't use thousands separators unquoted: PowerShell turns `15.000,50` into `15,50`.
47
- - Payments always include `paid` and `refunded` as numbers (0 when none) and `refunds: [{ id, status, type, amount, currency, created_at }]`. Filter events with `events list --payment <id>`.
48
- - Test plan: `payments create --amount 15000` → `sandbox pay <id> --amount 14999` (UNDERPAID, don't fulfil) → `sandbox pay <id> --amount 1` (SUCCESS) → `webhooks add <https url>` → `webhooks test <id>` → `doctor` until it exits 0.
49
-
50
- ## Comandos
51
-
52
- | Comando | |
53
- |---|---|
54
- | `login [--production]` | Conecta tu cuenta Talo desde el navegador |
55
- | `status` | Cuenta, estado en Talo y permisos de la key |
56
- | `doctor` | Checklist para pasar a producción, con lo que falta |
57
- | `payments create --amount <ARS> [--external-id <id>] [--motive <texto>]` | Crea un cobro y muestra CVU y alias |
58
- | `payments get <id>` | Estado de un cobro |
59
- | `payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]` | Cobros de un período (30 días por defecto) |
60
- | `payments refund <id> [--amount <ARS>]` | Reembolso total o parcial |
61
- | `sandbox pay <id> [--amount <ARS>]` | Simula la transferencia del cliente |
62
- | `balance`, `transactions [--from] [--to] [--limit <n>] [--cursor]` | Saldo y movimientos |
63
- | `events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <n>]` | Eventos recientes, filtrables por cobro o tipo |
64
- | `events listen [--forward-to <url\|puerto>]` | Recibe eventos en vivo y los reenvía a localhost sin túneles (sin firma, con `talo-hub-forwarded: 1`: solo para desarrollo) |
65
- | `webhooks list \| add <url> \| remove <id> \| test <id>` | Webhooks firmados con HMAC-SHA256 |
66
- | `keys list \| create [--name] [--scope read\|charge\|full] \| revoke <id>` | Keys para tus proyectos (`charge` por defecto) |
67
- | `mcp [--client claude\|cursor\|desktop] [--with-key]` | Configuración del MCP para tu agente |
68
- | `skills install [--global]` | Instala las skills de Talo |
69
- | `use <sandbox\|production>` | Entorno activo de los próximos comandos |
70
- | `logout [--production \| --all]` | Revoca la key en el hub y la borra de esta máquina |
71
-
72
- Opciones: `--sandbox`, `--production`, `--json`, `--version`, `--hub <url>`.
73
-
74
- Montos: `15000` o `15000.50`. También entiende `15000,50` y `15.000,50`, pero en PowerShell escribilos entre comillas (`"15.000,50"`): sin comillas, PowerShell los convierte en otro número antes de que lleguen al CLI. Fechas: `AAAA-MM-DD` o ISO 8601, en hora de Argentina.
75
-
76
- ## De sandbox a producción
77
-
78
- ```bash
79
- npx talo-hub doctor # sandbox listo
80
- npx talo-hub login --production
81
- npx talo-hub keys create --production --name mi-tienda --scope charge
82
- npx talo-hub webhooks add https://tu-sitio.com/api/webhooks/talo --production
83
- npx talo-hub webhooks test <id> --production
84
- npx talo-hub doctor --production
85
- ```
86
-
87
- ## Seguridad
88
-
89
- - `login` muestra un código para confirmar en el navegador. La key queda en `~/.config/talo-hub/config.json` (en Windows, `%APPDATA%\talo-hub`), legible solo por tu usuario.
90
- - `TALO_HUB_API_KEY` usa una key sin login, para CI y agentes. Si no coincide con `--sandbox` o `--production`, el CLI frena en vez de operar en el entorno equivocado.
91
- - Cada key guardada recuerda el hub que la emitió y no se manda a ningún otro.
92
- - `mcp` usa OAuth por defecto: tu key no queda en el historial de la terminal ni en archivos del proyecto.
93
- - Tus keys solo viajan por https (o a un hub en tu propia máquina, para desarrollo).
94
-
95
- ## Links
96
-
97
- - Docs: https://talopartnerhub.vercel.app/docs
98
- - Para agentes: https://talopartnerhub.vercel.app/docs/agents · https://talopartnerhub.vercel.app/llms.txt
99
- - API: https://talopartnerhub.vercel.app/docs/api · OpenAPI: https://talopartnerhub.vercel.app/openapi.json
100
- - Dashboard: https://talopartnerhub.vercel.app/dashboard
101
-
102
- ---
103
-
104
- talo-hub es parte de Talo Partner Hub, una integración de un partner de Talo Pay; no es un producto oficial de Talo. Los cobros los procesa Talo en tu cuenta.
1
+ # talo-hub
2
+
3
+ **Cobrá con transferencias bancarias en Argentina, con acreditación y conciliación automáticas.** Desde la terminal, tu código o tu agente de IA, con [Talo Pay](https://talo.com.ar).
4
+
5
+ Cada cobro tiene su propio **CVU y alias**. Tu cliente transfiere desde cualquier banco o billetera, Talo lo acredita y vos sabés al instante qué pedido se pagó: sin comprobantes, sin revisar el home banking y sin conciliar a mano.
6
+
7
+ ```bash
8
+ npx talo-hub login # conectá tu cuenta Talo (sandbox, sin plata real)
9
+ npx talo-hub payments create --amount 15000 # → CVU y alias para que te transfieran
10
+ npx talo-hub sandbox pay <payment_id> # simulá la transferencia del cliente
11
+ npx talo-hub events listen # → payment.paid (muestra los últimos y espera los nuevos)
12
+ ```
13
+
14
+ Sin instalar nada: alcanza con Node 20 o más nuevo. Documentación: **https://talopartnerhub.vercel.app/docs**
15
+
16
+ ## Por qué talo-hub
17
+
18
+ - **Un CVU por cobro:** cada pago se identifica solo. Se acabó el "mandame el comprobante".
19
+ - **Confirmación automática:** te enterás cuando la plata entra, por terminal, webhook firmado o tu agente.
20
+ - **Sandbox real:** simulás transferencias exactas, de menos (UNDERPAID) o de más (OVERPAID) y probás todo el flujo sin mover plata.
21
+ - **Hecho para agentes de IA:** servidor MCP con OAuth, skills listas y docs en Markdown. Tu agente integra, prueba y lleva a producción.
22
+ - **Seguro:** keys separadas para sandbox y producción, con permisos `read`, `charge` (cobra, no reembolsa) o `full`.
23
+
24
+ ## Para agentes de IA
25
+
26
+ Conectá Claude Code, Cursor o Claude Desktop a tu cuenta Talo. Con OAuth, la key nunca pasa por el chat:
27
+
28
+ ```bash
29
+ claude mcp add --transport http talo https://talopartnerhub.vercel.app/api/mcp
30
+ # después, en Claude Code: /mcp → talo → Authenticate
31
+ ```
32
+
33
+ ```bash
34
+ npx talo-hub mcp --client cursor # o --client desktop; --with-key para usar tu key
35
+ npx talo-hub skills install # skills de Talo + el kit de tu stack, en .claude/skills
36
+ ```
37
+
38
+ El agente puede crear cobros, simular transferencias, registrar y probar su webhook, correr el checklist de producción (`check_go_live`) y leer la documentación (`read_docs`).
39
+
40
+ ### Instructions for AI agents
41
+
42
+ - Purpose: accept ARS bank-transfer payments in Argentina on the user's Talo Pay account. Each payment returns a `cvu` and `alias`; status goes `PENDING` → `SUCCESS` (or `UNDERPAID` / `OVERPAID` / `EXPIRED`).
43
+ - Connect: MCP at `https://talopartnerhub.vercel.app/api/mcp` (OAuth), or this CLI after the user runs `npx talo-hub login`. A person must authorize Talo once per environment; everything else you can do.
44
+ - Keys: `tph_test_…` sandbox (test money: test freely), `tph_live_…` production (real money: confirm refunds and amount changes with the user). Scopes `read` < `charge` < `full`; give servers `charge`. Store project keys in the server's env (`TALO_HUB_API_KEY`), never in code or chat: `keys create --env-file .env.local` or `--vercel production|preview[:<branch>]` writes it there without printing it (same flags on `webhooks add` for `TALO_HUB_WEBHOOK_SECRET`); exporting it in your own shell replaces the login key. `login --production` does not make production the default: pass `--production` or run `use production`.
45
+ - Machine-readable: every command takes `--json` (JSON on stdout, human messages on stderr) and exits non-zero on failure, `doctor` and `webhooks test` included. Errors with `--json`: `{"error":{"code","message","status"}}`, with stable codes such as `not_logged_in`, `invalid_amount`, `invalid_hub`, `env_key_mismatch`, `insufficient_scope` (plus `key_scope` and `required_scope`), `talo_reconnect_required`, `network_error`, `invalid_response`. `status` is the hub's HTTP status, or null when the CLI found the error itself or couldn't reach the hub. `events listen --json` prints one event per line; the last few existing events come first with `"replayed": true`. Docs as Markdown: https://talopartnerhub.vercel.app/llms.txt, `/docs/<page>.md`, OpenAPI at `/openapi.json`.
46
+ - Amounts: pass `--amount 15000` or `15000.50`. Don't use thousands separators unquoted: PowerShell turns `15.000,50` into `15,50`.
47
+ - Payments always include `paid` and `refunded` as numbers (0 when none) and `refunds: [{ id, status, type, amount, currency, created_at }]`. Filter events with `events list --payment <id>`.
48
+ - Test plan: `payments create --amount 15000` → `sandbox pay <id> --amount 14999` (UNDERPAID, don't fulfil) → `sandbox pay <id> --amount 1` (SUCCESS) → `webhooks add <https url>` (it first probes the URL and refuses redirects, Vercel Deployment Protection or a handler that accepts unsigned events; `--skip-check` to skip) → `webhooks test <id>` (on failure `reason` says why: `redirect`, `vercel_protection`, `timeout`, `network`, `http_error`) → `doctor` until it exits 0. Best practices per stack: `skills install` (it detects the stack).
49
+
50
+ ## Comandos
51
+
52
+ | Comando | |
53
+ |---|---|
54
+ | `login [--production]` | Conecta tu cuenta Talo desde el navegador |
55
+ | `status` | Cuenta, estado en Talo y permisos de la key |
56
+ | `doctor` | Checklist para pasar a producción, con lo que falta |
57
+ | `payments create --amount <ARS> [--external-id <id>] [--motive <texto>]` | Crea un cobro y muestra CVU y alias |
58
+ | `payments get <id>` | Estado de un cobro |
59
+ | `payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]` | Cobros de un período (30 días por defecto) |
60
+ | `payments refund <id> [--amount <ARS>]` | Reembolso total o parcial |
61
+ | `sandbox pay <id> [--amount <ARS>]` | Simula la transferencia del cliente |
62
+ | `balance`, `transactions [--from] [--to] [--limit <n>] [--cursor]` | Saldo y movimientos |
63
+ | `events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <n>]` | Eventos recientes, filtrables por cobro o tipo |
64
+ | `events listen [--forward-to <url\|puerto>]` | Recibe eventos en vivo y los reenvía a localhost sin túneles (sin firma, con `talo-hub-forwarded: 1`: solo para desarrollo) |
65
+ | `webhooks list \| add <url> \| remove <id> \| test <id>` | Webhooks firmados con HMAC-SHA256. `add` prueba antes la URL (redirecciones, Deployment Protection de Vercel) |
66
+ | `webhooks add <url> --vercel <production\|preview[:rama]>` / `--env-file <archivo>` | Guarda el secreto como `TALO_HUB_WEBHOOK_SECRET` en Vercel o en tu .env, sin mostrarlo |
67
+ | `keys list \| create [--name] [--scope read\|charge\|full] \| revoke <id>` | Keys para tus proyectos (`charge` por defecto) |
68
+ | `keys create … --vercel <production\|preview[:rama]>` / `--env-file <archivo>` | Guarda la key como `TALO_HUB_API_KEY` en Vercel (tipo Secret) o en tu .env, sin mostrarla |
69
+ | `mcp [--client claude\|cursor\|desktop] [--with-key]` | Configuración del MCP para tu agente |
70
+ | `skills install [--stack <kit>] [--global]` | Instala las skills de Talo y el kit de mejores prácticas de tu stack (Next.js + Vercel, Node, Python, Laravel, edge) |
71
+ | `skills list` | Kits por stack y cuál corresponde a tu proyecto |
72
+ | `use <sandbox\|production>` | Entorno activo de los próximos comandos |
73
+ | `logout [--production \| --all]` | Revoca la key en el hub y la borra de esta máquina |
74
+
75
+ Opciones: `--sandbox`, `--production`, `--json`, `--version`, `--hub <url>`.
76
+
77
+ Montos: `15000` o `15000.50`. También entiende `15000,50` y `15.000,50`, pero en PowerShell escribilos entre comillas (`"15.000,50"`): sin comillas, PowerShell los convierte en otro número antes de que lleguen al CLI. Fechas: `AAAA-MM-DD` o ISO 8601, en hora de Argentina.
78
+
79
+ ## De sandbox a producción
80
+
81
+ ```bash
82
+ npx talo-hub doctor # sandbox listo
83
+ npx talo-hub login --production
84
+ npx talo-hub keys create --production --name mi-tienda --scope charge --vercel production # o --env-file .env
85
+ npx talo-hub webhooks add https://tu-sitio.com/api/webhooks/talo --production --vercel production
86
+ npx talo-hub webhooks test <id> --production
87
+ npx talo-hub doctor --production
88
+ ```
89
+
90
+ ## Seguridad
91
+
92
+ - `login` muestra un código para confirmar en el navegador. La key queda en `~/.config/talo-hub/config.json` (en Windows, `%APPDATA%\talo-hub`), legible solo por tu usuario.
93
+ - `TALO_HUB_API_KEY` usa una key sin login, para CI y agentes. Si no coincide con `--sandbox` o `--production`, el CLI frena en vez de operar en el entorno equivocado.
94
+ - Cada key guardada recuerda el hub que la emitió y no se manda a ningún otro.
95
+ - `mcp` usa OAuth por defecto: tu key no queda en el historial de la terminal ni en archivos del proyecto.
96
+ - Tus keys solo viajan por https (o a un hub en tu propia máquina, para desarrollo).
97
+
98
+ ## Links
99
+
100
+ - Docs: https://talopartnerhub.vercel.app/docs
101
+ - Para agentes: https://talopartnerhub.vercel.app/docs/agents · https://talopartnerhub.vercel.app/llms.txt
102
+ - API: https://talopartnerhub.vercel.app/docs/api · OpenAPI: https://talopartnerhub.vercel.app/openapi.json
103
+ - Dashboard: https://talopartnerhub.vercel.app/dashboard
104
+
105
+ ---
106
+
107
+ talo-hub es parte de Talo Partner Hub, una integración de un partner de Talo Pay; no es un producto oficial de Talo. Los cobros los procesa Talo en tu cuenta.
package/dist/cli.js CHANGED
@@ -12,43 +12,52 @@ import { parseArgs } from "node:util";
12
12
  import { configPath, environmentOfKey, loadConfig, requestedHub, resolveKey, saveConfig, DEFAULT_HUB, } from "./config.js";
13
13
  import { Hub, HubRequestError, networkReason } from "./hub.js";
14
14
  import { parseAmount, parseCount, parseDate, parseForwardUrl } from "./parse.js";
15
+ import { probeWebhookUrl } from "./probe.js";
16
+ import { addToVercel, describeTarget, gitIgnores, parseVercelTarget, writeEnvFile } from "./secrets.js";
17
+ import { detectStacks, pickStacks, readSkillsIndex } from "./stacks.js";
15
18
  const VERSION = createRequire(import.meta.url)("../package.json").version;
16
19
  /** How to tell people to run us again: through npx when that's how they ran us. */
17
20
  const CMD = process.env.npm_command === "exec" ? "npx talo-hub" : "talo-hub";
18
21
  const jsonMode = process.argv.some((arg) => /^--json(=|$)/.test(arg));
19
- const HELP = `talo-hub ${VERSION}: transferencias bancarias automáticas en Argentina con Talo Pay.
20
-
21
- Cuenta
22
- login [--production] Conecta tu cuenta Talo (sandbox por defecto)
23
- logout [--production | --all] Revoca y borra la key guardada
24
- use <sandbox|production> Elige el entorno de los próximos comandos
25
- status Cuenta conectada, estado en Talo y permisos de la key
26
- doctor Checklist para pasar a producción
27
-
28
- Cobros
29
- payments create --amount <ARS> [--external-id <id>] [--motive <texto>]
30
- payments get <payment_id>
31
- payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]
32
- payments refund <payment_id> [--amount <ARS>] Sin --amount, reembolso total
33
- sandbox pay <payment_id> [--amount <ARS>] Simula la transferencia (sandbox)
34
- balance
35
- transactions [--from …] [--to …] [--limit <1-200>] [--cursor <next_cursor>]
36
-
37
- Eventos y webhooks
38
- events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <1-200>]
39
- Sin --after, los más recientes
40
- events listen [--forward-to <url|puerto>] Muestra los últimos y escucha los nuevos
41
- (los reenviados no van firmados: solo para desarrollo)
42
- webhooks list | add <url> | remove <id> | test <id>
43
-
44
- Keys y agentes
45
- keys list | create [--name <nombre>] [--scope read|charge|full] | revoke <id> (charge por defecto)
46
- mcp [--client claude|cursor|desktop] [--with-key] Configuración del MCP para tu agente
47
- skills install [--global] Instala las skills de Talo en .claude/skills
48
-
49
- Montos: 15000 o 15000.50 (en PowerShell, con separador de miles, entre comillas: "15.000,50").
50
- Fechas: AAAA-MM-DD o ISO 8601; sin zona horaria, hora de Argentina.
51
- Opciones: --sandbox, --production, --json, --hub <url>, --version
22
+ const HELP = `talo-hub ${VERSION}: transferencias bancarias automáticas en Argentina con Talo Pay.
23
+
24
+ Cuenta
25
+ login [--production] Conecta tu cuenta Talo (sandbox por defecto)
26
+ logout [--production | --all] Revoca y borra la key guardada
27
+ use <sandbox|production> Elige el entorno de los próximos comandos
28
+ status Cuenta conectada, estado en Talo y permisos de la key
29
+ doctor Checklist para pasar a producción
30
+
31
+ Cobros
32
+ payments create --amount <ARS> [--external-id <id>] [--motive <texto>]
33
+ payments get <payment_id>
34
+ payments list [--from AAAA-MM-DD] [--to AAAA-MM-DD]
35
+ payments refund <payment_id> [--amount <ARS>] Sin --amount, reembolso total
36
+ sandbox pay <payment_id> [--amount <ARS>] Simula la transferencia (sandbox)
37
+ balance
38
+ transactions [--from …] [--to …] [--limit <1-200>] [--cursor <next_cursor>]
39
+
40
+ Eventos y webhooks
41
+ events list [--payment <id>] [--type <tipo>] [--after <id>] [--limit <1-200>]
42
+ Sin --after, los más recientes
43
+ events listen [--forward-to <url|puerto>] Muestra los últimos y escucha los nuevos
44
+ (los reenviados no van firmados: solo para desarrollo)
45
+ webhooks list | add <url> | remove <id> | test <id>
46
+ webhooks add <url> [--vercel <entorno>] [--env-file <archivo>] [--skip-check]
47
+ Antes de registrar prueba la URL (redirecciones, Vercel)
48
+
49
+ Keys y agentes
50
+ keys list | create [--name <nombre>] [--scope read|charge|full] | revoke <id> (charge por defecto)
51
+ keys create … --vercel <production|preview[:rama]> | --env-file <archivo>
52
+ Guarda la key en Vercel o en tu .env sin mostrarla
53
+ mcp [--client claude|cursor|desktop] [--with-key] Configuración del MCP para tu agente
54
+ skills install [--stack <kit>] [--global] Instala las skills de Talo en .claude/skills, con el kit
55
+ de tu stack (lo detecta; --stack all, --stack none)
56
+ skills list Kits por stack y cuál corresponde a este proyecto
57
+
58
+ Montos: 15000 o 15000.50 (en PowerShell, con separador de miles, entre comillas: "15.000,50").
59
+ Fechas: AAAA-MM-DD o ISO 8601; sin zona horaria, hora de Argentina.
60
+ Opciones: --sandbox, --production, --json, --hub <url>, --version
52
61
  Variables: TALO_HUB_API_KEY (usa esa key en vez de la guardada), TALO_HUB_URL`;
53
62
  /** Prints the error (as JSON with --json, with a stable code) and exits 1. */
54
63
  function fail(message, code = "usage", extra = {}) {
@@ -99,6 +108,10 @@ function parseCommandLine() {
99
108
  client: { type: "string" },
100
109
  "with-key": { type: "boolean" },
101
110
  global: { type: "boolean" },
111
+ stack: { type: "string" },
112
+ vercel: { type: "string" },
113
+ "env-file": { type: "string" },
114
+ "skip-check": { type: "boolean" },
102
115
  help: { type: "boolean", short: "h" },
103
116
  version: { type: "boolean", short: "v" },
104
117
  },
@@ -135,9 +148,12 @@ const COMMAND_OPTIONS = {
135
148
  "events list": ["after", "limit", "payment", "type"],
136
149
  "events listen": ["forward-to"],
137
150
  webhooks: [],
151
+ "webhooks add": ["vercel", "env-file", "skip-check"],
138
152
  keys: ["name", "scope"],
153
+ "keys create": ["name", "scope", "vercel", "env-file"],
139
154
  mcp: ["client", "with-key"],
140
- skills: ["global"],
155
+ "skills install": ["global", "stack"],
156
+ "skills list": [],
141
157
  };
142
158
  function checkOptions(command, sub) {
143
159
  const key = COMMAND_OPTIONS[`${command} ${sub ?? "list"}`] ? `${command} ${sub ?? "list"}` : command;
@@ -423,16 +439,47 @@ async function listen(hub) {
423
439
  await sleep(2000);
424
440
  }
425
441
  }
426
- const SKILLS = ["talo-payments", "talo-go-live", "talo-operations"];
427
- /** Downloads the skills into ./.claude/skills (or ~/.claude/skills with --global). */
442
+ /** The hub's list of skills: the core ones, and the stack kits with how to recognize each stack. */
443
+ async function fetchSkillsIndex(hubUrl) {
444
+ let status = 0;
445
+ let body;
446
+ try {
447
+ const response = await fetch(`${hubUrl}/skills/index.json`, { signal: AbortSignal.timeout(30_000) });
448
+ status = response.status;
449
+ if (response.ok)
450
+ body = await response.json().catch(() => undefined);
451
+ }
452
+ catch (error) {
453
+ fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
454
+ }
455
+ const skills = readSkillsIndex(body);
456
+ if (!skills?.length)
457
+ fail(`No pudimos leer la lista de skills del hub (HTTP ${status}).`, "download_failed");
458
+ return skills;
459
+ }
460
+ const kitIds = (kits) => kits.map((kit) => kit.stack).join(", ");
461
+ /**
462
+ * Downloads the core skills and the stack kits into ./.claude/skills (or ~/.claude/skills with
463
+ * --global): the kits that match this project, the ones --stack names, or all of them for --global.
464
+ */
428
465
  async function installSkills(hubUrl) {
466
+ const index = await fetchSkillsIndex(hubUrl);
467
+ const kits = index.filter((skill) => skill.kind === "stack");
468
+ const detected = flags.global ? [] : await detectStacks(process.cwd(), kits);
469
+ let chosen = flags.global ? kits : detected;
470
+ if (flags.stack !== undefined) {
471
+ const picked = pickStacks(kits, flags.stack);
472
+ if (picked.unknown.length)
473
+ fail(`No hay kit para ${picked.unknown.join(", ")}. Kits: ${kitIds(kits)} (o all, none).`, "unknown_stack");
474
+ chosen = picked.kits;
475
+ }
429
476
  const root = join(flags.global ? homedir() : process.cwd(), ".claude", "skills");
430
477
  const installed = [];
431
- for (const skill of SKILLS) {
478
+ for (const skill of [...index.filter((entry) => entry.kind === "core"), ...chosen]) {
432
479
  let text = "";
433
480
  let status = 0;
434
481
  try {
435
- const response = await fetch(`${hubUrl}/skills/${skill}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
482
+ const response = await fetch(`${hubUrl}/skills/${skill.slug}/SKILL.md`, { signal: AbortSignal.timeout(30_000) });
436
483
  status = response.status;
437
484
  if (response.ok)
438
485
  text = await response.text();
@@ -440,15 +487,75 @@ async function installSkills(hubUrl) {
440
487
  catch (error) {
441
488
  fail(`No pudimos conectar con ${hubUrl} (${networkReason(error)}).`, "network_error");
442
489
  }
443
- if (!text.startsWith("---") || !text.includes(`name: ${skill}`))
444
- fail(`No pudimos descargar la skill ${skill} (HTTP ${status}).`, "download_failed");
445
- const path = join(root, skill, "SKILL.md");
446
- await mkdir(join(root, skill), { recursive: true });
490
+ if (!text.startsWith("---") || !text.includes(`name: ${skill.slug}`))
491
+ fail(`No pudimos descargar la skill ${skill.slug} (HTTP ${status}).`, "download_failed");
492
+ const path = join(root, skill.slug, "SKILL.md");
493
+ await mkdir(join(root, skill.slug), { recursive: true });
447
494
  await writeFile(path, text);
448
495
  installed.push(path);
449
496
  say(`✓ ${path}`);
450
497
  }
451
- emit({ installed }, () => console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`));
498
+ emit({ installed, stacks: chosen.map((kit) => kit.stack), detected: detected.map((kit) => kit.stack) }, () => {
499
+ if (chosen.length)
500
+ console.log(`Kits por stack: ${chosen.map((kit) => kit.label).join(", ")}.`);
501
+ else if (flags.stack === undefined)
502
+ console.log(`No reconocí el stack de este proyecto. Elegí un kit con ${CMD} skills install --stack <${kitIds(kits)}>.`);
503
+ console.log(`Tu agente ya puede integrar y operar Talo. Sumale el MCP: ${CMD} mcp`);
504
+ });
505
+ }
506
+ /** The stack kits, marking the ones that match this project. */
507
+ async function listSkills(hubUrl) {
508
+ const index = await fetchSkillsIndex(hubUrl);
509
+ const kits = index.filter((skill) => skill.kind === "stack");
510
+ const detected = new Set((await detectStacks(process.cwd(), kits)).map((kit) => kit.slug));
511
+ emit({ skills: index.map((skill) => ({ ...skill, ...(skill.kind === "stack" ? { detected: detected.has(skill.slug) } : {}) })) }, () => {
512
+ console.log(`Skills base: ${index.filter((skill) => skill.kind === "core").map((skill) => skill.slug).join(", ")}`);
513
+ console.log("\nKits por stack (--stack <kit>):");
514
+ for (const kit of kits)
515
+ console.log(` ${kit.stack.padEnd(16)}${kit.label}${detected.has(kit.slug) ? " ← este proyecto" : ""}`);
516
+ console.log(`\nInstalar: ${CMD} skills install. Otro stack: ${DEFAULT_HUB}/docs/stacks.md`);
517
+ });
518
+ }
519
+ /** Where --vercel and --env-file ask a new secret to go, checked before anything is created. */
520
+ function secretDestinations() {
521
+ let vercel;
522
+ if (flags.vercel !== undefined) {
523
+ try {
524
+ vercel = parseVercelTarget(flags.vercel);
525
+ }
526
+ catch (error) {
527
+ fail(error.message, "invalid_option");
528
+ }
529
+ }
530
+ if (flags["env-file"] !== undefined && !flags["env-file"].trim())
531
+ fail("--env-file necesita la ruta del archivo, por ejemplo .env.local.", "invalid_option");
532
+ return { vercel, envFile: flags["env-file"] };
533
+ }
534
+ /**
535
+ * Writes the secret to each destination. Returns where it went, or Vercel's or the file system's
536
+ * error (the caller then undoes what it created, so no secret is left that nobody has).
537
+ */
538
+ async function storeSecret(name, value, to) {
539
+ const saved = [];
540
+ if (to.envFile) {
541
+ try {
542
+ await writeEnvFile(to.envFile, name, value);
543
+ }
544
+ catch (error) {
545
+ return { saved, error: `No pudimos escribir ${to.envFile}: ${error.message}` };
546
+ }
547
+ saved.push(to.envFile);
548
+ if (gitIgnores(to.envFile) === false)
549
+ say(`! git no ignora ${to.envFile}: agregá .env* a .gitignore antes de commitear.`);
550
+ }
551
+ if (to.vercel) {
552
+ say(`… Guardando ${name} en Vercel (${to.vercel.environment}${to.vercel.branch ? `, rama ${to.vercel.branch}` : ""})`);
553
+ const error = addToVercel(name, value, to.vercel);
554
+ if (error)
555
+ return { saved, error: `Vercel no la guardó:\n${error}\n¿Corriste vercel link en este proyecto?` };
556
+ saved.push(describeTarget(to.vercel));
557
+ }
558
+ return { saved };
452
559
  }
453
560
  /** Which shell will run a printed command: PowerShell spells environment variables differently. */
454
561
  function shellSyntax() {
@@ -524,8 +631,8 @@ async function main() {
524
631
  if (command === "login")
525
632
  return login(config);
526
633
  if (command === "skills") {
527
- if (sub !== "install")
528
- fail(`Usá: ${CMD} skills install [--global]`);
634
+ if (sub !== undefined && sub !== "install" && sub !== "list")
635
+ fail(`Usá: ${CMD} skills install [--stack <kit>] [--global] | skills list`);
529
636
  let hubUrl;
530
637
  try {
531
638
  hubUrl = requestedHub(flags.hub) ?? DEFAULT_HUB;
@@ -533,7 +640,7 @@ async function main() {
533
640
  catch (error) {
534
641
  fail(error.message, "invalid_hub");
535
642
  }
536
- return installSkills(hubUrl);
643
+ return sub === "install" ? installSkills(hubUrl) : listSkills(hubUrl);
537
644
  }
538
645
  if (command === "logout") {
539
646
  const environments = flags.all ? ["sandbox", "production"] : [chosenEnvironment(config)];
@@ -745,7 +852,25 @@ async function run(hub, ctx) {
745
852
  if (!arg)
746
853
  fail(`Usá: ${CMD} webhooks ${sub === "add" ? "add <url>" : `${sub} <id>`}`);
747
854
  if (sub === "add") {
855
+ const destinations = secretDestinations();
856
+ if (!flags["skip-check"]) {
857
+ const probe = await probeWebhookUrl(arg);
858
+ const shown = arg.replace(/(x-vercel-protection-bypass=)[^&#]+/, "$1redacted");
859
+ if (probe.verdict !== "ok")
860
+ fail(`${shown}: ${probe.message}\nSi ya lo resolviste, repetí con --skip-check.`, `webhook_url_${probe.verdict}`, { status: probe.status ?? null });
861
+ say(`✓ ${probe.message}`);
862
+ }
748
863
  const endpoint = await hub.post("/api/v1/webhook-endpoints", { url: arg });
864
+ if (destinations.vercel || destinations.envFile) {
865
+ const stored = await storeSecret("TALO_HUB_WEBHOOK_SECRET", endpoint.secret, destinations);
866
+ if (stored.error) {
867
+ await hub.request("DELETE", `/api/v1/webhook-endpoints/${encodeURIComponent(endpoint.id)}`).catch(() => undefined);
868
+ fail(`${stored.error}\nBorramos el webhook que acabábamos de registrar.`, "secret_not_saved");
869
+ }
870
+ const { secret: _secret, ...shown } = endpoint;
871
+ return emit({ ...shown, secret_saved_to: stored.saved }, () => console.log(`✓ Webhook ${endpoint.id}: secreto guardado como TALO_HUB_WEBHOOK_SECRET en ${stored.saved.join(" y ")} (no se muestra).` +
872
+ (destinations.vercel ? `\n Redesplegá para que lo tome, y probalo con ${CMD} webhooks test ${endpoint.id}.` : "")));
873
+ }
749
874
  return emit(endpoint, () => console.log(`✓ Webhook ${endpoint.id}\n Secreto para verificar la firma (guardalo en el .env de tu servidor como TALO_HUB_WEBHOOK_SECRET; no se vuelve a mostrar):\n ${endpoint.secret}`));
750
875
  }
751
876
  if (sub === "remove")
@@ -755,7 +880,7 @@ async function run(hub, ctx) {
755
880
  process.exitCode = 1;
756
881
  return emit(result, () => console.log(result.delivered
757
882
  ? `✓ ${result.url} recibió el evento de prueba (HTTP ${result.status_code}).`
758
- : `✗ ${result.url} no lo aceptó: ${result.error ?? `HTTP ${result.status_code}`}. Revisá que verifique la firma y responda 2xx.`));
883
+ : `✗ ${result.url} no lo aceptó: ${result.error ?? `HTTP ${result.status_code}`}.${result.reason && result.reason !== "http_error" ? "" : " Revisá que verifique la firma y responda 2xx."}`));
759
884
  }
760
885
  case "keys": {
761
886
  if (sub === undefined || sub === "list")
@@ -763,10 +888,27 @@ async function run(hub, ctx) {
763
888
  if (sub === "create") {
764
889
  if (flags.scope && !["read", "charge", "full"].includes(flags.scope))
765
890
  fail("--scope tiene que ser read, charge o full.", "invalid_option");
891
+ const destinations = secretDestinations();
766
892
  const created = await hub.post("/api/v1/api-keys", {
767
893
  name: flags.name ?? "CLI",
768
894
  scope: flags.scope ?? "charge",
769
895
  });
896
+ if (destinations.vercel || destinations.envFile) {
897
+ const revoke = () => hub.request("DELETE", `/api/v1/api-keys/${encodeURIComponent(created.id)}`).catch(() => undefined);
898
+ if (created.environment === "production" && destinations.vercel && destinations.vercel.environment !== "production") {
899
+ await revoke();
900
+ fail("Una key de producción (plata real) va solo en el entorno production de Vercel. La revocamos; para preview creá una de sandbox.", "live_key_outside_production");
901
+ }
902
+ const stored = await storeSecret("TALO_HUB_API_KEY", created.api_key, destinations);
903
+ if (stored.error) {
904
+ await revoke();
905
+ fail(`${stored.error}\nRevocamos la key que acabábamos de crear.`, "secret_not_saved");
906
+ }
907
+ const { api_key, ...shown } = created;
908
+ const prefix = `${api_key.slice(0, 13)}…`;
909
+ return emit({ ...shown, prefix, saved_to: stored.saved }, () => console.log(`✓ Key ${prefix} (${created.environment} · scope ${created.scope}) guardada como TALO_HUB_API_KEY en ${stored.saved.join(" y ")}. No se muestra.` +
910
+ (destinations.vercel ? "\n Redesplegá para que la tome." : "")));
911
+ }
770
912
  return emit(created, () => console.log(`✓ ${created.api_key}\n ${created.environment} · scope ${created.scope}. Guardala ahora en el .env o los secretos de tu servidor (TALO_HUB_API_KEY): no se vuelve a mostrar.\n En esta terminal, exportarla reemplaza a la key de tu login.`));
771
913
  }
772
914
  if (sub === "revoke") {
package/dist/probe.js ADDED
@@ -0,0 +1,59 @@
1
+ // Before registering a webhook URL, one unsigned POST tells whether the hub's signed deliveries
2
+ // would reach the handler: the hub doesn't follow redirects and sends no login, and a correct
3
+ // handler rejects an unsigned body with 400.
4
+ const VERCEL_LOGIN = /^https:\/\/vercel\.com\/sso-api/;
5
+ export async function probeWebhookUrl(url, fetchImpl = fetch) {
6
+ let response;
7
+ try {
8
+ response = await fetchImpl(url, {
9
+ method: "POST",
10
+ headers: { "content-type": "application/json", "user-agent": "TaloPartnerHub-Webhooks/1" },
11
+ body: "{}",
12
+ redirect: "manual",
13
+ signal: AbortSignal.timeout(10_000),
14
+ });
15
+ }
16
+ catch (error) {
17
+ const reason = error.cause?.code ?? error.name;
18
+ return { verdict: "unreachable", message: `No pudimos llegar a la URL (${reason}). ¿Está desplegada y es pública?` };
19
+ }
20
+ const status = response.status;
21
+ const location = response.headers.get("location") ?? "";
22
+ const body = status === 401 || status === 403 ? (await response.text().catch(() => "")).slice(0, 2000) : "";
23
+ const vercelLogin = VERCEL_LOGIN.test(location) || /_vercel_sso_nonce=/.test(response.headers.get("set-cookie") ?? "") || /Protected deployment|Protected by Vercel/.test(body);
24
+ if (vercelLogin) {
25
+ return {
26
+ verdict: "vercel_protection",
27
+ status,
28
+ message: "La Deployment Protection de Vercel la bloquea antes de tu código (las URLs *.vercel.app de cada deploy y de cada rama están protegidas). Usá el dominio de producción, o abrí esa URL a propósito (Deployment Protection Exception o ?x-vercel-protection-bypass=<secreto>).",
29
+ };
30
+ }
31
+ if (status >= 300 && status < 400) {
32
+ return { verdict: "redirect", status, message: `Redirige${location ? ` a ${location}` : ""}. El hub no sigue redirecciones: registrá la URL final (sin barra al final, el dominio principal).` };
33
+ }
34
+ if (response.headers.get("x-vercel-mitigated")) {
35
+ return { verdict: "vercel_firewall", status, message: "El Firewall de Vercel desafió el pedido. Agregá una regla Bypass para POST a esta ruta antes de los managed rulesets." };
36
+ }
37
+ if (status === 419) {
38
+ return { verdict: "csrf", status, message: "Responde 419: la ruta está bajo el CSRF de Laravel. Ponela en routes/api.php o excluila en bootstrap/app.php." };
39
+ }
40
+ if (status === 401 || status === 403) {
41
+ return {
42
+ verdict: "auth",
43
+ status,
44
+ message: `Responde ${status} antes de verificar la firma: algo delante de la ruta pide login o un token CSRF (middleware, proxy de auth, CsrfViewMiddleware de Django). Excluí la ruta del webhook.`,
45
+ };
46
+ }
47
+ if (status === 404 || status === 405) {
48
+ return { verdict: "not_found", status, message: `Responde ${status}: no hay una ruta POST ahí. Revisá la URL o desplegá el handler primero.` };
49
+ }
50
+ if (status === 429) {
51
+ return { verdict: "vercel_firewall", status, message: "Responde 429: un rate limit o el firewall frena el pedido. Excluí la ruta del webhook." };
52
+ }
53
+ if (status >= 200 && status < 300) {
54
+ return { verdict: "unsigned_accepted", status, message: `Aceptó un evento sin firma (HTTP ${status}). El handler tiene que verificar talo-hub-signature y responder 400 si no es válida.` };
55
+ }
56
+ // 400 is the expected answer; a 5xx usually means TALO_HUB_WEBHOOK_SECRET isn't set yet, which is
57
+ // normal before registering (the secret comes from this command).
58
+ return { verdict: "ok", status, message: status === 400 ? "Rechaza eventos sin firma (HTTP 400), como corresponde." : `Respondió HTTP ${status}.` };
59
+ }
@@ -0,0 +1,67 @@
1
+ // Puts a new secret (a project key or a webhook secret) where the project reads it, without
2
+ // printing it: into a dotenv file, or into a Vercel environment with `vercel env add`, the value
3
+ // going through stdin. Piping into `vercel env add` from a shell can store an empty value (it reads
4
+ // stdin for only 500 ms) or a BOM (Windows PowerShell), so we write the pipe ourselves.
5
+ import { spawnSync } from "node:child_process";
6
+ import { chmod, readFile, writeFile } from "node:fs/promises";
7
+ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,249}$/;
8
+ /** `production`, `preview` or `preview:<git branch>`. */
9
+ export function parseVercelTarget(value) {
10
+ const [environment, branch, ...rest] = value.trim().split(":");
11
+ if (environment === "development") {
12
+ throw new Error("Para desarrollo usá --env-file .env.local: Vercel no deja bajar los valores secretos a tu máquina.");
13
+ }
14
+ if ((environment !== "production" && environment !== "preview") || rest.length) {
15
+ throw new Error(`--vercel tiene que ser production, preview o preview:<rama> (recibimos "${value}").`);
16
+ }
17
+ if (branch !== undefined && (environment !== "preview" || !BRANCH.test(branch))) {
18
+ throw new Error(`La rama va solo con preview y sin espacios ni comillas: --vercel preview:<rama> (recibimos "${value}").`);
19
+ }
20
+ return branch ? { environment, branch } : { environment };
21
+ }
22
+ export const describeTarget = (target) => `vercel:${target.environment}${target.branch ? `:${target.branch}` : ""}`;
23
+ function run(command, args, input) {
24
+ // Through the shell so `vercel` (vercel.cmd on Windows) resolves; every argument is validated
25
+ // above and the secret only travels on stdin.
26
+ return spawnSync([command, ...args].join(" "), { input, encoding: "utf8", shell: true, timeout: 180_000 });
27
+ }
28
+ /** The Vercel CLI on the PATH, or npx's (TALO_HUB_VERCEL_CLI overrides both, for tests). */
29
+ function vercelCommand() {
30
+ if (process.env.TALO_HUB_VERCEL_CLI)
31
+ return process.env.TALO_HUB_VERCEL_CLI;
32
+ return run("vercel", ["--version"]).status === 0 ? "vercel" : "npx --yes vercel";
33
+ }
34
+ /** Stores `value` as a Secret env var of the linked Vercel project. Returns Vercel's error when it fails. */
35
+ export function addToVercel(name, value, target) {
36
+ const args = ["env", "add", name, target.environment, ...(target.branch ? [target.branch] : []), "--sensitive", "--force", "--yes"];
37
+ const result = run(vercelCommand(), args, value);
38
+ if (result.status === 0)
39
+ return undefined;
40
+ const output = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.replaceAll(value, "…").trim();
41
+ return output.split("\n").filter(Boolean).slice(-4).join("\n") || (result.error?.message ?? `vercel terminó con código ${result.status}`);
42
+ }
43
+ /** Sets NAME=value in a dotenv file (replacing the line if there is one), readable only by the user. */
44
+ export async function writeEnvFile(path, name, value) {
45
+ let text = "";
46
+ try {
47
+ text = await readFile(path, "utf8");
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "ENOENT")
51
+ throw error;
52
+ }
53
+ const line = `${name}=${value}`;
54
+ const existing = new RegExp(`^(export\\s+)?${name}=.*$`, "m");
55
+ const next = existing.test(text) ? text.replace(existing, () => line) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${line}\n`;
56
+ await writeFile(path, next, { mode: 0o600 });
57
+ await chmod(path, 0o600).catch(() => undefined);
58
+ }
59
+ /** Whether git ignores the file: false means it could end up committed; undefined, not a git repo. */
60
+ export function gitIgnores(path) {
61
+ const result = spawnSync("git", ["check-ignore", "-q", path], { encoding: "utf8" });
62
+ if (result.status === 0)
63
+ return true;
64
+ if (result.status === 1)
65
+ return false;
66
+ return undefined;
67
+ }
package/dist/stacks.js ADDED
@@ -0,0 +1,97 @@
1
+ // Which stack kits fit a project. The hub's /skills/index.json says how to recognize each stack
2
+ // (dependency names, or files at the project root); we look for them in the current folder.
3
+ import { access, readdir, readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
6
+ const strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
7
+ /** Keeps the index entries we can trust: the slug becomes a folder name, so it must be plain. */
8
+ export function readSkillsIndex(body) {
9
+ const skills = body?.skills;
10
+ if (!Array.isArray(skills))
11
+ return undefined;
12
+ return skills.flatMap((raw) => {
13
+ const entry = raw;
14
+ if (typeof entry.slug !== "string" || !SLUG.test(entry.slug) || (entry.kind !== "core" && entry.kind !== "stack"))
15
+ return [];
16
+ if (entry.kind === "core")
17
+ return [{ slug: entry.slug, kind: "core" }];
18
+ if (typeof entry.stack !== "string" || !SLUG.test(entry.stack))
19
+ return [];
20
+ const detect = (entry.detect ?? {});
21
+ return [
22
+ {
23
+ slug: entry.slug,
24
+ kind: "stack",
25
+ stack: entry.stack,
26
+ label: typeof entry.label === "string" ? entry.label : entry.stack,
27
+ detect: { npm: strings(detect.npm), composer: strings(detect.composer), python: strings(detect.python), files: strings(detect.files) },
28
+ },
29
+ ];
30
+ });
31
+ }
32
+ async function text(path) {
33
+ try {
34
+ return await readFile(path, "utf8");
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ async function exists(path) {
41
+ try {
42
+ await access(path);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ function dependencyNames(source, sections) {
50
+ if (!source)
51
+ return new Set();
52
+ try {
53
+ const manifest = JSON.parse(source);
54
+ return new Set(sections.flatMap((section) => Object.keys(manifest[section] ?? {})));
55
+ }
56
+ catch {
57
+ return new Set();
58
+ }
59
+ }
60
+ /** A Python requirement as written in requirements.txt (`fastapi==…`), pyproject.toml (`"fastapi>=…"`, `fastapi = "^…"`) or a Pipfile. */
61
+ function mentionsPythonPackage(sources, name) {
62
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
63
+ return new RegExp(`(^|[\\s"'\\[,])${escaped}(?=[\\s"'<>=!~\\[;,]|$)`, "im").test(sources);
64
+ }
65
+ /** The stack kits whose dependencies or files appear at the root of `dir`. */
66
+ export async function detectStacks(dir, kits) {
67
+ const npm = dependencyNames(await text(join(dir, "package.json")), ["dependencies", "devDependencies"]);
68
+ const composer = dependencyNames(await text(join(dir, "composer.json")), ["require", "require-dev"]);
69
+ const entries = await readdir(dir).catch(() => []);
70
+ const pythonFiles = entries.filter((name) => /^requirements.*\.txt$/i.test(name) || name === "pyproject.toml" || name === "Pipfile");
71
+ const python = (await Promise.all(pythonFiles.map((name) => text(join(dir, name))))).join("\n");
72
+ const matched = [];
73
+ for (const kit of kits) {
74
+ const detect = kit.detect ?? {};
75
+ const found = (detect.npm ?? []).some((name) => npm.has(name)) ||
76
+ (detect.composer ?? []).some((name) => composer.has(name)) ||
77
+ (detect.python ?? []).some((name) => mentionsPythonPackage(python, name)) ||
78
+ (await Promise.all((detect.files ?? []).map((file) => exists(join(dir, file))))).some(Boolean);
79
+ if (found)
80
+ matched.push(kit);
81
+ }
82
+ return matched;
83
+ }
84
+ /** Resolves `--stack nextjs-vercel,python` (ids or full slugs), `all` or `none`. */
85
+ export function pickStacks(kits, asked) {
86
+ const wanted = asked
87
+ .split(",")
88
+ .map((item) => item.trim().toLowerCase())
89
+ .filter(Boolean);
90
+ if (wanted.includes("none"))
91
+ return { kits: [], unknown: [] };
92
+ if (wanted.includes("all"))
93
+ return { kits, unknown: [] };
94
+ const chosen = kits.filter((kit) => wanted.includes(kit.stack) || wanted.includes(kit.slug));
95
+ const unknown = wanted.filter((item) => !kits.some((kit) => kit.stack === item || kit.slug === item));
96
+ return { kits: chosen, unknown };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talo-hub",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Transferencias bancarias automáticas en Argentina con Talo Pay: un CVU por cobro, acreditación y conciliación automáticas, webhooks firmados, sandbox y MCP para agentes de IA (Claude, Cursor).",
5
5
  "keywords": [
6
6
  "talo",