plazbot-cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +67 -111
  2. package/dist/cli.js +26 -11
  3. package/dist/commands/agent/ai-config.js +264 -63
  4. package/dist/commands/agent/chat.js +16 -11
  5. package/dist/commands/agent/copy.js +49 -13
  6. package/dist/commands/agent/create.js +62 -17
  7. package/dist/commands/agent/delete.js +50 -39
  8. package/dist/commands/agent/enable-widget.js +47 -27
  9. package/dist/commands/agent/export.js +179 -88
  10. package/dist/commands/agent/files.js +155 -73
  11. package/dist/commands/agent/get.js +24 -16
  12. package/dist/commands/agent/index.js +12 -10
  13. package/dist/commands/agent/list.js +20 -11
  14. package/dist/commands/agent/monitor.js +101 -53
  15. package/dist/commands/agent/on-message.js +55 -32
  16. package/dist/commands/agent/set.js +230 -82
  17. package/dist/commands/agent/templates.js +259 -156
  18. package/dist/commands/agent/tools.js +356 -123
  19. package/dist/commands/agent/update.js +69 -28
  20. package/dist/commands/agent/validate.js +72 -40
  21. package/dist/commands/agent/wizard.js +240 -329
  22. package/dist/commands/auth/browser-login.js +37 -31
  23. package/dist/commands/auth/login.js +49 -45
  24. package/dist/commands/auth/logout.js +20 -11
  25. package/dist/commands/auth/status.js +36 -12
  26. package/dist/commands/auth/workspace.js +61 -27
  27. package/dist/commands/portal/add-agent.js +106 -45
  28. package/dist/commands/portal/add-link.js +25 -28
  29. package/dist/commands/portal/clear-links.js +39 -19
  30. package/dist/commands/portal/create.js +66 -37
  31. package/dist/commands/portal/delete.js +40 -45
  32. package/dist/commands/portal/get.js +21 -54
  33. package/dist/commands/portal/index.js +12 -12
  34. package/dist/commands/portal/list.js +34 -52
  35. package/dist/commands/portal/shared.js +92 -0
  36. package/dist/commands/portal/update.js +100 -70
  37. package/dist/commands/whatsapp/broadcast.js +169 -77
  38. package/dist/commands/whatsapp/channels.js +167 -65
  39. package/dist/commands/whatsapp/chat.js +45 -46
  40. package/dist/commands/whatsapp/connect-flow.js +16 -15
  41. package/dist/commands/whatsapp/connect.js +36 -21
  42. package/dist/commands/whatsapp/delete-webhook.js +42 -25
  43. package/dist/commands/whatsapp/index.js +15 -16
  44. package/dist/commands/whatsapp/register-webhook.js +43 -26
  45. package/dist/commands/whatsapp/send-template.js +62 -61
  46. package/dist/commands/whatsapp/send.js +46 -33
  47. package/dist/commands/whatsapp/setup.js +62 -52
  48. package/dist/commands/whatsapp/shared.js +75 -0
  49. package/dist/commands/whatsapp/widget.js +82 -45
  50. package/dist/commands/workers/deploy.js +80 -58
  51. package/dist/commands/workers/index.js +8 -8
  52. package/dist/commands/workers/list.js +42 -20
  53. package/dist/commands/workers/logs.js +139 -36
  54. package/dist/commands/workers/remove.js +45 -47
  55. package/dist/commands/workers/secret.js +159 -83
  56. package/dist/commands/workers/test.js +108 -60
  57. package/dist/schemas/agent.config.schema.json +183 -8
  58. package/dist/studio/api/sseClient.js +1 -1
  59. package/dist/studio/api/studioApi.js +8 -0
  60. package/dist/studio/components/App.js +12 -0
  61. package/dist/studio/components/Footer.js +1 -1
  62. package/dist/studio/index.js +77 -14
  63. package/dist/studio/runOneShot.js +62 -23
  64. package/dist/studio/runRepl.js +5 -5
  65. package/dist/studio/slash/handlers.js +11 -8
  66. package/dist/studio/state/store.js +1 -1
  67. package/dist/utils/agent-errors.js +14 -11
  68. package/dist/utils/agent-patch.js +60 -0
  69. package/dist/utils/banner.js +9 -5
  70. package/dist/utils/confirm.js +31 -0
  71. package/dist/utils/credentials.js +13 -3
  72. package/dist/utils/logger.js +13 -10
  73. package/dist/utils/output.js +78 -0
  74. package/dist/utils/phone.js +22 -0
  75. package/dist/utils/ui.js +14 -11
  76. package/package.json +2 -2
@@ -7,6 +7,10 @@
7
7
  "name": { "type": "string" },
8
8
  "description": { "type": "string" },
9
9
  "prompt": { "type": "string" },
