citaspro-cli 1.0.2

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 (3) hide show
  1. package/README.md +68 -0
  2. package/citasproctl.js +1021 -0
  3. package/package.json +23 -0
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # citaspro-cli
2
+
3
+ CLI para gestionar leads, reservas y calendario de CitasPro.
4
+
5
+ ## Instalacion
6
+
7
+ ```bash
8
+ npm install -g citaspro-cli
9
+ ```
10
+
11
+ ## Inicio rapido
12
+
13
+ ```bash
14
+ citaspro-cli login
15
+ citaspro-cli whoami
16
+ citaspro-cli leads:list
17
+ citaspro-cli bookings:list
18
+ ```
19
+
20
+ ## Autenticacion
21
+
22
+ ```bash
23
+ citaspro-cli login
24
+ citaspro-cli whoami
25
+ citaspro-cli logout
26
+ ```
27
+
28
+ ## Comandos principales
29
+
30
+ ### Leads
31
+
32
+ ```bash
33
+ citaspro-cli leads:list
34
+ citaspro-cli leads:get <lead_id|email|telefono>
35
+ citaspro-cli leads:create '{"nombre":"Juan","email":"juan@demo.com","telefono":"+34612345678"}'
36
+ ```
37
+
38
+ ### Reservas
39
+
40
+ ```bash
41
+ citaspro-cli bookings:list
42
+ citaspro-cli tools:availability 2026-02-20 10:00 10:30
43
+ citaspro-cli tools:create-booking '{"fecha":"2026-02-20","hora_inicio":"10:00","hora_fin":"10:30","contacto_nombre":"Juan","contacto_email":"juan@demo.com"}'
44
+ citaspro-cli bookings:reschedule <booking_id> 2026-02-21 11:00 11:30
45
+ citaspro-cli tools:cancel-booking <booking_id>
46
+ ```
47
+
48
+ ### Calendario y llamadas
49
+
50
+ ```bash
51
+ citaspro-cli tools:caldav
52
+ citaspro-cli tools:call +34612345678 "Recordatorio de cita"
53
+ ```
54
+
55
+ ## Modo agente / automatizacion
56
+
57
+ ```bash
58
+ citaspro-cli ai:manifest
59
+ citaspro-cli whoami --json
60
+ citaspro-cli leads:list 20 --json
61
+ citaspro-cli bookings:list 20 --json
62
+ ```
63
+
64
+ ## Ayuda
65
+
66
+ ```bash
67
+ citaspro-cli --help
68
+ ```
package/citasproctl.js ADDED
@@ -0,0 +1,1021 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ require('dotenv').config();
4
+ } catch (_error) {
5
+ // dotenv es opcional para el CLI
6
+ }
7
+ const axios = require('axios');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { exec, execSync } = require('child_process');
12
+
13
+ function isEnabled(value) {
14
+ return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
15
+ }
16
+
17
+ function normalizeBaseUrl(url) {
18
+ return String(url || '').replace(/\/+$/, '');
19
+ }
20
+
21
+ function resolveApiBase() {
22
+ if (process.env.CITAS_API_URL) {
23
+ return normalizeBaseUrl(process.env.CITAS_API_URL);
24
+ }
25
+
26
+ const useLocalhost = isEnabled(process.env.CITAS_USE_LOCALHOST) || isEnabled(process.env.CITAS_CLI_USE_LOCALHOST);
27
+ if (useLocalhost) {
28
+ const localPort = process.env.CITAS_LOCAL_PORT || process.env.PORT || '3000';
29
+ return `http://localhost:${localPort}`;
30
+ }
31
+
32
+ return 'https://citaspro.cloud';
33
+ }
34
+
35
+ const API_BASE = resolveApiBase();
36
+ const AUTH_FILE = process.env.CITAS_CLI_AUTH_FILE || path.join(os.homedir(), '.citas-cli-auth.json');
37
+
38
+ const USE_COLOR = Boolean(process.stdout.isTTY) && process.env.NO_COLOR !== '1';
39
+ const ANSI = {
40
+ reset: '\x1b[0m',
41
+ bold: '\x1b[1m',
42
+ dim: '\x1b[2m',
43
+ red: '\x1b[31m',
44
+ green: '\x1b[32m',
45
+ yellow: '\x1b[33m',
46
+ cyan: '\x1b[36m',
47
+ gray: '\x1b[90m',
48
+ };
49
+
50
+ function colorize(text, color) {
51
+ if (!USE_COLOR) {
52
+ return text;
53
+ }
54
+ return `${ANSI[color] || ''}${text}${ANSI.reset}`;
55
+ }
56
+
57
+ function bold(text) {
58
+ return colorize(text, 'bold');
59
+ }
60
+
61
+ function usage() {
62
+ console.log(`
63
+ CitasPro CLI
64
+ ============
65
+
66
+ ${bold('USO:')}
67
+ node tools/citas-cli.js <comando> [argumentos] [--json]
68
+ node tools/citas-cli.js --openclaw [nombre_agente]
69
+ node tools/citas-cli.js --openclaw [nombre_agente] --whatsapp
70
+
71
+ ${bold('ENTORNO API:')}
72
+ Default: https://citaspro.cloud
73
+ Override directo: CITAS_API_URL=https://mi-api
74
+ Modo local: CITAS_USE_LOCALHOST=1
75
+ Puerto local opcional: CITAS_LOCAL_PORT=3000
76
+ (alias modo local): CITAS_CLI_USE_LOCALHOST=1
77
+
78
+ ${bold('INICIO RAPIDO (HUMANO):')}
79
+ node tools/citas-cli.js login
80
+ node tools/citas-cli.js whoami
81
+ node tools/citas-cli.js leads:list
82
+ node tools/citas-cli.js bookings:list
83
+
84
+ ${bold('MODO IA / AGENTE:')}
85
+ ${colorize('--json', 'cyan')} Fuerza salida JSON estable
86
+ ${colorize('--openclaw', 'cyan')} [nombre_agente] Crea subagente OpenClaw en ~/clawd
87
+ ${colorize('--whatsapp', 'cyan')} Configura login del canal WhatsApp
88
+ ${colorize('ai:manifest', 'cyan')} Describe comandos, argumentos y salida
89
+ node tools/citas-cli.js whoami --json
90
+
91
+ ${bold('AUTENTICACION:')}
92
+ ${colorize('login', 'cyan')} OAuth Device Flow (abre navegador y espera)
93
+ ${colorize('logout', 'cyan')} Elimina sesión local
94
+ ${colorize('whoami', 'cyan')} Muestra usuario, tenant y tipo auth
95
+
96
+ ${bold('LEADS:')}
97
+ ${colorize('leads:list', 'cyan')} [limit] Lista leads (default: 20)
98
+ ${colorize('leads:get', 'cyan')} <id|email|phone> Obtiene un lead
99
+ ${colorize('leads:create', 'cyan')} '<json>' Crea lead (nombre + email obligatorios)
100
+
101
+ ${bold('RESERVAS:')}
102
+ ${colorize('bookings:list', 'cyan')} [limit] Lista reservas
103
+ ${colorize('tools:availability', 'cyan')} <fecha> <hora_inicio> <hora_fin>
104
+ Valida disponibilidad
105
+ ${colorize('tools:create-booking', 'cyan')} '<json>' Crea reserva
106
+ ${colorize('bookings:reschedule', 'cyan')} <id> <fecha> <hora_inicio> <hora_fin>
107
+ Reprograma reserva
108
+ ${colorize('tools:cancel-booking', 'cyan')} <id> Cancela reserva
109
+
110
+ ${bold('CALENDARIO / LLAMADAS:')}
111
+ ${colorize('tools:caldav', 'cyan')} Devuelve enlace CalDAV
112
+ ${colorize('tools:call', 'cyan')} <to> [message] Llamada (real o simulada)
113
+
114
+ ${bold('DEVICE FLOW (MANUAL):')}
115
+ ${colorize('device:start', 'cyan')} Genera device_code + user_code
116
+ ${colorize('device:poll', 'cyan')} <device_code> Intercambia device_code por token
117
+
118
+ ${bold('ALIASES (cortos):')}
119
+ leads ls [limit]
120
+ leads get <id|email|phone>
121
+ leads add '<json>'
122
+ bookings ls [limit]
123
+ bookings mv <id> <fecha> <hora_inicio> <hora_fin>
124
+ bookings rm <id>
125
+ calendar avail <fecha> <hora_inicio> <hora_fin>
126
+ calendar caldav
127
+ call <to> [message]
128
+
129
+ ${bold('EJEMPLOS:')}
130
+ node tools/citas-cli.js --openclaw ventas-bot
131
+ node tools/citas-cli.js --openclaw ventas-bot --whatsapp
132
+ node tools/citas-cli.js leads:create '{"nombre":"Juan","email":"juan@demo.com","telefono":"+34612345678"}'
133
+ node tools/citas-cli.js tools:create-booking '{"fecha":"2026-02-20","hora_inicio":"10:00","hora_fin":"10:30","contacto_nombre":"Juan","contacto_email":"juan@demo.com"}'
134
+ node tools/citas-cli.js bookings:list 20 --json
135
+ `);
136
+ }
137
+
138
+ const AI_MANIFEST = {
139
+ name: 'citaspro-cli',
140
+ version: '1.0.0',
141
+ base_url: API_BASE,
142
+ auth: {
143
+ mode: 'device_flow_local_session',
144
+ login_command: 'node tools/citas-cli.js login',
145
+ },
146
+ global_flags: ['--json', '--openclaw', '--whatsapp'],
147
+ commands: [
148
+ { id: 'whoami', args: [], returns: ['user', 'tenant_id', 'auth_type'] },
149
+ { id: 'leads:list', args: ['limit?'], returns: ['count', 'leads[]'] },
150
+ { id: 'leads:get', args: ['id|email|phone'], returns: ['lead'] },
151
+ { id: 'leads:create', args: ['json_payload'], returns: ['lead'] },
152
+ { id: 'bookings:list', args: ['limit?'], returns: ['count', 'bookings[]'] },
153
+ { id: 'tools:availability', args: ['fecha', 'hora_inicio', 'hora_fin'], returns: ['available', 'reasons', 'conflicts[]'] },
154
+ { id: 'tools:create-booking', args: ['json_payload'], returns: ['booking'] },
155
+ { id: 'bookings:reschedule', args: ['booking_id', 'fecha', 'hora_inicio', 'hora_fin'], returns: ['booking'] },
156
+ { id: 'tools:cancel-booking', args: ['booking_id'], returns: ['booking'] },
157
+ { id: 'tools:caldav', args: [], returns: ['caldav_url', 'radicale_username', 'calendar_id'] },
158
+ { id: 'tools:call', args: ['to', 'message?'], returns: ['status', 'provider', 'to'] },
159
+ { id: 'openclaw:init', args: ['agent_name?'], returns: ['output_dir', 'files[]', 'prompt_preview'] },
160
+ ],
161
+ };
162
+
163
+ function looksLikeToken(value) {
164
+ if (typeof value !== 'string') {
165
+ return false;
166
+ }
167
+ return value.startsWith('cli_') || value.startsWith('rcli_') || value.split('.').length === 3;
168
+ }
169
+
170
+ function looksLikePayload(value) {
171
+ if (typeof value !== 'string') {
172
+ return false;
173
+ }
174
+ const trimmed = value.trim();
175
+ return trimmed.startsWith('{') || trimmed.startsWith('[');
176
+ }
177
+
178
+ function readSession() {
179
+ try {
180
+ if (!fs.existsSync(AUTH_FILE)) {
181
+ return null;
182
+ }
183
+
184
+ const raw = fs.readFileSync(AUTH_FILE, 'utf8');
185
+ const parsed = JSON.parse(raw);
186
+
187
+ if (!parsed?.access_token) {
188
+ return null;
189
+ }
190
+
191
+ return parsed;
192
+ } catch (_error) {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ function writeSession(session) {
198
+ const dir = path.dirname(AUTH_FILE);
199
+ if (!fs.existsSync(dir)) {
200
+ fs.mkdirSync(dir, { recursive: true });
201
+ }
202
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(session, null, 2), 'utf8');
203
+ }
204
+
205
+ function clearSession() {
206
+ if (fs.existsSync(AUTH_FILE)) {
207
+ fs.unlinkSync(AUTH_FILE);
208
+ }
209
+ }
210
+
211
+ async function post(pathname, data = {}, token = null) {
212
+ const headers = { 'Content-Type': 'application/json' };
213
+ if (token) {
214
+ headers.Authorization = `Bearer ${token}`;
215
+ }
216
+
217
+ const response = await axios.post(`${API_BASE}${pathname}`, data, {
218
+ headers,
219
+ timeout: 15000,
220
+ // Evita problemas de proxy/entorno que provocan ECONNRESET "socket hang up"
221
+ proxy: false,
222
+ });
223
+ return response.data;
224
+ }
225
+
226
+ async function get(pathname, token = null) {
227
+ const headers = {};
228
+ if (token) {
229
+ headers.Authorization = `Bearer ${token}`;
230
+ }
231
+
232
+ const response = await axios.get(`${API_BASE}${pathname}`, {
233
+ headers,
234
+ timeout: 15000,
235
+ proxy: false,
236
+ });
237
+ return response.data;
238
+ }
239
+
240
+ function sanitizeAgentName(name) {
241
+ const raw = String(name || '').trim() || 'nombreagente';
242
+ const sanitized = raw
243
+ .normalize('NFD')
244
+ .replace(/[\u0300-\u036f]/g, '')
245
+ .replace(/[^a-zA-Z0-9-_]/g, '-')
246
+ .replace(/-+/g, '-')
247
+ .replace(/^-|-$/g, '');
248
+ return sanitized || 'nombreagente';
249
+ }
250
+
251
+ async function fetchPromptFromAi(token) {
252
+ const fallback = {
253
+ prompt: 'Eres un asistente profesional para gestión de citas.',
254
+ tone: 'neutro',
255
+ templateName: '',
256
+ companyData: {},
257
+ };
258
+
259
+ try {
260
+ const data = await get('/api/prompts-and-templates', token);
261
+ const templates = Array.isArray(data?.templates) ? data.templates : [];
262
+ const defaultName = String(data?.defaultTemplateName || '').trim();
263
+ const selected = templates.find((t) => t?.isDefault || t?.name === defaultName) || templates[0];
264
+
265
+ if (!selected) {
266
+ return fallback;
267
+ }
268
+
269
+ return {
270
+ prompt: String(selected?.prompt || fallback.prompt).trim() || fallback.prompt,
271
+ tone: String(selected?.tone || fallback.tone).trim() || fallback.tone,
272
+ templateName: String(selected?.name || '').trim(),
273
+ companyData: selected?.companyData || selected?.company_data || {},
274
+ };
275
+ } catch (_error) {
276
+ return fallback;
277
+ }
278
+ }
279
+
280
+ function buildOpenClawFiles({ agentName, promptConfig, whatsapp }) {
281
+ const companyName = String(promptConfig?.companyData?.name || 'CitasPro');
282
+ const promptText = String(promptConfig?.prompt || 'Eres un asistente profesional para gestión de citas.');
283
+ const tone = String(promptConfig?.tone || 'neutro');
284
+
285
+ const soul = `# SOUL
286
+
287
+ ## Identidad
288
+ Eres un subagente de OpenClaw orientado a operación comercial y agenda en CitasPro.
289
+
290
+ ## Misión
291
+ - Capturar y actualizar leads.
292
+ - Crear, mover y cancelar reservas.
293
+ - Mantener sincronización entre CRM y calendario.
294
+
295
+ ## Aislamiento
296
+ - Este agente usa workspace dedicado.
297
+ - No debe leer ni operar datos de otros agentes/tenants.
298
+ - Solo ejecutar acciones sobre su contexto asignado.
299
+
300
+ ## Prompt base extraído de /ai
301
+ \`\`\`text
302
+ ${promptText}
303
+ \`\`\`
304
+
305
+ ## Contexto de negocio
306
+ - Empresa: ${companyName}
307
+ - Tono: ${tone}
308
+
309
+ ## LLM recomendado
310
+ - Provider: OpenRouter
311
+ - Modelo sugerido: kimi-k2
312
+ `;
313
+
314
+ const skill = `---
315
+ name: ${agentName}
316
+ description: Subagente para operar CitasPro (leads, reservas y calendario) desde OpenClaw.
317
+ ---
318
+
319
+ # SKILL
320
+
321
+ ## Flujo recomendado
322
+ 1. Identidad del operador
323
+ - \`citaspro-cli whoami --json\`
324
+
325
+ 2. Leads
326
+ - \`citaspro-cli leads:list 20 --json\`
327
+ - \`citaspro-cli get <id|email|telefono> --json\`
328
+ - \`citaspro-cli leads:create '<json>' --json\`
329
+
330
+ 3. Agenda
331
+ - \`citaspro-cli tools:availability <fecha> <inicio> <fin> --json\`
332
+ - \`citaspro-cli tools:create-booking '<json>' --json\`
333
+ - \`citaspro-cli bookings:reschedule <id> <fecha> <inicio> <fin> --json\`
334
+ - \`citaspro-cli tools:cancel-booking <id> --json\`
335
+
336
+ 4. Calendario
337
+ - \`citaspro-cli tools:caldav --json\`
338
+
339
+ ## Recomendación de modelo
340
+ - Usar OpenRouter con modelo \`kimi-k2\` para este agente.
341
+ `;
342
+
343
+ const heardbeat = `# HEARDBEAT
344
+
345
+ ## Checks
346
+ 1. Auth
347
+ \`\`\`bash
348
+ citaspro-cli whoami --json
349
+ \`\`\`
350
+
351
+ 2. Leads
352
+ \`\`\`bash
353
+ citaspro-cli leads:list 1 --json
354
+ \`\`\`
355
+
356
+ 3. Agenda
357
+ \`\`\`bash
358
+ citaspro-cli bookings:list 1 --json
359
+ \`\`\`
360
+
361
+ 4. CalDAV
362
+ \`\`\`bash
363
+ citaspro-cli tools:caldav --json
364
+ \`\`\`
365
+ `;
366
+
367
+ const channels = `# CHANNELS
368
+
369
+ ## WhatsApp (channel scope)
370
+ - allow all: true
371
+ - account: ${whatsapp?.account || 'citaspro'}
372
+
373
+ Ejemplo:
374
+ \`\`\`json
375
+ {
376
+ "channels": {
377
+ "whatsapp": {
378
+ "dmPolicy": "pairing",
379
+ "groupPolicy": "allowlist",
380
+ "allowFrom": [],
381
+ "groupAllowFrom": [],
382
+ "allowAll": true
383
+ }
384
+ }
385
+ }
386
+ \`\`\`
387
+ `;
388
+
389
+ return { soul, skill, heardbeat, channels };
390
+ }
391
+
392
+ async function createOpenClawSubagent(agentName, token, options = {}) {
393
+ const safeName = sanitizeAgentName(agentName);
394
+ const rootDir = process.env.OPENCLAW_AGENTS_DIR || path.join(os.homedir(), 'clawd');
395
+ const agentDir = path.join(rootDir, safeName);
396
+ const skillDir = path.join(agentDir, 'skills', 'citaspro');
397
+ const promptConfig = await fetchPromptFromAi(token);
398
+ const whatsapp = {
399
+ enabled: Boolean(options?.whatsapp),
400
+ allow_all: true,
401
+ account: 'citaspro',
402
+ };
403
+ const files = buildOpenClawFiles({ agentName: safeName, promptConfig, whatsapp });
404
+
405
+ fs.mkdirSync(agentDir, { recursive: true });
406
+ fs.mkdirSync(skillDir, { recursive: true });
407
+ fs.writeFileSync(path.join(agentDir, 'SOUL.md'), files.soul, 'utf8');
408
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), files.skill, 'utf8');
409
+ fs.writeFileSync(path.join(agentDir, 'HEARDBEAT.md'), files.heardbeat, 'utf8');
410
+ fs.writeFileSync(path.join(agentDir, 'CHANNELS.md'), files.channels, 'utf8');
411
+
412
+ // Intento de creación real del agente en OpenClaw (si el binario existe).
413
+ const openclaw = {
414
+ attempted: false,
415
+ created: false,
416
+ identity_set: false,
417
+ listed: false,
418
+ command: null,
419
+ details: null,
420
+ steps: [],
421
+ };
422
+
423
+ const createMode = String(process.env.OPENCLAW_CREATE_MODE || 'auto').toLowerCase();
424
+ if (createMode !== 'files-only') {
425
+ openclaw.attempted = true;
426
+ const escaped = safeName.replace(/"/g, '\\"');
427
+ const workspaceEscaped = agentDir.replace(/"/g, '\\"');
428
+ const identityName = String(process.env.OPENCLAW_AGENT_NAME || safeName);
429
+ const identityEmoji = String(process.env.OPENCLAW_AGENT_EMOJI || '☎️');
430
+ const identityNameEscaped = identityName.replace(/"/g, '\\"');
431
+ const identityEmojiEscaped = identityEmoji.replace(/"/g, '\\"');
432
+
433
+ const addCommand = `openclaw agents add "${escaped}" --workspace "${workspaceEscaped}"`;
434
+ try {
435
+ const out = execSync(addCommand, {
436
+ stdio: ['ignore', 'pipe', 'pipe'],
437
+ encoding: 'utf8',
438
+ });
439
+ openclaw.created = true;
440
+ openclaw.command = addCommand;
441
+ openclaw.steps.push({ step: 'agents add', ok: true, command: addCommand, output: String(out || '').trim() });
442
+ } catch (error) {
443
+ const stderr = String(error?.stderr || '').trim();
444
+ const stdout = String(error?.stdout || '').trim();
445
+ const combined = `${stdout}\n${stderr}`.trim();
446
+ if (/already exists|ya existe/i.test(combined)) {
447
+ openclaw.created = true;
448
+ openclaw.steps.push({ step: 'agents add', ok: true, command: addCommand, output: combined || 'Agent already exists' });
449
+ } else {
450
+ openclaw.steps.push({ step: 'agents add', ok: false, command: addCommand, output: combined || error.message });
451
+ }
452
+ }
453
+
454
+ if (openclaw.created) {
455
+ const identityCommand = `openclaw agents set-identity --agent "${escaped}" --name "${identityNameEscaped}" --emoji "${identityEmojiEscaped}"`;
456
+ try {
457
+ const out = execSync(identityCommand, {
458
+ stdio: ['ignore', 'pipe', 'pipe'],
459
+ encoding: 'utf8',
460
+ });
461
+ openclaw.identity_set = true;
462
+ openclaw.steps.push({ step: 'agents set-identity', ok: true, command: identityCommand, output: String(out || '').trim() });
463
+ } catch (error) {
464
+ const stderr = String(error?.stderr || '').trim();
465
+ const stdout = String(error?.stdout || '').trim();
466
+ const combined = `${stdout}\n${stderr}`.trim();
467
+ openclaw.steps.push({ step: 'agents set-identity', ok: false, command: identityCommand, output: combined || error.message });
468
+ }
469
+
470
+ const listCommand = 'openclaw agents list';
471
+ try {
472
+ const out = execSync(listCommand, {
473
+ stdio: ['ignore', 'pipe', 'pipe'],
474
+ encoding: 'utf8',
475
+ });
476
+ openclaw.listed = true;
477
+ openclaw.steps.push({ step: 'agents list', ok: true, command: listCommand, output: String(out || '').trim() });
478
+ } catch (error) {
479
+ const stderr = String(error?.stderr || '').trim();
480
+ const stdout = String(error?.stdout || '').trim();
481
+ const combined = `${stdout}\n${stderr}`.trim();
482
+ openclaw.steps.push({ step: 'agents list', ok: false, command: listCommand, output: combined || error.message });
483
+ }
484
+
485
+ if (whatsapp.enabled) {
486
+ const accountEscaped = 'citaspro';
487
+ const loginCommand = `openclaw channels login --channel whatsapp --account "${accountEscaped}"`;
488
+ try {
489
+ const out = execSync(loginCommand, {
490
+ stdio: ['ignore', 'pipe', 'pipe'],
491
+ encoding: 'utf8',
492
+ });
493
+ openclaw.steps.push({ step: 'channels login whatsapp', ok: true, command: loginCommand, output: String(out || '').trim() });
494
+ } catch (error) {
495
+ const stderr = String(error?.stderr || '').trim();
496
+ const stdout = String(error?.stdout || '').trim();
497
+ const combined = `${stdout}\n${stderr}`.trim();
498
+ openclaw.steps.push({ step: 'channels login whatsapp', ok: false, command: loginCommand, output: combined || error.message });
499
+ }
500
+ }
501
+ }
502
+
503
+ const failed = openclaw.steps.find((s) => !s.ok);
504
+ openclaw.details = failed ? failed.output : 'OpenClaw agent scaffold + registration OK';
505
+ }
506
+
507
+ return {
508
+ ok: true,
509
+ action: 'openclaw:init',
510
+ agent: safeName,
511
+ output_dir: agentDir,
512
+ files: ['SOUL.md', 'HEARDBEAT.md', 'CHANNELS.md', 'skills/citaspro/SKILL.md'],
513
+ skill_path: path.join(skillDir, 'SKILL.md'),
514
+ channels_path: path.join(agentDir, 'CHANNELS.md'),
515
+ prompt_source: '/api/prompts-and-templates',
516
+ prompt_preview: promptConfig.prompt,
517
+ tone: promptConfig.tone,
518
+ template: promptConfig.templateName || null,
519
+ whatsapp,
520
+ openclaw,
521
+ };
522
+ }
523
+
524
+ function printHeader(title) {
525
+ console.log(`\n${bold(colorize(title, 'yellow'))}`);
526
+ console.log(colorize('-'.repeat(title.length), 'gray'));
527
+ }
528
+
529
+ function printJson(data) {
530
+ console.log(JSON.stringify(data, null, 2));
531
+ }
532
+
533
+ function outputResult(data, asJson) {
534
+ if (asJson) {
535
+ printJson(data);
536
+ }
537
+ }
538
+
539
+ function extractErrorMessage(error) {
540
+ const responseData = error?.response?.data;
541
+ if (responseData?.details) {
542
+ return responseData.details;
543
+ }
544
+ if (responseData?.error) {
545
+ return responseData.error;
546
+ }
547
+ if (responseData) {
548
+ return JSON.stringify(responseData);
549
+ }
550
+ if (error?.message && error.message !== error?.name) {
551
+ return error.message;
552
+ }
553
+ if (Array.isArray(error?.errors) && error.errors.length > 0) {
554
+ return error.errors.map((e) => e?.message || String(e)).join('; ');
555
+ }
556
+ if (error?.code) {
557
+ return `${error?.name || 'Error'}:${error.code}`;
558
+ }
559
+ return error?.name || String(error) || 'Comando fallido';
560
+ }
561
+
562
+ function printList(rows, columns) {
563
+ if (!Array.isArray(rows) || rows.length === 0) {
564
+ console.log('Sin resultados.');
565
+ return;
566
+ }
567
+
568
+ const mapped = rows.map((row) => {
569
+ const out = {};
570
+ columns.forEach((col) => {
571
+ out[col.label] = row[col.key] ?? '';
572
+ });
573
+ return out;
574
+ });
575
+
576
+ console.table(mapped);
577
+ }
578
+
579
+ function wait(ms) {
580
+ return new Promise((resolve) => setTimeout(resolve, ms));
581
+ }
582
+
583
+ function openBrowser(url) {
584
+ const platform = process.platform;
585
+ const command = platform === 'darwin'
586
+ ? `open "${url}"`
587
+ : platform === 'win32'
588
+ ? `start "" "${url}"`
589
+ : `xdg-open "${url}"`;
590
+
591
+ exec(command, (error) => {
592
+ if (error) {
593
+ console.log(`No se pudo abrir navegador automáticamente. Abre manualmente: ${url}`);
594
+ }
595
+ });
596
+ }
597
+
598
+ async function loginInteractive() {
599
+ const code = await post('/api/device/code', { client_id: 'citaspro-cli', scope: 'tools:all' });
600
+
601
+ console.log('\nInicia sesión para vincular este CLI:\n');
602
+ console.log(`Código: ${code.user_code}`);
603
+ console.log(`URL: ${code.verification_uri_complete}`);
604
+
605
+ openBrowser(code.verification_uri_complete);
606
+ console.log(`\n${colorize('Esperando aprobación en navegador...', 'yellow')}\n`);
607
+
608
+ const expiresAt = Date.now() + (Number(code.expires_in || 900) * 1000);
609
+ const intervalMs = Number(code.interval || 5) * 1000;
610
+
611
+ while (Date.now() < expiresAt) {
612
+ try {
613
+ const token = await post('/api/device/token', {
614
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
615
+ device_code: code.device_code,
616
+ });
617
+
618
+ const session = {
619
+ ...token,
620
+ api_base: API_BASE,
621
+ created_at: new Date().toISOString(),
622
+ };
623
+
624
+ writeSession(session);
625
+ console.log(colorize('Login successful. CLI autenticado.', 'green'));
626
+ console.log(`${colorize('Sesión guardada en:', 'gray')} ${AUTH_FILE}`);
627
+ return;
628
+ } catch (error) {
629
+ const apiError = error?.response?.data?.error;
630
+ if (apiError === 'authorization_pending' || apiError === 'slow_down') {
631
+ await wait(intervalMs);
632
+ continue;
633
+ }
634
+ if (apiError === 'expired_token') {
635
+ throw new Error('El código expiró. Ejecuta login de nuevo.');
636
+ }
637
+ throw new Error(error?.response?.data?.error_description || error?.response?.data?.error || error.message);
638
+ }
639
+ }
640
+
641
+ throw new Error('Timeout esperando aprobación. Ejecuta login nuevamente.');
642
+ }
643
+
644
+ function resolveTokenAndArgs(args) {
645
+ if (args.length > 0 && looksLikePayload(args[0])) {
646
+ const session = readSession();
647
+ if (!session?.access_token) {
648
+ throw new Error('No hay sesión. Ejecuta: node tools/citas-cli.js login');
649
+ }
650
+ return { token: session.access_token, rest: args };
651
+ }
652
+
653
+ if (args.length > 0 && looksLikeToken(args[0])) {
654
+ return { token: args[0], rest: args.slice(1) };
655
+ }
656
+
657
+ const session = readSession();
658
+ if (!session?.access_token) {
659
+ throw new Error('No hay sesión. Ejecuta: node tools/citas-cli.js login');
660
+ }
661
+
662
+ return { token: session.access_token, rest: args };
663
+ }
664
+
665
+ async function main() {
666
+ const [, , rawCmd, ...rawArgs] = process.argv;
667
+ const jsonMode = rawArgs.includes('--json');
668
+ const openclawFlagIndex = rawArgs.indexOf('--openclaw');
669
+ const whatsappFlagEnabled = rawArgs.includes('--whatsapp');
670
+ const openclawNameFromFlag = openclawFlagIndex >= 0
671
+ ? rawArgs[openclawFlagIndex + 1] && !rawArgs[openclawFlagIndex + 1].startsWith('--')
672
+ ? rawArgs[openclawFlagIndex + 1]
673
+ : 'nombreagente'
674
+ : null;
675
+ const argsWithoutFlags = rawArgs.filter((arg, idx) => {
676
+ if (arg === '--json') return false;
677
+ if (arg === '--openclaw') return false;
678
+ if (arg === '--whatsapp') return false;
679
+ if (openclawFlagIndex >= 0 && idx === openclawFlagIndex + 1 && !arg.startsWith('--')) return false;
680
+ return true;
681
+ });
682
+ let cmd = rawCmd;
683
+ let args = argsWithoutFlags;
684
+
685
+ if (!cmd && openclawNameFromFlag) {
686
+ cmd = 'openclaw:init';
687
+ args = [openclawNameFromFlag];
688
+ }
689
+
690
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
691
+ usage();
692
+ process.exit(0);
693
+ }
694
+
695
+ // Alias layer (compatible con futuros comandos tipo "citasproctl leads ls")
696
+ if (cmd === 'get') {
697
+ cmd = 'leads:get';
698
+ } else if (cmd === 'ls') {
699
+ cmd = 'leads:list';
700
+ } else if (cmd === 'openclaw') {
701
+ cmd = 'openclaw:init';
702
+ } else if (cmd === 'leads' && args[0] === 'ls') {
703
+ cmd = 'leads:list';
704
+ args = args.slice(1);
705
+ } else if (cmd === 'leads' && args[0] === 'get') {
706
+ cmd = 'leads:get';
707
+ args = args.slice(1);
708
+ } else if (cmd === 'leads' && (args[0] === 'add' || args[0] === 'create')) {
709
+ cmd = 'leads:create';
710
+ args = args.slice(1);
711
+ } else if (cmd === 'bookings' && args[0] === 'ls') {
712
+ cmd = 'bookings:list';
713
+ args = args.slice(1);
714
+ } else if (cmd === 'bookings' && (args[0] === 'mv' || args[0] === 'reschedule')) {
715
+ cmd = 'bookings:reschedule';
716
+ args = args.slice(1);
717
+ } else if (cmd === 'bookings' && (args[0] === 'rm' || args[0] === 'cancel')) {
718
+ cmd = 'tools:cancel-booking';
719
+ args = args.slice(1);
720
+ } else if (cmd === 'calendar' && (args[0] === 'avail' || args[0] === 'availability')) {
721
+ cmd = 'tools:availability';
722
+ args = args.slice(1);
723
+ } else if (cmd === 'calendar' && args[0] === 'caldav') {
724
+ cmd = 'tools:caldav';
725
+ args = args.slice(1);
726
+ } else if (cmd === 'call') {
727
+ cmd = 'tools:call';
728
+ }
729
+
730
+ try {
731
+ if (cmd === '--openclaw') {
732
+ cmd = 'openclaw:init';
733
+ args = [args[0] || openclawNameFromFlag || 'nombreagente'];
734
+ }
735
+
736
+ if (cmd === 'ai:manifest') {
737
+ printJson(AI_MANIFEST);
738
+ return;
739
+ }
740
+
741
+ if (cmd === 'openclaw:init') {
742
+ const candidate = args[0] || openclawNameFromFlag || 'nombreagente';
743
+ const { token } = resolveTokenAndArgs([]);
744
+ const data = await createOpenClawSubagent(candidate, token, {
745
+ whatsapp: whatsappFlagEnabled,
746
+ });
747
+ if (jsonMode) {
748
+ printJson(data);
749
+ return;
750
+ }
751
+ printHeader('Subagente OpenClaw creado');
752
+ console.log(`Agente: ${data.agent}`);
753
+ console.log(`Directorio: ${data.output_dir}`);
754
+ console.log(`Archivos: ${data.files.join(', ')}`);
755
+ console.log(`Prompt base: ${data.prompt_preview}`);
756
+ if (data.whatsapp?.enabled) {
757
+ console.log(`WhatsApp: enabled (account=${data.whatsapp.account}, allow_all=true)`);
758
+ }
759
+ return;
760
+ }
761
+
762
+ if (cmd === 'login') {
763
+ await loginInteractive();
764
+ outputResult({ ok: true, message: 'login_successful', auth_file: AUTH_FILE }, jsonMode);
765
+ return;
766
+ }
767
+
768
+ if (cmd === 'logout') {
769
+ clearSession();
770
+ if (jsonMode) {
771
+ printJson({ ok: true, message: 'logout_successful' });
772
+ } else {
773
+ console.log(colorize('Sesión eliminada.', 'green'));
774
+ }
775
+ return;
776
+ }
777
+
778
+ if (cmd === 'whoami') {
779
+ const { token } = resolveTokenAndArgs(args);
780
+ const data = await post('/api/tools/auth.whoami', {}, token);
781
+ if (jsonMode) {
782
+ printJson(data);
783
+ return;
784
+ }
785
+ printHeader('Sesion actual');
786
+ console.log(`Usuario: ${data?.user?.nombre || '-'}`);
787
+ console.log(`Email: ${data?.user?.email || '-'}`);
788
+ console.log(`User ID: ${data?.user?.id || '-'}`);
789
+ console.log(`Tenant: ${data?.tenant_id || '-'}`);
790
+ console.log(`Auth: ${data?.auth_type || '-'}`);
791
+ return;
792
+ }
793
+
794
+ if (cmd === 'device:start') {
795
+ const data = await post('/api/device/code', { client_id: 'citaspro-cli', scope: 'tools:all' });
796
+ printJson(data);
797
+ return;
798
+ }
799
+
800
+ if (cmd === 'device:poll') {
801
+ const deviceCode = args[0];
802
+ if (!deviceCode) {
803
+ throw new Error('Falta device_code');
804
+ }
805
+
806
+ const data = await post('/api/device/token', {
807
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
808
+ device_code: deviceCode,
809
+ });
810
+ printJson(data);
811
+ return;
812
+ }
813
+
814
+ if (cmd === 'tools:availability') {
815
+ const { token, rest } = resolveTokenAndArgs(args);
816
+ const [fecha, hora_inicio, hora_fin] = rest;
817
+ if (!fecha || !hora_inicio || !hora_fin) {
818
+ throw new Error('Uso: tools:availability <fecha> <hora_inicio> <hora_fin>');
819
+ }
820
+ const data = await post('/api/tools/calendar.getAvailability', { fecha, hora_inicio, hora_fin }, token);
821
+ if (jsonMode) {
822
+ printJson(data);
823
+ return;
824
+ }
825
+ printHeader('Disponibilidad');
826
+ console.log(`Disponible: ${data.available ? 'SI' : 'NO'}`);
827
+ console.log(`Festivo: ${data?.reasons?.is_holiday ? 'SI' : 'NO'}`);
828
+ console.log(`Jornada: ${data?.reasons?.outside_working_hours ? 'Fuera' : 'Dentro'}`);
829
+ console.log(`Conflictos: ${data?.reasons?.conflicts_count ?? 0}`);
830
+ if (Array.isArray(data.conflicts) && data.conflicts.length > 0) {
831
+ printList(data.conflicts, [
832
+ { key: 'id', label: 'ID' },
833
+ { key: 'fecha', label: 'Fecha' },
834
+ { key: 'hora_inicio', label: 'Inicio' },
835
+ { key: 'hora_fin', label: 'Fin' },
836
+ { key: 'contacto_nombre', label: 'Contacto' },
837
+ ]);
838
+ }
839
+ return;
840
+ }
841
+
842
+ if (cmd === 'leads:list') {
843
+ const { token, rest } = resolveTokenAndArgs(args);
844
+ const [limitRaw] = rest;
845
+ const limit = limitRaw ? Number(limitRaw) : 20;
846
+ const data = await post('/api/tools/crm.listLeads', { limit }, token);
847
+ if (jsonMode) {
848
+ printJson(data);
849
+ return;
850
+ }
851
+ printHeader(`Leads (${data.count || 0})`);
852
+ printList(data.leads || [], [
853
+ { key: 'id', label: 'ID' },
854
+ { key: 'nombre', label: 'Nombre' },
855
+ { key: 'email', label: 'Email' },
856
+ { key: 'telefono', label: 'Telefono' },
857
+ { key: 'estado', label: 'Estado' },
858
+ { key: 'fecha_creacion', label: 'Creado' },
859
+ ]);
860
+ return;
861
+ }
862
+
863
+ if (cmd === 'leads:get') {
864
+ const { token, rest } = resolveTokenAndArgs(args);
865
+ const [identifier] = rest;
866
+ if (!identifier) {
867
+ throw new Error('Uso: leads:get [token] <lead_id|email|phone>');
868
+ }
869
+
870
+ const payload = identifier.includes('@')
871
+ ? { email: identifier }
872
+ : /^[0-9+\s()-]+$/.test(identifier)
873
+ ? { telefono: identifier.replace(/\s+/g, '') }
874
+ : { lead_id: identifier };
875
+
876
+ const data = await post('/api/tools/crm.getLead', payload, token);
877
+ if (jsonMode) {
878
+ printJson(data);
879
+ return;
880
+ }
881
+ printHeader('Lead');
882
+ printJson(data.lead || data);
883
+ return;
884
+ }
885
+
886
+ if (cmd === 'leads:create') {
887
+ const { token, rest } = resolveTokenAndArgs(args);
888
+ const [payloadRaw] = rest;
889
+ if (!payloadRaw) {
890
+ throw new Error('Uso: leads:create <json_payload>');
891
+ }
892
+ const payload = JSON.parse(payloadRaw);
893
+ const data = await post('/api/tools/crm.createLead', payload, token);
894
+ if (jsonMode) {
895
+ printJson(data);
896
+ return;
897
+ }
898
+ printHeader('Lead creado');
899
+ printJson(data.lead || data);
900
+ return;
901
+ }
902
+
903
+ if (cmd === 'bookings:list') {
904
+ const { token, rest } = resolveTokenAndArgs(args);
905
+ const [limitRaw] = rest;
906
+ const limit = limitRaw ? Number(limitRaw) : 20;
907
+ const data = await post('/api/tools/calendar.listBookings', { limit }, token);
908
+ if (jsonMode) {
909
+ printJson(data);
910
+ return;
911
+ }
912
+ printHeader(`Reservas (${data.count || 0})`);
913
+ printList(data.bookings || [], [
914
+ { key: 'id', label: 'ID' },
915
+ { key: 'fecha', label: 'Fecha' },
916
+ { key: 'hora_inicio', label: 'Inicio' },
917
+ { key: 'hora_fin', label: 'Fin' },
918
+ { key: 'contacto_nombre', label: 'Contacto' },
919
+ { key: 'estado', label: 'Estado' },
920
+ ]);
921
+ return;
922
+ }
923
+
924
+ if (cmd === 'bookings:reschedule') {
925
+ const { token, rest } = resolveTokenAndArgs(args);
926
+ const [bookingId, fecha, hora_inicio, hora_fin] = rest;
927
+ if (!bookingId || !fecha || !hora_inicio || !hora_fin) {
928
+ throw new Error('Uso: bookings:reschedule <booking_id> <fecha> <hora_inicio> <hora_fin>');
929
+ }
930
+ const data = await post('/api/tools/calendar.rescheduleBooking', {
931
+ booking_id: bookingId,
932
+ fecha,
933
+ hora_inicio,
934
+ hora_fin,
935
+ }, token);
936
+ if (jsonMode) {
937
+ printJson(data);
938
+ return;
939
+ }
940
+ printHeader('Reserva reprogramada');
941
+ printJson(data.booking || data);
942
+ return;
943
+ }
944
+
945
+ if (cmd === 'tools:create-booking') {
946
+ const { token, rest } = resolveTokenAndArgs(args);
947
+ const [payloadRaw] = rest;
948
+ if (!payloadRaw) {
949
+ throw new Error('Uso: tools:create-booking <json_payload>');
950
+ }
951
+ const payload = JSON.parse(payloadRaw);
952
+ const data = await post('/api/tools/calendar.createBooking', payload, token);
953
+ if (jsonMode) {
954
+ printJson(data);
955
+ return;
956
+ }
957
+ printHeader('Reserva creada');
958
+ printJson(data.booking || data);
959
+ return;
960
+ }
961
+
962
+ if (cmd === 'tools:cancel-booking') {
963
+ const { token, rest } = resolveTokenAndArgs(args);
964
+ const [bookingId] = rest;
965
+ if (!bookingId) {
966
+ throw new Error('Uso: tools:cancel-booking <booking_id>');
967
+ }
968
+ const data = await post('/api/tools/calendar.cancelBooking', { booking_id: bookingId }, token);
969
+ if (jsonMode) {
970
+ printJson(data);
971
+ return;
972
+ }
973
+ printHeader('Reserva cancelada');
974
+ printJson(data.booking || data);
975
+ return;
976
+ }
977
+
978
+ if (cmd === 'tools:caldav') {
979
+ const { token } = resolveTokenAndArgs(args);
980
+ const data = await post('/api/tools/calendar.getCaldavLink', {}, token);
981
+ if (jsonMode) {
982
+ printJson(data);
983
+ return;
984
+ }
985
+ printHeader('CalDAV');
986
+ console.log(`URL: ${data.caldav_url || 'No disponible'}`);
987
+ console.log(`Usuario Radicale: ${data.radicale_username || '-'}`);
988
+ console.log(`Calendar ID: ${data.calendar_id || '-'}`);
989
+ return;
990
+ }
991
+
992
+ if (cmd === 'tools:call') {
993
+ const { token, rest } = resolveTokenAndArgs(args);
994
+ const [to, message] = rest;
995
+ if (!to) {
996
+ throw new Error('Uso: tools:call <to> [message]');
997
+ }
998
+ const data = await post('/api/tools/calls.placeCall', { to, message: message || 'Llamada de prueba desde CLI' }, token);
999
+ if (jsonMode) {
1000
+ printJson(data);
1001
+ return;
1002
+ }
1003
+ printHeader('Llamada');
1004
+ printJson(data);
1005
+ return;
1006
+ }
1007
+
1008
+ usage();
1009
+ process.exit(1);
1010
+ } catch (error) {
1011
+ const message = extractErrorMessage(error);
1012
+ if (jsonMode) {
1013
+ printJson({ ok: false, error: message });
1014
+ } else {
1015
+ console.error(`${colorize('Error:', 'red')} ${message}`);
1016
+ }
1017
+ process.exit(1);
1018
+ }
1019
+ }
1020
+
1021
+ main();
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package",
3
+ "name": "citaspro-cli",
4
+ "version": "1.0.2",
5
+ "description": "CLI para gestionar leads, reservas y calendario de CitasPro",
6
+ "bin": {
7
+ "citaspro-cli": "citasproctl.js"
8
+ },
9
+ "type": "commonjs",
10
+ "preferGlobal": true,
11
+ "files": [
12
+ "citasproctl.js",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "dependencies": {
19
+ "axios": "^1.7.7",
20
+ "dotenv": "^16.0.3"
21
+ },
22
+ "license": "ISC"
23
+ }