opencode-wyvern 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rhaegal222
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # opencode-wyvern
2
+
3
+ Setup **modulare** per OpenCode: installi tutto, attivi quello che vuoi.
4
+
5
+ Il pacchetto non contiene alcun dato privato: host, utente, chiavi SSH e
6
+ API key vengono **richiesti/creati durante il setup**, mai salvati in locale
7
+ (le API key finiscono solo in `~/.config/opencode/.env` sul server, `chmod 600`).
8
+
9
+ ## Installazione
10
+
11
+ ```bash
12
+ npm install -g opencode-wyvern
13
+ oc-setup
14
+ ```
15
+
16
+ La procedura guidata:
17
+
18
+ 1. **Moduli** — scegli quali sezioni attivare (default: tutte).
19
+ 2. **Connessione** — solo i dati necessari ai moduli scelti
20
+ (host obbligatorio solo se usi SSH/sezioni remote; coi soli client basta l'alias).
21
+ 3. **Provider / plugin / claude-mem** — solo se le sezioni corrispondenti sono attive;
22
+ endpoint e chiavi sono richieste al volo.
23
+ 4. **Applicazione** — chiave SSH, alias, profili client e bootstrap remoto.
24
+
25
+ Le sezioni restano salvate in `~/.config/opencode-wyvern/config.json`
26
+ (solo dati non sensibili) e puoi attivarle/disattivarle in seguito.
27
+
28
+ ## Comandi
29
+
30
+ ```
31
+ oc-setup avvia la procedura guidata
32
+ oc-setup generate riapplica le sezioni attive dalla config
33
+ oc-setup status mostra moduli, entry e provider configurati
34
+ oc-setup activate <sezione> attiva un modulo
35
+ oc-setup deactivate <sezione> disattiva un modulo
36
+ oc-setup print <sezione> stampa l'artefatto di una sezione
37
+ ```
38
+
39
+ ## Moduli
40
+
41
+ | Sezione | Dove | Cosa fa |
42
+ |---------------|-------|---------|
43
+ | `ssh` | locale | genera/riusa la chiave ed25519, alias in `~/.ssh/config`, install chiave sul server |
44
+ | `client-pwsh` | locale | comandi `oc-*` nel profilo PowerShell (`oc-sessions`, `oc-go`, `oc-resume`, `oc-recap`, ...) |
45
+ | `client-bash` | locale | stesso blocco in `~/.bashrc` |
46
+ | `server` | remoto | controlla node/npm, installa opencode se manca, crea `~/.config/opencode` + `AGENTS.md` |
47
+ | `commands` | remoto | comando `/baseline-ui` (`command/baseline-ui.md`) |
48
+ | `providers` | remoto | provider opencode in `opencode.json`; chiavi in `.env` |
49
+ | `plugins` | remoto | plugin npm installati nella dir config e listati in `opencode.json` |
50
+ | `claude-mem` | remoto | memoria: wrapper `plugins/claude-mem-plugin.js` |
51
+
52
+ ## Provider presets
53
+
54
+ La sezione `providers` propone (checkbox): **GitHub Copilot**, **Google Gemini**,
55
+ **OpenCode Zen**, **Anthropic Claude**, **OpenAI / Codex** e **OmniRoute**
56
+ (gateway multi-modello con supporto `auto/*`). Le chiavi vengono chieste al setup
57
+ solo per i provider che le richiedono (`GOOGLE_GENERATIVE_AI_API_KEY`,
58
+ `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) e finiscono solo nel `.env` remoto.
59
+ Per OmniRoute viene chiesto l'URL del gateway (default `http://127.0.0.1:20128`).
60
+
61
+ Il modello default è scelto automaticamente (con OmniRoute: `omniroute/auto/best-coding`,
62
+ altrimenti il primo modello del primo provider attivo), insieme a `small_model`.
63
+ Un'opzione del setup aggiunge i tuning collaudati `tool_output` e `compaction`.
64
+
65
+ ## Client
66
+
67
+ I client espongono i comandi `oc-*` e mostrano le **sessioni delle ultime 24 ore**
68
+ (finestra scorrevole). In particolare `oc-sessions` elenca le sessioni recenti,
69
+ `oc-go` apre una sessione, `oc-resume` ne riapre più di una, `oc-recap` riepiloga
70
+ a fine connessione. Richiedono `oc-setup ssh` o una `~/.ssh/config` già pronta.
71
+
72
+ ## Sviluppo
73
+
74
+ ```bash
75
+ node scripts/sync-client.mjs # applica le modifiche condivise e rigenera il template pwsh (sanitizzato)
76
+ node bin/oc-setup.js --help
77
+ ```
78
+
79
+ ## Sicurezza
80
+
81
+ - Nessuna chiave/token/password nel pacchetto o in `config.json` locale.
82
+ - Le API key sono scritte dal bootstrap remoto in `~/.config/opencode/.env` (`chmod 600`).
83
+ - I file remoti viaggiano come base64 dentro lo script; con server non raggiungibile
84
+ lo script non viene stampato a video (evita leak delle chiavi).
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import { run, cmdStatus, cmdActivate, cmdDeactivate, cmdGenerate, cmdPrint, runPrintClient } from "../src/setup.js"
3
+ import { runUninstall } from "../src/uninstall.js"
4
+
5
+ const argv = process.argv.slice(2)
6
+
7
+ const HELP = `oc-setup — OpenCode Wyvern
8
+
9
+ Setup modulare per OpenCode: installi tutto, attivi quello che vuoi.
10
+ Nessun dato privato è incluso nel pacchetto: host, utente, chiavi e
11
+ API key vengono richiesti/creati in locale durante l'esecuzione.
12
+
13
+ Uso:
14
+ oc-setup avvia la procedura guidata interattiva
15
+ oc-setup generate riapplica le sezioni attive dalla config
16
+ oc-setup uninstall disinstallazione completa (client, config, cache, alias SSH)
17
+ oc-setup status mostra i moduli e lo stato
18
+ oc-setup activate <sezione> attiva un modulo
19
+ oc-setup deactivate <sezione> disattiva un modulo
20
+ oc-setup print <sezione> stampa l'artefatto di una sezione
21
+ oc-setup --version | -v mostra la versione
22
+ oc-setup --help | -h questo aiuto
23
+
24
+ Sezioni:
25
+ ssh, client-pwsh, client-bash, server, commands, providers, plugins, claude-mem
26
+ `
27
+
28
+ if (argv.includes("--help") || argv.includes("-h")) {
29
+ console.log(HELP)
30
+ process.exit(0)
31
+ }
32
+ if (argv.includes("--version") || argv.includes("-v")) {
33
+ const meta = await import("../package.json", { with: { type: "json" } })
34
+ console.log(meta.default.version)
35
+ process.exit(0)
36
+ }
37
+
38
+ const printIdx = argv.indexOf("--print-client")
39
+ if (printIdx !== -1) {
40
+ const kind = argv[printIdx + 1]
41
+ if (kind !== "pwsh" && kind !== "bash") {
42
+ console.error("uso: oc-setup --print-client <pwsh|bash>")
43
+ process.exit(2)
44
+ }
45
+ await runPrintClient(kind)
46
+ process.exit(0)
47
+ }
48
+
49
+ const [sub, arg1] = argv
50
+ try {
51
+ switch (sub) {
52
+ case "status":
53
+ await cmdStatus()
54
+ break
55
+ case "activate":
56
+ if (!arg1) fail("uso: oc-setup activate <sezione>")
57
+ await cmdActivate(arg1)
58
+ break
59
+ case "deactivate":
60
+ if (!arg1) fail("uso: oc-setup deactivate <sezione>")
61
+ await cmdDeactivate(arg1)
62
+ break
63
+ case "generate":
64
+ await cmdGenerate()
65
+ break
66
+ case "uninstall":
67
+ await runUninstall()
68
+ break
69
+ case "print":
70
+ if (!arg1) fail("uso: oc-setup print <sezione>")
71
+ await cmdPrint(arg1)
72
+ break
73
+ case undefined:
74
+ await run(argv)
75
+ break
76
+ default:
77
+ fail(`comando sconosciuto: ${sub}`)
78
+ }
79
+ } catch (err) {
80
+ console.error(`\n[error] ${err && err.message ? err.message : err}`)
81
+ process.exit(1)
82
+ }
83
+
84
+ function fail(msg) {
85
+ console.error(`[error] ${msg}`)
86
+ process.exit(2)
87
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "opencode-wyvern",
3
+ "version": "0.1.0",
4
+ "description": "Setup modulare per OpenCode: SSH, client PowerShell/bash (comandi oc-*), configurazione server opencode, provider, plugin e comandi custom. Installi tutto, attivi quello che vuoi.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Rhaegal222",
9
+ "url": "https://github.com/Rhaegal222"
10
+ },
11
+ "engines": {
12
+ "node": ">=18"
13
+ },
14
+ "bin": {
15
+ "oc-setup": "bin/oc-setup.js"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "src",
20
+ "templates",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "opencode",
26
+ "ssh",
27
+ "setup",
28
+ "modular",
29
+ "cli",
30
+ "claude-mem",
31
+ "powershell",
32
+ "bash"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/Rhaegal222/opencode-wyvern.git"
37
+ },
38
+ "scripts": {
39
+ "test": "node bin/oc-setup.js --help"
40
+ }
41
+ }
package/src/client.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs"
2
+ import crypto from "node:crypto"
3
+ import { render, readTemplate } from "./config.js"
4
+ import { pwshProfilePath, bashRcPath } from "./shell.js"
5
+ import { c } from "./prompts.js"
6
+
7
+ export const MARKER = "# ---- OpenCode Wyvern (auto-generato) ----"
8
+ /** Marker storici: un blocco generato in passato va comunque rimosso. */
9
+ export const LEGACY_MARKERS = ["# ---- Opencode Remote (auto-generato) ----"]
10
+
11
+ /** Mappa tipo template -> file di profilo su cui appende il blocco. */
12
+ const TARGETS = {
13
+ pwsh: { tpl: "client-pwsh.ps1", path: pwshProfilePath },
14
+ bash: { tpl: "client-bash.sh", path: bashRcPath },
15
+ }
16
+
17
+ /**
18
+ * Blocco client renderizzato con i valori reali.
19
+ * Nessun dato sensibile: solo alias/host/user/dir, mai chiavi o password.
20
+ */
21
+ export function renderClient(kind, { server, host, user, dir }) {
22
+ const vars = {
23
+ OC_SERVER: server || "remote-server",
24
+ OC_HOST: host || "localhost",
25
+ OC_USER: user || "user",
26
+ OC_DIR: dir || "~",
27
+ }
28
+ const tpl = readTemplate(TARGETS[kind].tpl)
29
+ return render(tpl, vars).replace(/\r\n/g, "\n")
30
+ }
31
+
32
+ /** Sostituisce/crea il blocco generato in un profilo, con backup. */
33
+ function upsertBlock(filePath, block, { dryRun = false } = {}) {
34
+ const existed = fs.existsSync(filePath)
35
+ let content = existed ? fs.readFileSync(filePath, "utf8") : ""
36
+
37
+ if (content.trim() !== "" && !content.endsWith("\n")) content += "\n"
38
+
39
+ // rimuove ogni blocco già generato (marker attuale o legacy), sempre in coda
40
+ const all = [MARKER, ...LEGACY_MARKERS]
41
+ const hits = all.map((m) => content.indexOf(m)).filter((i) => i !== -1)
42
+ if (hits.length) {
43
+ const start = Math.min(...hits)
44
+ content = content.slice(0, start)
45
+ if (content !== "" && !content.endsWith("\n")) content += "\n"
46
+ }
47
+
48
+ const stamp = `# --- installato da oc-setup il ${new Date().toISOString().slice(0, 10)} ---`
49
+ const blockOut = block.replace(
50
+ `${MARKER}\n`,
51
+ `${MARKER}\n${stamp}\n`
52
+ )
53
+
54
+ let backup = null
55
+ if (existed && !dryRun) {
56
+ backup = `${filePath}.bak-${crypto.randomBytes(3).toString("hex")}`
57
+ fs.writeFileSync(backup, content, "utf8")
58
+ }
59
+ if (!dryRun) {
60
+ fs.writeFileSync(filePath, content + blockOut + "\n", "utf8")
61
+ }
62
+ return { filePath, backup, changed: true, dryRun }
63
+ }
64
+
65
+ /**
66
+ * Rimuove i blocchi client auto-generati (marker attuale o legacy) da un
67
+ * profilo, con backup dell'originale. Utile per la disinstallazione.
68
+ */
69
+ export function stripClientBlock(filePath) {
70
+ if (!fs.existsSync(filePath)) return { filePath, changed: false }
71
+ const content = fs.readFileSync(filePath, "utf8")
72
+ const all = [MARKER, ...LEGACY_MARKERS]
73
+ const hits = all.map((m) => content.indexOf(m)).filter((i) => i !== -1)
74
+ if (!hits.length) return { filePath, changed: false }
75
+ const start = Math.min(...hits)
76
+ const stripped = content.slice(0, start).replace(/\s+$/, "")
77
+ const backup = `${filePath}.bak-${crypto.randomBytes(3).toString("hex")}`
78
+ fs.writeFileSync(backup, content, "utf8")
79
+ fs.writeFileSync(filePath, stripped ? stripped + "\n" : "", "utf8")
80
+ return { filePath, backup, changed: true }
81
+ }
82
+
83
+ /**
84
+ * Installa i comandi client `oc-*` nei profili richiesti.
85
+ * `onlyPrint` (null | "pwsh" | "bash") stampa il blocco renderizzato
86
+ * senza modificare file. `targets` è l'array dei tipi da installare.
87
+ */
88
+ export async function installClient(cfg, { targets = ["pwsh", "bash"], onlyPrint = null } = {}) {
89
+ if (onlyPrint) {
90
+ const kind = onlyPrint === "bash" ? "bash" : "pwsh"
91
+ console.log(renderClient(kind, cfg))
92
+ return { printed: true, kind }
93
+ }
94
+
95
+ const results = []
96
+ for (const t of targets) {
97
+ const target = TARGETS[t]
98
+ if (!target) continue
99
+ const block = renderClient(t, cfg)
100
+ results.push(upsertBlock(target.path(), block))
101
+ }
102
+
103
+ console.log(c.green("\nClient installato:"))
104
+ for (const r of results) {
105
+ console.log(` - ${r.filePath}${r.backup ? ` (backup: ${r.backup})` : ""}`)
106
+ }
107
+ console.log(c.dim("\nRicarica il profilo con: . $PROFILE (PowerShell) / source ~/.bashrc (bash)"))
108
+ return { installed: results }
109
+ }
package/src/config.js ADDED
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { configDir, configFilePath } from "./shell.js"
5
+
6
+ /**
7
+ * Configurazione persistente della CLI.
8
+ *
9
+ * NB: NON contiene mai chiavi API né password. I segreti API vengono scritti
10
+ * esclusivamente nel file `.env` sul SERVER (chmod 600), mai qui.
11
+ */
12
+ export function loadConfig() {
13
+ try {
14
+ const f = configFilePath()
15
+ if (!fs.existsSync(f)) return {}
16
+ return JSON.parse(fs.readFileSync(f, "utf8"))
17
+ } catch {
18
+ return {}
19
+ }
20
+ }
21
+
22
+ export function saveConfig(cfg) {
23
+ fs.mkdirSync(configDir(), { recursive: true })
24
+ fs.writeFileSync(configFilePath(), JSON.stringify(cfg, null, 2) + "\n", "utf8")
25
+ try {
26
+ fs.chmodSync(configFilePath(), 0o600)
27
+ } catch {
28
+ /* non critico */
29
+ }
30
+ }
31
+
32
+ export function maskSecret(value) {
33
+ if (!value) return value
34
+ if (value.length <= 8) return "*".repeat(value.length)
35
+ return value.slice(0, 4) + "*".repeat(Math.max(4, value.length - 8)) + value.slice(-4)
36
+ }
37
+
38
+ export function ensureDir(p) {
39
+ fs.mkdirSync(p, { recursive: true })
40
+ return p
41
+ }
42
+
43
+ export function templatePath(name) {
44
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates", name)
45
+ }
46
+
47
+ export function readTemplate(name) {
48
+ const p = templatePath(name)
49
+ if (!fs.existsSync(p)) {
50
+ throw new Error(`template mancante: ${name}`)
51
+ }
52
+ return fs.readFileSync(p, "utf8")
53
+ }
54
+
55
+ /**
56
+ * Sostituisce i segnaposto `__KEY__` con i valori forniti.
57
+ */
58
+ export function render(template, vars) {
59
+ let out = template
60
+ for (const [key, value] of Object.entries(vars)) {
61
+ out = out.split(`__${key.toUpperCase()}__`).join(String(value))
62
+ }
63
+ return out
64
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,164 @@
1
+ import { stdin as input, stdout as output } from "node:process"
2
+
3
+ const color = (code, s) => `\x1b[${code}m${s}\x1b[0m`
4
+ export const c = {
5
+ dim: (s) => color(90, s),
6
+ cyan: (s) => color(36, s),
7
+ green: (s) => color(32, s),
8
+ yellow: (s) => color(33, s),
9
+ red: (s) => color(31, s),
10
+ bold: (s) => color(1, s),
11
+ }
12
+
13
+ function enableRaw() {
14
+ try {
15
+ input.setRawMode(true)
16
+ input.resume()
17
+ input.setEncoding("utf8")
18
+ return true
19
+ } catch {
20
+ return false // stdin non interattivo
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Fallback senza TTY: legge lo stdin a righe e le mette in coda, così
26
+ * più domande successive consumano le risposte in ordine (utile per
27
+ * piping/test e ambiente completamente non interattivo).
28
+ */
29
+ const fallbackQueue = []
30
+
31
+ function readLineFallback(done) {
32
+ const next = () => {
33
+ if (fallbackQueue.length) {
34
+ const v = fallbackQueue.shift()
35
+ return done(v)
36
+ }
37
+ const onData = (chunk) => {
38
+ const text = chunk.toString()
39
+ const parts = text.split(/\r\n|\r|\n/)
40
+ fallbackQueue.push(...parts)
41
+ input.removeListener("data", onData)
42
+ next()
43
+ }
44
+ input.on("data", onData)
45
+ input.resume()
46
+ }
47
+ next()
48
+ }
49
+
50
+ /** Legge una riga da tastiera. `masked` oscura la digitazione (per segreti); */
51
+ /** gestisce backspace e Ctrl+C. */
52
+ export function readLine({ masked = false, allowEmpty = false } = {}, done) {
53
+ if (!enableRaw()) {
54
+ readLineFallback(done)
55
+ return
56
+ }
57
+
58
+ let buf = ""
59
+ const onData = (chunk) => {
60
+ for (const ch of chunk) {
61
+ if (ch === "\r" || ch === "\n") {
62
+ if (!allowEmpty && buf === "") continue
63
+ input.removeListener("data", onData)
64
+ input.setRawMode(false)
65
+ input.pause()
66
+ output.write("\n")
67
+ return done(buf)
68
+ }
69
+ if (ch === "\u0003") {
70
+ input.setRawMode(false)
71
+ input.pause()
72
+ process.exit(130)
73
+ }
74
+ if (ch === "\u007f" || ch === "\b") {
75
+ if (buf.length) {
76
+ buf = buf.slice(0, -1)
77
+ output.write("\b \b")
78
+ }
79
+ } else {
80
+ buf += ch
81
+ output.write(masked ? "*" : ch)
82
+ }
83
+ }
84
+ }
85
+ input.on("data", onData)
86
+ }
87
+
88
+ /** Domanda a testo semplice con default opzionale. */
89
+ export async function ask(question, { defaultValue = "", hint = "", validate } = {}) {
90
+ const suffix = hint ? ` ${c.dim(`(${hint})`)}` : ""
91
+ const def = defaultValue ? c.dim(`[${defaultValue}]`) : ""
92
+ // eslint-disable-next-line no-constant-condition
93
+ for (;;) {
94
+ output.write(`${c.cyan("?")} ${question}${suffix} ${def}\n> `)
95
+ const raw = await new Promise((resolve) => readLine({}, resolve))
96
+ const value = raw.trim() === "" ? defaultValue : raw.trim()
97
+ if (validate && !validate(value)) {
98
+ console.log(c.yellow(" risposta non valida, riprova."))
99
+ continue
100
+ }
101
+ return value
102
+ }
103
+ }
104
+
105
+ /** Domanda sì/no con default indicato in maiuscolo. */
106
+ export async function confirm(question, defaultValue = false) {
107
+ const hint = defaultValue ? "S/n" : "s/N"
108
+ // eslint-disable-next-line no-constant-condition
109
+ for (;;) {
110
+ output.write(`${c.cyan("?")} ${question} ${c.dim(`(${hint})`)}\n> `)
111
+ const a = (await new Promise((resolve) => readLine({}, resolve))).trim().toLowerCase()
112
+ if (a === "") return defaultValue
113
+ if (/^(s|si|sì|y|yes)$/.test(a)) return true
114
+ if (/^(n|no)$/.test(a)) return false
115
+ console.log(c.yellow(" rispondi s o n."))
116
+ }
117
+ }
118
+
119
+ /** Selezione singola (indice numerico). */
120
+ export async function select(question, choices, { defaultValue = 0 } = {}) {
121
+ console.log(`\n${c.cyan("?")} ${question}`)
122
+ choices.forEach((choice, i) => {
123
+ console.log(` ${i + 1}. ${choice.label}`)
124
+ })
125
+ return choices[Number(await ask("Scelta (numero)", {
126
+ defaultValue: String(defaultValue + 1),
127
+ validate: (v) => /^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= choices.length,
128
+ })) - 1]
129
+ }
130
+
131
+ /** Selezione multipla: numeri separati da virgola/spazio (es. "1,3,5"); invio = nessuna. */
132
+ export async function checkbox(question, choices, { defaultIndices = [] } = {}) {
133
+ console.log(`\n${c.cyan("?")} ${question} ${c.dim("(invio = nessuno; es. 1,3,5)")}`)
134
+ choices.forEach((choice, i) => {
135
+ const sel = defaultIndices.includes(i) ? "•" : " "
136
+ console.log(` ${sel} ${i + 1}. ${choice.label}`)
137
+ })
138
+ const answer = await ask("Seleziona (es. 1,3,5)", {
139
+ validate: (v) => {
140
+ const nums = v.split(/[\s,]+/).filter(Boolean)
141
+ if (!nums.length) return true // invio = nessuno (salta)
142
+ return nums.every((n) => /^\d+$/.test(n) && Number(n) >= 1 && Number(n) <= choices.length)
143
+ },
144
+ })
145
+ const nums = answer.split(/[\s,]+/).filter(Boolean)
146
+ if (!nums.length) return []
147
+ return [...new Set(nums.map((n) => Number(n) - 1))].map((i) => choices[i])
148
+ }
149
+
150
+ /** Input mascherato per segreti (API key ecc.). */
151
+ export async function secret(question, { allowEmpty = false } = {}) {
152
+ output.write(`${c.cyan("?")} ${question}\n> `)
153
+ return new Promise((resolve) => readLine({ masked: true, allowEmpty }, (v) => resolve(v)))
154
+ }
155
+
156
+ export function closePrompts() {
157
+ input.removeAllListeners("data")
158
+ try {
159
+ input.setRawMode(false)
160
+ } catch {
161
+ /* ok */
162
+ }
163
+ input.pause()
164
+ }