10
+ "imagePrompt": {
11
+ "type": "string",
12
+ "description": "Instruccion usada cuando llega una imagen/archivo sin texto del usuario (caption vacio)."
13
+ },
10
14
  "zone": {
11
15
  "type": "string",
12
16
  "enum": ["LA", "EU"]
@@ -43,15 +47,15 @@
43
47
  "personality": { "type": "string" },
44
48
  "objective": { "type": "string" },
45
49
  "language": { "type": "string" },
46
- "emojis": { "type": "boolean" },
50
+ "emojis": { "type": ["boolean", "null"] },
47
51
  "preferredFormat": { "type": "string" },
48
52
  "maxWords": { "type": "integer" },
49
53
  "avoidTopics": {
50
54
  "type": "array",
51
55
  "items": { "type": "string" }
52
56
  },
53
- "respondOnlyIfKnows": { "type": "boolean" },
54
- "maintainToneBetweenMessages": { "type": "boolean" },
57
+ "respondOnlyIfKnows": { "type": ["boolean", "null"] },
58
+ "maintainToneBetweenMessages": { "type": ["boolean", "null"] },
55
59
  "greeting": { "type": "string" }
56
60
  }
57
61
  },
@@ -99,6 +103,30 @@
99
103
  "enableWhatsappWidget": { "type": "boolean" },
100
104
  "urlWhatsappWidget": { "type": "string" },
101
105
  "formWidget": { "type": "boolean" },
106
+ "formTypeWidget": {
107
+ "type": ["string", "null"],
108
+ "enum": ["chat", "fields", null],
109
+ "default": "chat",
110
+ "description": "Estilo del formulario de registro: chat (conversacional) o fields (campos en una sola pantalla). Solo aplica si formWidget es true."
111
+ },
112
+ "formFieldsWidget": {
113
+ "type": ["array", "null"],
114
+ "maxItems": 5,
115
+ "description": "Campos adicionales del formulario por campos (formTypeWidget = fields). Cada valor se guarda en una variable del contacto.",
116
+ "items": {
117
+ "type": "object",
118
+ "properties": {
119
+ "id": { "type": "string" },
120
+ "label": { "type": "string", "maxLength": 60 },
121
+ "type": { "type": "string", "enum": ["text", "select", "date"], "default": "text" },
122
+ "options": { "type": ["array", "null"], "items": { "type": "string", "maxLength": 60 }, "maxItems": 20 },
123
+ "variable": { "type": "string" },
124
+ "required": { "type": "boolean" }
125
+ },
126
+ "required": ["label", "type", "variable"],
127
+ "additionalProperties": false
128
+ }
129
+ },
102
130
  "darkWidget": { "type": "boolean" },
103
131
  "colorWidget": {
104
132
  "type": ["string", "null"],
@@ -140,6 +168,17 @@
140
168
  "default": false,
141
169
  "description": "Si true, divide la respuesta del agente IA en varios mensajes (split por doble salto de linea)."
142
170
  },
171
+ "advisorNameModeWidget": {
172
+ "type": ["string", "null"],
173
+ "enum": ["agent", "custom", null],
174
+ "default": "agent",
175
+ "description": "Nombre que ve el cliente sobre los mensajes del asesor humano en el widget: agent (nombre del asesor asignado) o custom (advisorNameWidget)."
176
+ },
177
+ "advisorNameWidget": {
178
+ "type": ["string", "null"],
179
+ "maxLength": 60,
180
+ "description": "Nombre personalizado del asesor humano en el widget (solo si advisorNameModeWidget = custom). Ej: Equipo de Soporte."
181
+ },
143
182
  "enableAvatarWidget": { "type": "boolean" },
