supcoflow 0.0.0-stage → 0.1.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.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # supcoflow
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ La terminal de [Supcoflow](https://app.supcoflow.dev): iniciar sesión y manejar el chat de soporte
4
+ desde la consola, o desde un asistente de código que corre comandos por vos.
5
+
6
+ Por ahora trae lo básico: iniciar y cerrar sesión, y ver con qué cuenta estás.
7
+
8
+ ## Instalar
9
+
10
+ Hace falta Node 24 o más nuevo.
11
+
12
+ ```bash
13
+ npx supcoflow login
14
+ ```
15
+
16
+ ## Los comandos
17
+
18
+ | Comando | Qué hace |
19
+ |---|---|
20
+ | `supcoflow login` | Te muestra un enlace y un código; lo aprobás en la consola y la terminal queda con su clave. |
21
+ | `supcoflow login --start` | El primer paso, solo: pide el código y sale 5 con el enlace para aprobarlo. |
22
+ | `supcoflow login --wait` | El segundo paso: espera la aprobación hasta 90 s. Si todavía no llegó, sale 5: volvé a correrlo. |
23
+ | `supcoflow logout` | Revoca la clave en uso y la borra de esta máquina. |
24
+ | `supcoflow quien-soy` | Con qué clave anda la terminal, a nombre de quién y en qué equipo. |
25
+ | `supcoflow describir --json` | Todo el contrato (comandos, flags, facultades de la clave, códigos) en una línea de JSON. |
26
+
27
+ `supcoflow --help` (o `supcoflow <comando> --help`) muestra la ayuda con ejemplos.
28
+
29
+ ### Ejemplos
30
+
31
+ ```bash
32
+ # Una persona frente a la terminal
33
+ supcoflow login
34
+
35
+ # Un asistente, sin terminal interactiva: dos pasos
36
+ supcoflow login --start --json # sale 5 con verification_uri_complete y user_code
37
+ supcoflow login --wait --json # sale 5 mientras la persona no apruebe; 0 cuando aprueba
38
+
39
+ supcoflow quien-soy --json
40
+ supcoflow logout --dry-run # qué revocaría, sin revocar nada
41
+ ```
42
+
43
+ ## El contrato
44
+
45
+ Pensado para que lo lea un programa, no sólo una persona:
46
+
47
+ - **`--json`**: el resultado sale por stdout en **una línea** de JSON; los avisos van por stderr.
48
+ Los errores también: `{"ok":false,"error":"…","message":"…"}`, con su código de salida.
49
+ - **Sin preguntas sin terminal interactiva** (o con `--json`): si falta un dato, sale 3 diciendo qué
50
+ flag usar. `supcoflow login` sin terminal sale 3 y te indica el camino en dos pasos.
51
+ - **`--dry-run`** donde se puede simular (`login`, `login --start`, `logout`). `login --wait
52
+ --dry-run` sale 3: sondear consume el código y no hay cómo simularlo.
53
+
54
+ | Código | Quiere decir |
55
+ |---|---|
56
+ | 0 | ok |
57
+ | 1 | inesperado |
58
+ | 2 | falta login |
59
+ | 3 | entrada inválida |
60
+ | 4 | hace falta una persona |
61
+ | 5 | esperando a una persona: volvé a llamar |
62
+ | 6 | tope alcanzado |
63
+
64
+ ## Dónde queda la clave
65
+
66
+ - En el **llavero del sistema** (Keychain en macOS, Secret Service con `secret-tool` en Linux).
67
+ - Si no hay llavero, en `~/.config/supcoflow/credenciales.json` (o `$XDG_CONFIG_HOME/supcoflow/…`)
68
+ con permisos `0600`, y la terminal te avisa.
69
+ - **Nunca dentro de un repo de git**: si ese archivo cayera dentro de uno (un `HOME` que es un repo
70
+ de dotfiles, por ejemplo), no guarda nada y te pide usar el llavero o `SUPCOFLOW_TOKEN`.
71
+ - **`SUPCOFLOW_TOKEN`** tiene prioridad sobre lo guardado: sirve para CI y para la nube.
72
+ - La clave nunca sale en un log ni en una salida `--json`.
73
+
74
+ Cuando iniciás sesión de nuevo, la clave anterior se revoca recién cuando la nueva quedó guardada; si
75
+ no se pudo revocar, queda anotada y se reintenta en el próximo `login` o `logout`.
76
+
77
+ ## La API es fija
78
+
79
+ La terminal habla siempre con `https://app.supcoflow.dev`. Ningún archivo del repo puede cambiar
80
+ eso, así que clonar un repo ajeno y correr `supcoflow` no manda tu clave a otro servidor. Para
81
+ desarrollo, sólo la variable de entorno `SUPCOFLOW_API_URL` la mueve (`https`, o `http` contra
82
+ `localhost`), y lo guardado queda atado a la API que lo emitió: una clave de un motor local nunca
83
+ viaja a producción.
package/dist/api.js ADDED
@@ -0,0 +1,133 @@
1
+ // Los cuatro pedidos de la terminal al motor. Sin dependencias: `fetch` viene de Node y se inyecta.
2
+ import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
3
+ // La URL viene FIJA (§5.2): si un archivo del repo pudiera cambiarla, clonar un repo ajeno y correr
4
+ // `supcoflow` mandaría el token a un servidor ajeno. Sólo el entorno del proceso la mueve, y `http`
5
+ // únicamente contra la propia máquina: contra un host remoto la clave viajaría en claro.
6
+ export const API_DE_PRODUCCION = "https://app.supcoflow.dev";
7
+ const LOCALES = new Set(["localhost", "127.0.0.1", "[::1]"]);
8
+ export function urlDeLaApi(env) {
9
+ const crudo = env.SUPCOFLOW_API_URL;
10
+ if (crudo === undefined || crudo === "")
11
+ return new URL(API_DE_PRODUCCION);
12
+ const invalida = () => new FalloDeLaTerminal(SALIDA.entradaInvalida, "api_url_invalida", "SUPCOFLOW_API_URL tiene que ser https, o http contra localhost.");
13
+ let u;
14
+ try {
15
+ u = new URL(crudo);
16
+ }
17
+ catch {
18
+ throw invalida();
19
+ }
20
+ if (u.protocol === "https:" || (u.protocol === "http:" && LOCALES.has(u.hostname)))
21
+ return new URL(u.origin);
22
+ throw invalida();
23
+ }
24
+ // A mano a propósito: tiene que coincidir con package.json (0.1.0); sólo identifica a la terminal
25
+ // en los logs del motor, no decide nada.
26
+ const VERSION = "0.1.0";
27
+ const PLAZO_POR_DEFECTO_MS = 15_000;
28
+ // Todo lo que vuelve de afuera se trata como no confiable: nunca se pega el cuerpo crudo en un
29
+ // mensaje (una página HTML no sirve en una línea y podría traer cualquier cosa), y el mensaje del
30
+ // error de red no se reenvía (podría llevar la URL o la clave).
31
+ async function pedir(api, ruta, init, f, plazoMs) {
32
+ let res;
33
+ let texto;
34
+ try {
35
+ res = await f(new URL(ruta, api), {
36
+ ...init,
37
+ headers: { ...init.headers, "user-agent": `supcoflow-cli/${VERSION}` },
38
+ signal: AbortSignal.timeout(plazoMs ?? PLAZO_POR_DEFECTO_MS),
39
+ // Un 307/308 reenvía el cuerpo del formulario (con el device_code) adonde diga `Location`, aunque
40
+ // sea otro origen o http. Los cuatro endpoints del motor nunca redirigen: una redirección es
41
+ // anómala y sale como `motor_inalcanzable`, sin Location ni cuerpo en el mensaje.
42
+ redirect: "error",
43
+ });
44
+ texto = await res.text();
45
+ }
46
+ catch {
47
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "motor_inalcanzable", "No pude comunicarme con el motor (red caída o demora).");
48
+ }
49
+ let cuerpo;
50
+ try {
51
+ cuerpo = JSON.parse(texto);
52
+ }
53
+ catch {
54
+ cuerpo = undefined;
55
+ }
56
+ if (typeof cuerpo !== "object" || cuerpo === null || Array.isArray(cuerpo)) {
57
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "respuesta_inesperada", `El motor respondió ${res.status} sin JSON.`);
58
+ }
59
+ return { status: res.status, cuerpo: cuerpo };
60
+ }
61
+ function sinServicio(status) {
62
+ return new FalloDeLaTerminal(SALIDA.inesperado, "motor_inalcanzable", `El motor respondió ${status}; probá en unos minutos.`);
63
+ }
64
+ function inesperada(status) {
65
+ return new FalloDeLaTerminal(SALIDA.inesperado, "respuesta_inesperada", `El motor respondió ${status} con algo que no esperaba.`);
66
+ }
67
+ const texto = (v) => (typeof v === "string" ? v : null);
68
+ const CLIENT_ID = "supcoflow-cli";
69
+ const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
70
+ const FORMULARIO = { "content-type": "application/x-www-form-urlencoded" };
71
+ export async function pedirCodigo(api, maquina, f) {
72
+ const { status, cuerpo: c } = await pedir(api, "/api/dispositivo/codigo", { method: "POST", headers: FORMULARIO, body: new URLSearchParams({ client_id: CLIENT_ID, maquina }).toString() }, f);
73
+ if (status === 429 || status === 503) {
74
+ throw new FalloDeLaTerminal(SALIDA.inesperado, texto(c.error) ?? "demasiados_pedidos", "El motor no acepta más pedidos ahora; probá en unos minutos.");
75
+ }
76
+ if (status >= 500)
77
+ throw sinServicio(status);
78
+ const [deviceCode, userCode, verificationUri, verificationUriComplete] = [c.device_code, c.user_code, c.verification_uri, c.verification_uri_complete].map(texto);
79
+ if (status !== 200 || !deviceCode || !userCode || !verificationUri || !verificationUriComplete
80
+ || typeof c.expires_in !== "number" || typeof c.interval !== "number")
81
+ throw inesperada(status);
82
+ return { deviceCode, userCode, verificationUri, verificationUriComplete, expiresIn: c.expires_in, interval: c.interval };
83
+ }
84
+ const ERRORES_DEL_CANJE = {
85
+ authorization_pending: "pendiente", slow_down: "slow_down", access_denied: "rechazado",
86
+ expired_token: "vencido", invalid_grant: "invalido",
87
+ };
88
+ // `plazoMs` es el techo de ESTE pedido: el sondeo lo achica cerca del final de su tramo para que
89
+ // un pedido colgado no lo pase de largo.
90
+ export async function canjear(api, deviceCode, f, plazoMs) {
91
+ const { status, cuerpo: c } = await pedir(api, "/api/dispositivo/token", { method: "POST", headers: FORMULARIO, body: new URLSearchParams({ grant_type: GRANT_TYPE, device_code: deviceCode, client_id: CLIENT_ID }).toString() }, f, plazoMs);
92
+ if (status >= 500)
93
+ throw sinServicio(status);
94
+ if (status === 200) {
95
+ const clave = texto(c.access_token);
96
+ const equipo = texto(c.equipo);
97
+ if (!clave || !equipo)
98
+ throw inesperada(status);
99
+ return { tipo: "token", clave, equipo };
100
+ }
101
+ const tipo = ERRORES_DEL_CANJE[texto(c.error) ?? ""];
102
+ if (status === 400 && tipo)
103
+ return { tipo };
104
+ throw inesperada(status);
105
+ }
106
+ const bearer = (clave) => ({ authorization: `Bearer ${clave}` });
107
+ export async function quienSoy(api, clave, f) {
108
+ const { status, cuerpo: c } = await pedir(api, "/api/yo", { method: "GET", headers: bearer(clave) }, f);
109
+ if (status === 401) {
110
+ throw new FalloDeLaTerminal(SALIDA.faltaLogin, "clave_invalida", texto(c.message) ?? "La clave ya no vale: iniciá sesión de nuevo.");
111
+ }
112
+ if (status >= 500)
113
+ throw sinServicio(status);
114
+ const u = c.usuario;
115
+ if (status !== 200 || !texto(c.via) || !texto(c.equipo))
116
+ throw inesperada(status);
117
+ return {
118
+ via: c.via,
119
+ equipo: c.equipo,
120
+ usuario: u ? { id: String(u.id), login: texto(u.login), email: texto(u.email) } : null,
121
+ proyecto: texto(c.proyecto),
122
+ };
123
+ }
124
+ export async function revocarPropia(api, clave, f, plazoMs) {
125
+ const { status } = await pedir(api, "/api/dispositivo/revocar", { method: "POST", headers: bearer(clave) }, f, plazoMs);
126
+ if (status === 200)
127
+ return "revocada";
128
+ if (status === 401)
129
+ return "ya_no_valia";
130
+ if (status >= 500)
131
+ throw sinServicio(status);
132
+ throw inesperada(status);
133
+ }
@@ -0,0 +1,43 @@
1
+ import { parseArgs } from "node:util";
2
+ import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
3
+ // strict: un flag mal escrito (`--estart`) no se ignora en silencio: un asistente creería que
4
+ // pidió algo que no pidió.
5
+ export function leerArgumentos(argv) {
6
+ let parsed;
7
+ try {
8
+ parsed = parseArgs({
9
+ args: argv,
10
+ strict: true,
11
+ allowPositionals: true,
12
+ options: {
13
+ json: { type: "boolean" },
14
+ "dry-run": { type: "boolean" },
15
+ help: { type: "boolean", short: "h" },
16
+ start: { type: "boolean" },
17
+ wait: { type: "boolean" },
18
+ },
19
+ });
20
+ }
21
+ catch (e) {
22
+ const code = e.code;
23
+ if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
24
+ const m = /'(--?[^']+)'/.exec(e.message);
25
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "flag_desconocido", `No conozco el flag ${m ? m[1] : "que pasaste"}. Probá \`supcoflow --help\`.`);
26
+ }
27
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", `${e.message} Probá \`supcoflow --help\`.`);
28
+ }
29
+ if (parsed.positionals.length > 1) {
30
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_de_mas", "Pasaste más de un comando. Probá `supcoflow --help`.");
31
+ }
32
+ const v = parsed.values;
33
+ return {
34
+ comando: parsed.positionals[0] ?? null,
35
+ flags: {
36
+ json: v.json === true,
37
+ dryRun: v["dry-run"] === true,
38
+ help: v.help === true,
39
+ start: v.start === true,
40
+ wait: v.wait === true,
41
+ },
42
+ };
43
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // El binario `supcoflow`: lee los argumentos, arma el entorno real y despacha. Todo va dentro de un
3
+ // solo try/catch: cualquier cosa que se escape (un archivo de credenciales ilegible, un llavero roto)
4
+ // sale como un 1 limpio, en una línea si se pidió --json, sin traza y con los secretos tapados.
5
+ import { revocarPropia } from "./api.js";
6
+ import { leerArgumentos } from "./argumentos.js";
7
+ import { ayuda, comandoDescribir, DESCRIPCION } from "./comandos/describir.js";
8
+ import { loginInteractivo, loginStart, loginWait } from "./comandos/login.js";
9
+ import { comandoLogout } from "./comandos/logout.js";
10
+ import { comandoQuienSoy } from "./comandos/quien-soy.js";
11
+ import { emitir, FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "./contrato.js";
12
+ import { entornoReal } from "./entorno.js";
13
+ const COMANDOS = new Set(DESCRIPCION.comandos.map((c) => c.nombre));
14
+ const invalida = (error, message) => new FalloDeLaTerminal(SALIDA.entradaInvalida, error, message);
15
+ async function correr(argv) {
16
+ // Si los argumentos no se pueden leer no se sabe qué flags hay: se mira `--json` a mano para que
17
+ // el error salga en el formato que pidió quien llama.
18
+ let json = argv.includes("--json");
19
+ try {
20
+ const { comando, flags } = leerArgumentos(argv);
21
+ json = flags.json;
22
+ if (comando !== null && !COMANDOS.has(comando)) {
23
+ throw invalida("comando_desconocido", `No conozco el comando «${comando}». Probá \`supcoflow --help\`.`);
24
+ }
25
+ if (flags.help || comando === null) {
26
+ const texto = ayuda(comando);
27
+ return { r: { codigo: SALIDA.ok, json: { ok: true, ayuda: texto }, texto }, json };
28
+ }
29
+ if ((flags.start || flags.wait) && comando !== "login") {
30
+ throw invalida("flag_no_aplica", "--start y --wait son sólo de `supcoflow login`.");
31
+ }
32
+ if (comando === "describir")
33
+ return { r: comandoDescribir(), json };
34
+ if (comando === "login") {
35
+ if (flags.start && flags.wait) {
36
+ throw invalida("flags_incompatibles", "--start y --wait van por separado: primero uno y después el otro.");
37
+ }
38
+ // ANTES de armar el entorno: no hay cómo simular un canje sin consumirlo, y este error no
39
+ // puede depender de leer nada guardado ni de llamar al motor.
40
+ if (flags.wait && flags.dryRun) {
41
+ throw invalida("dry_run_no_soportado", "`login --wait` no tiene --dry-run: sondear consume el código. Probá `supcoflow login --start --dry-run`.");
42
+ }
43
+ }
44
+ const e = entornoReal();
45
+ switch (comando) {
46
+ case "login":
47
+ if (flags.start)
48
+ return { r: await loginStart(e.login, flags), json };
49
+ if (flags.wait)
50
+ return { r: await loginWait(e.login), json };
51
+ // Sin TTY, `login --dry-run` igual tiene algo que decir: lo que haría el primer paso.
52
+ if (flags.dryRun)
53
+ return { r: await loginStart(e.login, { dryRun: true }), json };
54
+ return { r: await loginInteractivo({ ...e.login, esTty: e.esTty, mostrar: e.mostrar }, flags), json };
55
+ case "logout":
56
+ return {
57
+ r: await comandoLogout({
58
+ api: e.api, guardado: e.guardado, ahora: e.login.ahora, avisar: e.avisar,
59
+ revocarPropia: (a, clave, plazoMs) => revocarPropia(a, clave, fetch, plazoMs),
60
+ }, flags),
61
+ json,
62
+ };
63
+ case "quien-soy":
64
+ return { r: await comandoQuienSoy({ api: e.api, guardado: e.guardado, quienSoy: e.quienSoy, avisar: e.avisar }), json };
65
+ }
66
+ throw new Error(`comando sin despacho: ${comando}`);
67
+ }
68
+ catch (e) {
69
+ return { r: resultadoDeFallo(e), json };
70
+ }
71
+ }
72
+ // `process.exitCode` y nunca `process.exit()`: éste corta el proceso con stdout a medio escribir
73
+ // cuando la salida va a un pipe, y quien llama leería una línea de JSON partida.
74
+ const { r, json } = await correr(process.argv.slice(2));
75
+ emitir(r, json, (s) => void process.stdout.write(s));
76
+ process.exitCode = r.codigo;
@@ -0,0 +1,106 @@
1
+ // `supcoflow describir --json`: el contrato entero en una línea (§13), para que un asistente sepa
2
+ // qué comandos hay, con qué flags, qué puede la clave y qué quiere decir cada código, sin leer la
3
+ // ayuda pensada para personas. La ayuda se arma desde acá: una sola fuente, así no se desfasan.
4
+ import { SALIDA } from "../contrato.js";
5
+ // A mano a propósito, como en api.ts: tiene que coincidir con package.json (lo pinza un test).
6
+ const VERSION = "0.1.0";
7
+ export const DESCRIPCION = {
8
+ version: VERSION,
9
+ comandos: [
10
+ {
11
+ nombre: "login",
12
+ resumen: "Inicia sesión aprobando un código desde la consola. Sin terminal interactiva, en dos pasos: --start y después --wait.",
13
+ flags: [
14
+ "--start: pide el código y sale 5 con la URL para aprobarlo",
15
+ "--wait: espera la aprobación hasta 90 s; sale 5 si todavía no llegó (volvé a llamarlo)",
16
+ "--dry-run: dice qué haría sin pedir nada (no vale con --wait)",
17
+ "--json",
18
+ ],
19
+ ejemplos: ["supcoflow login", "supcoflow login --start --json", "supcoflow login --wait --json"],
20
+ codigos: [0, 1, 2, 3, 4, 5],
21
+ },
22
+ {
23
+ nombre: "logout",
24
+ resumen: "Revoca la clave en uso y la borra de esta máquina; reintenta las que quedaron por revocar.",
25
+ flags: ["--dry-run: dice qué revocaría sin revocar nada", "--json"],
26
+ ejemplos: ["supcoflow logout", "supcoflow logout --json"],
27
+ codigos: [0, 1, 3],
28
+ },
29
+ {
30
+ nombre: "quien-soy",
31
+ resumen: "Muestra con qué clave anda la terminal, a nombre de quién y en qué equipo.",
32
+ flags: ["--json"],
33
+ ejemplos: ["supcoflow quien-soy", "supcoflow quien-soy --json"],
34
+ codigos: [0, 1, 2, 3],
35
+ },
36
+ {
37
+ nombre: "describir",
38
+ resumen: "Describe comandos, flags, facultades y códigos de salida, para un asistente.",
39
+ flags: ["--json"],
40
+ ejemplos: ["supcoflow describir --json"],
41
+ codigos: [0],
42
+ },
43
+ ],
44
+ flags_globales: ["--json: el resultado en una línea de JSON por stdout; los avisos van por stderr", "--help, -h: la ayuda con ejemplos"],
45
+ facultades: [
46
+ {
47
+ nombre: "consultar",
48
+ incluye: "Identidad y equipo, proyectos, configuración, casos, issues, runs, costos, estado de la instalación",
49
+ con_api_key: true,
50
+ },
51
+ {
52
+ nombre: "configurar",
53
+ incluye: "Crear/reusar proyecto, conectar o desconectar el repo, configurar y prender el chat, sitios permitidos, acuerdo de datos, política de problemas, publicar la guía, crear y revocar claves de consulta, perfiles de los agentes",
54
+ con_api_key: true,
55
+ },
56
+ { nombre: "probar", incluye: "La prueba hasta el issue (gasta, dentro de los topes)", con_api_key: true },
57
+ {
58
+ nombre: "solo_consola",
59
+ incluye: "Subir el tope de gasto, borrar el proyecto, encender la fábrica, dar o sacar acceso a personas: sólo con sesión en la consola",
60
+ con_api_key: false,
61
+ },
62
+ {
63
+ nombre: "operador",
64
+ incluye: "Solicitudes, /maquinaria, cerrar runs, la fábrica de operador: nunca por clave, aunque la persona sea operadora",
65
+ con_api_key: false,
66
+ },
67
+ ],
68
+ codigos: {
69
+ [SALIDA.ok]: "ok",
70
+ [SALIDA.inesperado]: "inesperado",
71
+ [SALIDA.faltaLogin]: "falta login",
72
+ [SALIDA.entradaInvalida]: "entrada inválida",
73
+ [SALIDA.haceFaltaUnaPersona]: "hace falta una persona",
74
+ [SALIDA.esperandoAUnaPersona]: "esperando a una persona (volvé a llamar)",
75
+ [SALIDA.topeAlcanzado]: "tope alcanzado",
76
+ },
77
+ };
78
+ export function comandoDescribir() {
79
+ return { codigo: SALIDA.ok, json: { ok: true, ...DESCRIPCION }, texto: ayuda(null) };
80
+ }
81
+ function bloque(c) {
82
+ return [
83
+ ` ${c.nombre}: ${c.resumen}`,
84
+ ...c.flags.map((f) => ` ${f}`),
85
+ ].join("\n");
86
+ }
87
+ export function ayuda(comando) {
88
+ const elegidos = comando === null ? DESCRIPCION.comandos : DESCRIPCION.comandos.filter((c) => c.nombre === comando);
89
+ const uso = comando === null ? "supcoflow <comando> [flags]" : `supcoflow ${comando} [flags]`;
90
+ const codigos = Object.entries(DESCRIPCION.codigos).map(([n, t]) => ` ${n} ${t}`);
91
+ return [
92
+ `Uso: ${uso}`,
93
+ "",
94
+ comando === null ? "Comandos:" : "Comando:",
95
+ ...elegidos.map(bloque),
96
+ "",
97
+ "Flags de todos los comandos:",
98
+ ...DESCRIPCION.flags_globales.map((f) => ` ${f}`),
99
+ "",
100
+ "Ejemplos:",
101
+ ...elegidos.flatMap((c) => c.ejemplos.map((e) => ` ${e}`)),
102
+ "",
103
+ "Códigos de salida:",
104
+ ...codigos,
105
+ ].join("\n");
106
+ }