144
183
  "avatarGender": {
145
184
  "type": "string",
@@ -178,14 +217,24 @@
178
217
  "properties": {
179
218
  "enabled": { "type": "boolean" },
180
219
  "phoneNumber": { "type": "string", "description": "DID phone number, e.g., +573151234567" },
181
- "ttsProvider": { "type": "string", "enum": ["google", "openai"] },
220
+ "ttsProvider": { "type": "string", "enum": ["google", "openai", "elevenlabs"] },
182
221
  "ttsVoiceId": { "type": "string", "description": "TTS voice ID, e.g., nova, es-US-Journey-D" },
183
222
  "ttsLanguage": { "type": "string", "description": "Language code, e.g., es-CO, es-MX" },
184
223
  "greeting": { "type": "string", "description": "Initial greeting message for TTS" },
185
224
  "maxCallDurationSeconds": { "type": "integer", "minimum": 60, "maximum": 1800, "default": 300 },
186
- "silenceTimeoutMs": { "type": "integer", "minimum": 2000, "maximum": 15000, "default": 5000 },
225
+ "silenceTimeoutMs": { "type": "integer", "minimum": 15000, "maximum": 60000, "default": 15000 },
187
226
  "transferExtension": { "type": "string", "description": "SIP extension for human transfer" },
188
- "voiceType": { "type": "string", "enum": ["compact", "complete"], "description": "compact=fast no tools, complete=full toolcalling+RAG" }
227
+ "voiceType": { "type": "string", "enum": ["compact", "complete"], "description": "compact=fast no tools, complete=full toolcalling+RAG" },
228
+ "elevenlabsApiKey": { "type": "string", "description": "ElevenLabs API key (BYOK)" },
229
+ "elevenlabsVoiceId": { "type": "string", "description": "ElevenLabs voice ID" },
230
+ "elevenlabsVoiceName": { "type": "string", "description": "ElevenLabs voice display name" },
231
+ "elevenlabsModelId": { "type": "string", "description": "ElevenLabs model ID (e.g., eleven_multilingual_v2)" },
232
+ "elevenlabsStability": { "type": "number", "minimum": 0, "maximum": 100, "description": "Voice stability 0-100" },
233
+ "elevenlabsSimilarity": { "type": "number", "minimum": 0, "maximum": 100, "description": "Voice similarity 0-100" },
234
+ "elevenlabsStyle": { "type": "number", "minimum": 0, "maximum": 100, "description": "Style exaggeration 0-100" },
235
+ "elevenlabsSpeakerBoost": { "type": "boolean", "description": "Enable speaker boost" },
236
+ "languageInstruction": { "type": "string", "description": "Custom language instruction appended to the LLM prompt (if empty, the PBX generates an automatic one based on ttsLanguage)" },
237
+ "fillersEnabled": { "type": "boolean", "description": "Enable filler phrases (e.g. 'Dejame revisar') while the AI processes the response", "default": true }
189
238
  }
190
239
  },
191
240
  "useToolCalling": {
@@ -256,6 +305,16 @@
256
305
  }
257
306
  }
258
307
  },
308
+ "enableContingency": {
309
+ "type": "boolean",
310
+ "default": false,
311
+ "description": "If true and multiple aiProviders exist, retries with the remaining providers when the default provider fails (Tool Calling orchestrator only)"
312
+ },
313
+ "enableAudioInput": {
314
+ "type": "boolean",
315
+ "default": false,
316
+ "description": "If true, transcribes received voice audios (Whisper) and the agent replies as text (Tool Calling orchestrator only)"
317
+ },
259
318
  "channels": {
260
319
  "type": "array",
261
320
  "items": {
@@ -277,7 +336,7 @@
277
336
  "intent": { "type": "string" },
278
337
  "reference": { "type": "string" },
279
338
  "enabled": { "type": "boolean", "default": true },
280
- "method": { "type": "string", "enum": ["GET", "POST"] },
339
+ "method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] },
281
340
  "tags": {
282
341
  "type": "array",
283
342
  "items": { "type": "string" }
@@ -310,6 +369,21 @@
310
369
  "type": { "type": "string" }
311
370
  }
312
371
  }
372
+ },
373
+ "buttons": {
374
+ "type": "array",
375
+ "maxItems": 3,
376
+ "items": {
377
+ "type": "object",
378
+ "required": ["text"],
379
+ "properties": {
380
+ "text": { "type": "string", "minLength": 1, "maxLength": 20 }
381
+ }
382
+ }
383
+ },
384
+ "contactField": {
385
+ "type": "string",
386
+ "enum": ["name", "lastname", "email", "phoneNumber", "documentNumber", "businessName"]
313
387
  }
314
388
  }
315
389
  }
@@ -484,6 +558,21 @@
484
558
  "type": { "type": "string" }
485
559
  }
486
560
  }
561
+ },
562
+ "buttons": {
563
+ "type": "array",
564
+ "maxItems": 3,
565
+ "items": {
566
+ "type": "object",
567
+ "required": ["text"],
568
+ "properties": {
569
+ "text": { "type": "string", "minLength": 1, "maxLength": 20 }
570
+ }
571
+ }
572
+ },
573
+ "contactField": {
574
+ "type": "string",
575
+ "enum": ["name", "lastname", "email", "phoneNumber", "documentNumber", "businessName"]
487
576
  }
488
577
  }
489
578
  }
@@ -517,10 +606,96 @@
517
606
  "action.stage.remove",
518
607
  "action.tag.remove",
519
608
  "action.segmentation.remove",
520
- "action.worker"
609
+ "action.worker",
610
+ "action.transfer.voip",
611
+ "action.transfer.voip.external",
612
+ "action.send.image",
613
+ "action.send.file",
614
+ "action.send.template",
615
+ "action.send.template.voip",
616
+ "action.ai.summary",
617
+ "action.agent.ask"
521
618
  ]
522
619
  },
523
620
  "value": { "type": "string" },
621
+ "queryTemplate": {
622
+ "type": "string",
623
+ "description": "Pregunta a enviar al agente consultado (solo action.agent.ask; el agentId destino va en 'value'). Soporta {{variables}}. Vacio = el LLM formula la pregunta con el parametro 'query'."
624
+ },
625
+ "outputVariable": {
626
+ "type": "string",
627
+ "description": "Codigo de variable del contacto donde guardar la respuesta del agente consultado (solo action.agent.ask, opcional)."
628
+ },
629
+ "assignMode": {
630
+ "type": "string",
631
+ "enum": ["user", "workload", "random", "workgroup"],
632
+ "description": "Solo action.asign. user (default): email fijo en 'value'. workload: miembro con menos chats abiertos. random: al azar. workgroup: miembro de workgroupId por carga de trabajo. En modos automaticos, 'value' (email) es el respaldo si no hay nadie disponible."
633
+ },
634
+ "workgroupId": {
635
+ "type": "string",
636
+ "description": "Solo action.asign con assignMode workgroup: id del grupo de trabajo del workspace."
637
+ },
638
+ "onlyOnlineAgents": {
639
+ "type": "boolean",
640
+ "description": "Solo action.asign en modos automaticos: considerar solo miembros conectados."
641
+ },
642
+ "sipServer": {
643
+ "type": "string",
644
+ "description": "Servidor/Proxy SIP del partner (solo action.transfer.voip.external). Ej: trunks.vpbx.me. La extension destino se guarda en 'value'."
645
+ },
646
+ "sipPort": {
647
+ "type": "integer",
648
+ "minimum": 1,
649
+ "maximum": 65535,
650
+ "default": 5060,
651
+ "description": "Puerto SIP del proxy (solo action.transfer.voip.external). Default: 5060"
652
+ },
653
+ "sipUser": {
654
+ "type": "string",
655
+ "description": "Usuario SIP para autenticacion saliente (solo action.transfer.voip.external)"
656
+ },
657
+ "sipPassword": {
658
+ "type": "string",
659
+ "description": "Clave SIP para autenticacion saliente (solo action.transfer.voip.external)"
660
+ },
661
+ "sipDomain": {
662
+ "type": "string",
663
+ "description": "From-domain opcional para PBX multi-tenant que lo exijan (solo action.transfer.voip.external)"
664
+ },
665
+ "fileName": {
666
+ "type": "string",
667
+ "description": "Nombre original del archivo subido a S3 (solo para action.send.image / action.send.file). La URL publica de S3 se guarda en 'value'."
668
+ },
669
+ "mimeType": {
670
+ "type": "string",
671
+ "description": "Content-Type del archivo subido (solo para action.send.image / action.send.file). Ej: image/png, application/pdf."
672
+ },
673
+ "countryPrefix": {
674
+ "type": "string",
675
+ "description": "Prefijo de pais obligatorio para action.send.template y action.send.template.voip (ej: +34 o 34). Se antepone al numero destino si este no lo tiene. El templateId se guarda en 'value'."
676
+ },
677
+ "phoneNumber": {
678
+ "type": "string",
679
+ "description": "Numero destino de la alerta SIN prefijo de pais (solo action.send.template). La plantilla se envia a este numero (alerta a otra persona), NO al contacto de la conversacion. Para varios numeros, crear varias acciones."
680
+ },
681
+ "waSenderId": {
682
+ "type": "string",
683
+ "description": "codeCellphoneNumberId de la integracion WhatsApp del workspace desde la que se envia la plantilla (obligatorio en action.send.template y action.send.template.voip)."
684
+ },
685
+ "templateVariables": {
686
+ "type": "array",
687
+ "description": "Valores de las variables {{n}} de la plantilla (action.send.template / .voip). Cada 'value' admite texto fijo y placeholders: {{contact.x}}, {{system.x}}, {{custom.x}} o {{campoRequerido}} de la accion.",
688
+ "items": {
689
+ "type": "object",
690
+ "required": ["componentType", "variable"],
691
+ "properties": {
692
+ "componentType": { "type": "string", "enum": ["HEADER", "BODY"] },
693
+ "variable": { "type": "string" },
694
+ "value": { "type": "string" }
695
+ },
696
+ "additionalProperties": false
697
+ }
698
+ },
524
699
  "durationMinutes": {
525
700
  "type": "integer",
526
701
  "minimum": 5,
@@ -33,7 +33,7 @@ export async function streamStudio(opts, request, onChunk, signal) {
33
33
  throw new StudioHttpError(res.status, res.statusText, text);
34
34
  }
35
35
  if (!res.body) {
36
- throw new Error('Respuesta sin body en POST /api/agent/studio');
36
+ throw new Error('Empty response body from POST /api/agent/studio.');
37
37
  }
38
38
  const reader = res.body.getReader();
39
39
  const decoder = new TextDecoder();
@@ -23,3 +23,11 @@ export function buildStudioHeaders(opts) {
23
23
  headers['x-user-id'] = opts.userId;
24
24
  return headers;
25
25
  }
26
+ /**
27
+ * userId sent to Studio (optional; the backend uses it to attribute created/saved
28
+ * agents). Stored login sessions carry it; env-var credentials
29
+ * (PLAZBOT_API_KEY/PLAZBOT_WORKSPACE_ID) don't, so PLAZBOT_USER_ID fills the gap.
30
+ */
31
+ export function resolveStudioUserId(creds) {
32
+ return creds.userId || process.env.PLAZBOT_USER_ID?.trim() || creds.email || undefined;
33
+ }
@@ -161,6 +161,13 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
161
161
  agentId: useStudioStore.getState().agentId,
162
162
  agentConfig: useStudioStore.getState().agentConfig,
163
163
  }, onChunk, controller.signal);
164
+ if (controller.signal.aborted) {
165
+ // Esc: the SSE client returns quietly on abort. Mark running steps and
166
+ // don't compact a half-written summary.
167
+ useStudioStore.getState().failAllRunningSteps('Cancelled');
168
+ useStudioStore.getState().setPendingCompact(false);
169
+ useStudioStore.getState().pushSyntheticAssistant('Response cancelled.');
170
+ }
164
171
  }
165
172
  catch (err) {
166
173
  const msg = formatError(err, dev);
@@ -215,6 +222,11 @@ function formatError(err, dev) {
215
222
  if (err instanceof Error) {
216
223
  if (err.name === 'AbortError')
217
224
  return '✖ Stream cancelled.';
225
+ if (err.name === 'TypeError' && /fetch failed/i.test(err.message)) {
226
+ return dev
227
+ ? '✖ Could not reach the local backend (http://localhost:5090). Is it running?'
228
+ : '✖ Could not reach the Plazbot API. Check your internet connection.';
229
+ }
218
230
  return `✖ ${err.message}`;
219
231
  }
220
232
  return '✖ Unknown error';
@@ -7,5 +7,5 @@ function fmt(n) {
7
7
  return String(n);
8
8
  }
9
9
  export function Footer({ inputTokens, outputTokens, streaming }) {
10
- return (_jsxs(Box, { paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "tokens: " }), _jsx(Text, { children: fmt(inputTokens) }), _jsx(Text, { dimColor: true, children: " in / " }), _jsx(Text, { children: fmt(outputTokens) }), _jsx(Text, { dimColor: true, children: " out" }), streaming ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " streaming\u2026" })] })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "/help \u00B7 \u2191\u2193 history \u00B7 Tab accept \u00B7 Esc cancel \u00B7 Ctrl+C quit" }) })] }));
10
+ return (_jsxs(Box, { paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "tokens: " }), _jsx(Text, { children: fmt(inputTokens) }), _jsx(Text, { dimColor: true, children: " in / " }), _jsx(Text, { children: fmt(outputTokens) }), _jsx(Text, { dimColor: true, children: " out" }), streaming ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " streaming\u2026" })] })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: streaming ? 'Esc cancel · Ctrl+C exit' : '/help · ↑↓ history · Tab/→ accept suggestion · Esc cancel · Ctrl+C exit' }) })] }));
11
11
  }
@@ -1,42 +1,105 @@
1
- import { Command } from 'commander';
1
+ import { Command, Option } from 'commander';
2
+ import { jsonOption, fail, CliError, setJsonMode } from '../utils/output.js';
3
+ import { addExamples, addNotes } from '../utils/help.js';
4
+ const WORKSPACE_DESC = 'Act on another workspace (support / white-label partner mode)';
5
+ /** Hidden legacy alias of `--workspace` (kept for backwards compatibility). */
6
+ function legacyWorkspaceIdOption() {
7
+ return new Option('--workspace-id <id>', WORKSPACE_DESC).hideHelp();
8
+ }
2
9
  export const studioCommand = new Command('studio')
3
- .description('Interactive Plazbot Studio REPL (create, diagnose and manage AI agents)')
10
+ .description('Open Plazbot Studio, an interactive assistant to create, diagnose and manage AI agents')
11
+ // Options written after `ask` belong to `ask` (otherwise `studio` would swallow
12
+ // `--json`, `--dev`, `-a`, `-w` and the subcommand would never see them).
13
+ .enablePositionalOptions()
4
14
  .option('--dev', 'Target the local backend (http://localhost:5090)', false)
5
- .option('-a, --agent-id <id>', 'Preload an agent on startup')
6
- .option('-w, --workspace-id <id>', 'Override the workspace (support / white-label partner mode)')
7
- .option('-m, --message <text>', 'Run a one-shot prompt and exit')
15
+ .option('-a, --agent-id <id>', 'Preload an agent as context on startup')
16
+ .option('-w, --workspace <id>', WORKSPACE_DESC)
17
+ .addOption(legacyWorkspaceIdOption())
18
+ .option('-m, --message <text>', 'Alias of `studio ask`: run a one-shot prompt and exit')
19
+ .addOption(jsonOption('With -m: print the SSE chunks as NDJSON (one JSON object per line)'))
8
20
  .action(async (opts) => {
9
- if (opts.message) {
21
+ const workspaceOverride = opts.workspace ?? opts.workspaceId;
22
+ if (opts.message !== undefined) {
23
+ if (!opts.message.trim()) {
24
+ fail(new CliError('The message is empty.', 'missing_message', 'Pass a prompt: plazbot studio -m "List my agents"'));
25
+ }
10
26
  const { runOneShot } = await import('./runOneShot.js');
11
27
  await runOneShot({
12
28
  dev: opts.dev,
13
29
  agentId: opts.agentId,
14
30
  message: opts.message,
15
- workspaceOverride: opts.workspaceId,
31
+ json: opts.json,
32
+ workspaceOverride,
16
33
  });
17
34
  return;
18
35
  }
36
+ if (opts.json) {
37
+ fail(new CliError('--json only works with a one-shot prompt.', 'json_requires_message', 'Use: plazbot studio ask "List my agents" --json'));
38
+ }
39
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
40
+ fail(new CliError('The Studio REPL needs an interactive terminal.', 'not_interactive', 'Use a one-shot prompt instead: plazbot studio ask "List my agents"'));
41
+ }
19
42
  const { runRepl } = await import('./runRepl.js');
20
43
  await runRepl({
21
44
  dev: opts.dev,
22
45
  agentId: opts.agentId,
23
- workspaceOverride: opts.workspaceId,
46
+ workspaceOverride,
24
47
  });
25
48
  });
26
- studioCommand
49
+ addExamples(studioCommand, [
50
+ { description: 'Open the interactive Studio', command: 'plazbot studio' },
51
+ { description: 'Open Studio with an agent already loaded', command: 'plazbot studio -a age_AbcDef123' },
52
+ { description: 'Run a single prompt and exit (same as `studio ask`)', command: 'plazbot studio -m "Diagnose this agent" -a age_AbcDef123' },
53
+ { description: 'Act on a customer workspace (support / partner mode)', command: 'plazbot studio -w wok_AbcDef123' },
54
+ ]);
55
+ addNotes(studioCommand, [
56
+ 'Requires a session: run `plazbot login` (or set PLAZBOT_API_KEY and PLAZBOT_WORKSPACE_ID).',
57
+ 'Keys: Enter send · ↑/↓ history · Tab or → accept the gray suggestion · Esc cancel the response · Ctrl+C exit.',
58
+ 'Type /help inside Studio to list slash commands (/agents, /load, /diagnose, /save, /export, /clear, /compact...).',
59
+ 'In support mode (-w) every action is tied to your user and visible in audit trails.',
60
+ ]);
61
+ const askCommand = studioCommand
27
62
  .command('ask <message>')
28
- .description('Run a one-shot studio query without opening the TUI')
63
+ .description('Run a one-shot Studio prompt without opening the interactive UI')
29
64
  .option('--dev', 'Target the local backend (http://localhost:5090)', false)
30
65
  .option('-a, --agent-id <id>', 'Agent to use as context')
31
- .option('-w, --workspace-id <id>', 'Override the workspace (support / white-label partner mode)')
32
- .option('--json', 'Print SSE chunks as raw NDJSON', false)
33
- .action(async (message, opts) => {
66
+ .option('-w, --workspace <id>', WORKSPACE_DESC)
67
+ .addOption(legacyWorkspaceIdOption())
68
+ .addOption(jsonOption('Print the SSE chunks as NDJSON (one JSON object per line)'))
69
+ .action(async (message, askOpts, cmd) => {
70
+ // Also honor options written before `ask` (`plazbot studio --dev ask "..."`).
71
+ const parent = (cmd.parent?.opts() ?? {});
72
+ const opts = {
73
+ dev: askOpts.dev || !!parent.dev,
74
+ json: askOpts.json || !!parent.json,
75
+ agentId: askOpts.agentId ?? parent.agentId,
76
+ workspace: askOpts.workspace ?? parent.workspace,
77
+ workspaceId: askOpts.workspaceId ?? parent.workspaceId,
78
+ };
79
+ if (opts.json)
80
+ setJsonMode(true);
81
+ if (!message.trim()) {
82
+ fail(new CliError('The message is empty.', 'missing_message', 'Pass a prompt: plazbot studio ask "List my agents"'));
83
+ }
34
84
  const { runOneShot } = await import('./runOneShot.js');
35
85
  await runOneShot({
36
86
  dev: opts.dev,
37
87
  agentId: opts.agentId,
38
88
  message,
39
89
  json: opts.json,
40
- workspaceOverride: opts.workspaceId,
90
+ workspaceOverride: opts.workspace ?? opts.workspaceId,
41
91
  });
42
92
  });
93
+ addExamples(askCommand, [
94
+ { description: 'List the agents in your workspace', command: 'plazbot studio ask "List my agents"' },
95
+ { description: 'Diagnose a specific agent', command: 'plazbot studio ask "Diagnose this agent and report issues" -a age_AbcDef123' },
96
+ { description: 'Stream raw chunks for scripting', command: 'plazbot studio ask "List my agents" --json' },
97
+ { description: 'Ask about a customer workspace (support / partner mode)', command: 'plazbot studio ask "List the agents" -w wok_AbcDef123' },
98
+ ]);
99
+ addNotes(askCommand, [
100
+ 'The answer streams to stdout; tool steps, token usage and errors go to stderr.',
101
+ 'With --json, stdout carries one JSON chunk per line: text, tool_call, tool_result, usage, error, done.',
102
+ 'Exits with code 1 when the request fails or the stream returns an error chunk.',
103
+ 'Each call is a fresh conversation (no history). Use `plazbot studio` for multi-turn sessions.',
104
+ 'With PLAZBOT_API_KEY / PLAZBOT_WORKSPACE_ID credentials, set PLAZBOT_USER_ID to attribute created or saved agents to a user (optional).',
105
+ ]);
@@ -1,18 +1,16 @@
1
1
  import { getStoredCredentials } from '../utils/credentials.js';
2
- import { logger } from '../utils/logger.js';
2
+ import { fail, CliError } from '../utils/output.js';
3
3
  import { streamStudio } from './api/sseClient.js';
4
4
  import { StudioHttpError } from './api/types.js';
5
5
  import { stepLabel } from './render/steps.js';
6
+ import { resolveStudioUserId } from './api/studioApi.js';
6
7
  export async function runOneShot(opts) {
7
8
  let creds;
8
9
  try {
9
10
  creds = await getStoredCredentials();
10
11
  }
11
12
  catch {
12
- logger.error('No active session. Run:');
13
- console.log(' plazbot login');
14
- process.exit(1);
15
- return;
13
+ fail(new CliError('You are not signed in.', 'not_signed_in', 'Run `plazbot login`, or set PLAZBOT_API_KEY and PLAZBOT_WORKSPACE_ID.'));
16
14
  }
17
15
  const effectiveWorkspace = opts.workspaceOverride ?? creds.workspace;
18
16
  const supportMode = !!opts.workspaceOverride && opts.workspaceOverride !== creds.workspace;
@@ -22,15 +20,18 @@ export async function runOneShot(opts) {
22
20
  const stream = {
23
21
  apiKey: creds.apiKey,
24
22
  workspaceId: effectiveWorkspace,
25
- userId: creds.userId ?? creds.email ?? undefined,
23
+ userId: resolveStudioUserId(creds),
26
24
  zone: creds.zone,
27
25
  dev: opts.dev,
28
26
  };
29
27
  const controller = new AbortController();
30
28
  process.on('SIGINT', () => controller.abort());
31
29
  let assistantText = '';
30
+ let streamError = null;
32
31
  const onChunk = (chunk) => {
33
32
  if (opts.json) {
33
+ if (chunk.type === 'error')
34
+ streamError = chunk.error || 'The Studio stream returned an error.';
34
35
  process.stdout.write(JSON.stringify(chunk) + '\n');
35
36
  return;
36
37
  }
@@ -53,7 +54,8 @@ export async function runOneShot(opts) {
53
54
  process.stderr.write(` tokens: ${chunk.input_tokens} in / ${chunk.output_tokens} out\n`);
54
55
  break;
55
56
  case 'error':
56
- process.stderr.write(`\n ✖ ${chunk.error}\n`);
57
+ streamError = chunk.error || 'The Studio stream returned an error.';
58
+ process.stderr.write(`\n ✖ ${streamError}\n`);
57
59
  break;
58
60
  case 'done':
59
61
  break;
@@ -65,32 +67,69 @@ export async function runOneShot(opts) {
65
67
  messages: [{ role: 'user', content: opts.message }],
66
68
  agentId: opts.agentId ?? null,
67
69
  }, onChunk, controller.signal);
70
+ if (controller.signal.aborted) {
71
+ if (!opts.json)
72
+ process.stdout.write('\n');
73
+ process.stderr.write(' Cancelled.\n');
74
+ process.exit(130);
75
+ }
68
76
  if (!opts.json)
69
77
  process.stdout.write('\n');
78
+ if (streamError) {
79
+ // In JSON mode the error chunk is already on stdout; also report it on stderr.
80
+ if (opts.json)
81
+ process.stderr.write(JSON.stringify({ error: { message: streamError, code: 'studio_error' } }) + '\n');
82
+ process.exit(1);
83
+ }
70
84
  if (!assistantText && !opts.json)
71
85
  process.stderr.write(' (no text response)\n');
72
86
  }
73
87
  catch (err) {
74
- process.stderr.write('\n' + formatError(err, opts.dev) + '\n');
75
- process.exit(1);
88
+ if (!opts.json)
89
+ process.stdout.write('\n');
90
+ fail(toCliError(err, opts.dev));
76
91
  }
77
92
  }
78
- function formatError(err, dev) {
93
+ function toCliError(err, dev) {
79
94
  if (err instanceof StudioHttpError) {
80
- if (err.status === 401)
81
- return '✖ Token expired or invalid. Run `plazbot login`.';
82
- if (err.status === 403)
83
- return '✖ No permission for this workspace.';
84
- if (err.status === 429)
85
- return '✖ Rate limit reached.';
95
+ const apiMessage = extractApiMessage(err.body);
96
+ if (err.status === 401) {
97
+ return new CliError(apiMessage || 'Your session is invalid or expired.', 'unauthorized', 'Run `plazbot login` to sign in again.');
98
+ }
99
+ if (err.status === 403) {
100
+ return new CliError(apiMessage || 'You do not have permission for this workspace.', 'forbidden', 'Check the active workspace with `plazbot whoami`.');
101
+ }
102
+ if (err.status === 429) {
103
+ return new CliError(apiMessage || 'Too many requests.', 'rate_limited', 'Wait a few seconds and try again.');
104
+ }
86
105
  if (err.status >= 500) {
87
- return dev
88
- ? `✖ Backend ${err.status}: ${err.body ?? err.statusText}`
89
- : `✖ Backend returned ${err.status}. Retry.`;
106
+ const detail = dev && err.body ? `: ${err.body}` : '.';
107
+ return new CliError(`Plazbot Studio error (HTTP ${err.status})${detail}`, 'api_error', 'Try again in a few seconds.');
90
108
  }
91
- return `✖ HTTP ${err.status} ${err.statusText}`;
109
+ return new CliError(apiMessage || `Studio request failed (HTTP ${err.status} ${err.statusText}).`, 'request_failed');
110
+ }
111
+ if (err instanceof CliError)
112
+ return err;
113
+ if (err instanceof Error) {
114
+ const code = err.cause?.code;
115
+ if (err.name === 'TypeError' && /fetch failed/i.test(err.message)) {
116
+ return new CliError(dev
117
+ ? `Could not reach the local backend (http://localhost:5090)${code ? ` (${code})` : ''}.`
118
+ : `Could not reach the Plazbot API${code ? ` (${code})` : ''}.`, 'network_error', dev ? 'Is the backend running?' : 'Check your internet connection.');
119
+ }
120
+ return new CliError(err.message.endsWith('.') ? err.message : `${err.message}.`);
121
+ }
122
+ return new CliError('Unknown error.');
123
+ }
124
+ function extractApiMessage(body) {
125
+ if (!body)
126
+ return undefined;
127
+ try {
128
+ const parsed = JSON.parse(body);
129
+ const msg = parsed.message ?? parsed.error;
130
+ return typeof msg === 'string' && msg ? msg : undefined;
131
+ }
132
+ catch {
133
+ return undefined;
92
134
  }
93
- if (err instanceof Error)
94
- return `✖ ${err.message}`;
95
- return '✖ Unknown error';
96
135
  }
@@ -6,6 +6,8 @@ import { dirname, join } from 'node:path';
6
6
  import { App } from './components/App.js';
7
7
  import { getStoredCredentials } from '../utils/credentials.js';
8
8
  import { logger } from '../utils/logger.js';
9
+ import { fail, CliError } from '../utils/output.js';
10
+ import { resolveStudioUserId } from './api/studioApi.js';
9
11
  // Estilo Claude Code: NO usamos alt screen buffer. Razon: en el alt screen el
10
12
  // scrollback nativo de la terminal queda inaccesible y el usuario no puede
11
13
  // revisar la conversacion anterior. Aceptamos el tradeoff de que en algunos
@@ -16,10 +18,8 @@ export async function runRepl(opts) {
16
18
  try {
17
19
  creds = await getStoredCredentials();
18
20
  }
19
- catch (err) {
20
- logger.error('No active session. Run:');
21
- console.log(' plazbot login');
22
- process.exit(1);
21
+ catch {
22
+ fail(new CliError('You are not signed in.', 'not_signed_in', 'Run `plazbot login`, or set PLAZBOT_API_KEY and PLAZBOT_WORKSPACE_ID.'));
23
23
  }
24
24
  const effectiveWorkspace = opts.workspaceOverride ?? creds.workspace;
25
25
  const supportMode = !!opts.workspaceOverride && opts.workspaceOverride !== creds.workspace;
@@ -30,7 +30,7 @@ export async function runRepl(opts) {
30
30
  const stream = {
31
31
  apiKey: creds.apiKey,
32
32
  workspaceId: effectiveWorkspace,
33
- userId: creds.userId ?? creds.email ?? undefined,
33
+ userId: resolveStudioUserId(creds),
34
34
  zone: creds.zone,
35
35
  dev: opts.dev,
36
36
  };