vipcode 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/shell.js ADDED
@@ -0,0 +1,107 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import crossSpawn from "cross-spawn";
5
+ // Lanza un programa. En Windows npm instala los programas como .cmd, que spawn no puede lanzar directamente:
6
+ // cross-spawn los lanza a través de cmd.exe con las comillas bien puestas
7
+ export function spawnProgram(command, args, options) {
8
+ if (process.platform === "win32") {
9
+ return crossSpawn(command, args, options);
10
+ }
11
+ return spawn(command, args, options);
12
+ }
13
+ // Para un programa y todo lo que haya lanzado. En Windows no hay grupos de procesos: taskkill /T recorre los hijos
14
+ export function killTree(child) {
15
+ if (process.platform === "win32" && child.pid) {
16
+ const taskkill = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
17
+ taskkill.on("error", () => child.kill());
18
+ return;
19
+ }
20
+ child.kill("SIGTERM");
21
+ }
22
+ // Separa los argumentos respetando las comillas: a "b c" d → [a, b c, d]
23
+ export function splitArgs(text) {
24
+ const result = [];
25
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
26
+ let match;
27
+ while ((match = pattern.exec(text)) !== null) {
28
+ result.push(match[1] ?? match[2] ?? match[3]);
29
+ }
30
+ return result;
31
+ }
32
+ function onPath(name, env) {
33
+ for (const dir of (env.PATH || env.Path || "").split(path.delimiter)) {
34
+ if (dir && fs.existsSync(path.join(dir, name))) {
35
+ return path.join(dir, name);
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ // El bash de Git para Windows. No vale el de C:\Windows\System32, que es el de WSL y trabaja en otro sistema de ficheros
41
+ function gitBash(env) {
42
+ const candidates = [];
43
+ const git = onPath("git.exe", env);
44
+ if (git) {
45
+ // git.exe está en …\Git\cmd, …\Git\bin o …\Git\mingw64\bin
46
+ candidates.push(path.resolve(path.dirname(git), "..", "bin", "bash.exe"), path.resolve(path.dirname(git), "..", "..", "bin", "bash.exe"));
47
+ }
48
+ const local = env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "Programs") : undefined;
49
+ for (const base of [env.ProgramFiles, env.ProgramW6432, env["ProgramFiles(x86)"], local]) {
50
+ if (base) {
51
+ candidates.push(path.join(base, "Git", "bin", "bash.exe"));
52
+ }
53
+ }
54
+ return candidates.find((file) => fs.existsSync(file)) || null;
55
+ }
56
+ function bash(file, name, login) {
57
+ // CHERE_INVOKING: el bash de Git para Windows no se va a tu carpeta personal al arrancar
58
+ return { name, file, args: (command) => [login ? "-lc" : "-c", command], env: { CHERE_INVOKING: "1" } };
59
+ }
60
+ function powershell(file) {
61
+ return {
62
+ name: "PowerShell",
63
+ file,
64
+ // En UTF-8 (si no, las tildes salen rotas), sin barras de progreso y con el código de salida del último programa.
65
+ // Va codificado para que las comillas del comando lleguen tal cual
66
+ args: (command) => {
67
+ const script = [
68
+ "$ProgressPreference = 'SilentlyContinue'",
69
+ "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false",
70
+ command,
71
+ "$vipcodeOk = $?",
72
+ "if ($LASTEXITCODE) { exit $LASTEXITCODE }",
73
+ "if (-not $vipcodeOk) { exit 1 }",
74
+ ].join("\n");
75
+ return ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "RemoteSigned", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
76
+ },
77
+ env: {},
78
+ };
79
+ }
80
+ function cmd(file) {
81
+ return { name: "cmd", file, args: (command) => ["/d", "/s", "/c", command], env: {} };
82
+ }
83
+ // La shell de los comandos: bash en macOS y Linux; en Windows, el bash de Git para Windows si lo tienes (los modelos
84
+ // escriben comandos de bash) y si no, PowerShell. VIPCODE_SHELL permite elegir otra (bash, pwsh, powershell o cmd)
85
+ export function detectShell(env = process.env, platform = process.platform) {
86
+ const custom = env.VIPCODE_SHELL;
87
+ if (custom) {
88
+ const base = path.basename(custom).toLowerCase().replace(/\.exe$/, "");
89
+ if (base === "pwsh" || base === "powershell") {
90
+ return powershell(custom);
91
+ }
92
+ if (base === "cmd") {
93
+ return cmd(custom);
94
+ }
95
+ return bash(custom, base, platform !== "win32");
96
+ }
97
+ if (platform !== "win32") {
98
+ return bash("bash", "bash", true);
99
+ }
100
+ const found = gitBash(env);
101
+ return found ? bash(found, "Git Bash", false) : powershell(onPath("pwsh.exe", env) || "powershell.exe");
102
+ }
103
+ let cached = null;
104
+ export function commandShell() {
105
+ cached ??= detectShell();
106
+ return cached;
107
+ }
package/dist/tools.js CHANGED
@@ -4,7 +4,8 @@ import { spawn, spawnSync } from "node:child_process";
4
4
  import fg from "fast-glob";
5
5
  import { isImagePath, toDataUrl } from "./images.js";
6
6
  import { stripControl } from "./sanitize.js";
7
- import { protectedDirs } from "./paths.js";
7
+ import { isInside, protectedDirs } from "./paths.js";
8
+ import { commandShell, killTree } from "./shell.js";
8
9
  const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**", "**/vendor/**"];
9
10
  const MAX_OUTPUT = 20000;
10
11
  export function truncate(text, max = MAX_OUTPUT) {
@@ -14,11 +15,11 @@ export function truncate(text, max = MAX_OUTPUT) {
14
15
  const half = Math.floor(max / 2);
15
16
  return `${text.slice(0, half)}\n\n[… ${text.length - max} caracteres omitidos …]\n\n${text.slice(-half)}`;
16
17
  }
17
- // Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
18
- // Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista
18
+ // Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista.
19
+ // La versión nativa devuelve además las mayúsculas reales de cada carpeta
19
20
  function realPathOf(file, depth = 0) {
20
21
  try {
21
- return fs.realpathSync(file);
22
+ return fs.realpathSync.native(file);
22
23
  }
23
24
  catch {
24
25
  // no existe (todavía) o es un enlace roto
@@ -39,25 +40,26 @@ function realPathOf(file, depth = 0) {
39
40
  }
40
41
  return path.join(realPathOf(parent, depth + 1), path.basename(file));
41
42
  }
43
+ // Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
42
44
  export function resolvePath(root, p, options = {}) {
43
45
  let input = p;
44
46
  if (!input) {
45
47
  input = ".";
46
48
  }
47
49
  const abs = path.resolve(root, input);
48
- if (abs !== root && !abs.startsWith(root + path.sep)) {
50
+ if (!isInside(abs, root)) {
49
51
  throw new Error(`La ruta ${p} está fuera del proyecto (${root})`);
50
52
  }
51
53
  // Un enlace simbólico dentro del proyecto tampoco puede llevar fuera (por ejemplo a ~/.ssh o a tus claves)
52
54
  const real = realPathOf(abs);
53
55
  const realRoot = realPathOf(root);
54
- if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
56
+ if (!isInside(real, realRoot)) {
55
57
  throw new Error(`La ruta ${p} es un enlace que lleva fuera del proyecto (${real})`);
56
58
  }
57
59
  // vipcode no puede tocarse a sí mismo: ni sus datos (claves, configuración) ni, para escribir, su instalación
58
60
  for (const { dir, read } of protectedDirs()) {
59
61
  const realDir = realPathOf(dir);
60
- if ((read || options.write) && (real === realDir || real.startsWith(realDir + path.sep))) {
62
+ if ((read || options.write) && isInside(real, realDir)) {
61
63
  if (read) {
62
64
  throw new Error(`La ruta ${p} está en la carpeta de datos de vipcode (${dir}), que guarda tus claves y tu configuración: no se puede leer ni cambiar desde aquí.`);
63
65
  }
@@ -78,12 +80,17 @@ function protectedIgnore(root) {
78
80
  }
79
81
  return patterns;
80
82
  }
83
+ // Ruta relativa al proyecto, siempre con / (también en Windows): es lo que ve el modelo y lo que comparan los permisos
81
84
  export function rel(root, abs) {
82
85
  const r = path.relative(root, abs);
83
86
  if (r === "") {
84
87
  return ".";
85
88
  }
86
- return r;
89
+ return r.split(path.sep).join("/");
90
+ }
91
+ // Los patrones glob van siempre con /; en Windows el modelo a veces los escribe con \
92
+ function globPattern(pattern) {
93
+ return process.platform === "win32" ? pattern.replace(/\\/g, "/") : pattern;
87
94
  }
88
95
  function rejected(reply, what) {
89
96
  if (reply.feedback) {
@@ -246,7 +253,7 @@ function readNumbered(file, label, offset, maxLines) {
246
253
  if (buffer.includes(0)) {
247
254
  return `${label} parece un fichero binario; no se muestra.`;
248
255
  }
249
- const lines = buffer.toString("utf8").split("\n");
256
+ const lines = buffer.toString("utf8").split(/\r?\n/);
250
257
  let start = 1;
251
258
  if (offset && offset > 0) {
252
259
  start = Math.floor(offset);
@@ -545,7 +552,7 @@ const globTool = {
545
552
  readOnly: true,
546
553
  async run(args, ctx) {
547
554
  const cwd = resolvePath(ctx.root, args.path);
548
- const files = await fg(String(args.pattern), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
555
+ const files = await fg(globPattern(String(args.pattern)), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
549
556
  if (files.length === 0) {
550
557
  return "Sin resultados.";
551
558
  }
@@ -610,12 +617,16 @@ const grepTool = {
610
617
  async run(args, ctx) {
611
618
  const pattern = String(args.pattern);
612
619
  if (hasRipgrep()) {
613
- const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--with-filename", "--max-columns", "300", "--max-columns-preview", "-e", pattern];
620
+ // --crlf: en los ficheros con saltos de línea de Windows, $ también encaja antes del \r\n
621
+ const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--with-filename", "--crlf", "--max-columns", "300", "--max-columns-preview", "-e", pattern];
622
+ if (process.platform === "win32") {
623
+ rgArgs.push("--path-separator", "/");
624
+ }
614
625
  if (args.ignore_case) {
615
626
  rgArgs.push("-i");
616
627
  }
617
628
  if (args.include) {
618
- rgArgs.push("-g", String(args.include));
629
+ rgArgs.push("-g", globPattern(String(args.include)));
619
630
  }
620
631
  for (const pattern of protectedIgnore(ctx.root)) {
621
632
  rgArgs.push("-g", `!${pattern}`);
@@ -629,7 +640,7 @@ const grepTool = {
629
640
  const lines = found.stdout
630
641
  .split("\n")
631
642
  .filter((line) => line.length > 0)
632
- .map((line) => line.replace(/^\.\//, "").replace(/^([^:]+):(\d+):/, "$1:$2: "));
643
+ .map((line) => line.replace(/\r$/, "").replace(/^\.\//, "").replace(/^([^:]+):(\d+):/, "$1:$2: "));
633
644
  if (lines.length > 200) {
634
645
  return `${lines.slice(0, 200).join("\n")}\n[… limitado a 200 resultados]`;
635
646
  }
@@ -649,7 +660,7 @@ const grepTool = {
649
660
  }
650
661
  let include = "**/*";
651
662
  if (args.include) {
652
- include = String(args.include);
663
+ include = globPattern(String(args.include));
653
664
  }
654
665
  const files = await fg(include, { cwd: ctx.root, ignore: [...IGNORE, ...protectedIgnore(ctx.root)], dot: false, onlyFiles: true, followSymbolicLinks: false });
655
666
  const results = [];
@@ -669,7 +680,7 @@ const grepTool = {
669
680
  if (buffer.includes(0)) {
670
681
  continue;
671
682
  }
672
- const lines = buffer.toString("utf8").split("\n");
683
+ const lines = buffer.toString("utf8").split(/\r?\n/);
673
684
  for (let i = 0; i < lines.length; i++) {
674
685
  if (regex.test(lines[i])) {
675
686
  results.push(`${file}:${i + 1}: ${lines[i].slice(0, 300)}`);
@@ -719,24 +730,24 @@ const bashTool = {
719
730
  timeout = Math.min(args.timeout_ms, 600000);
720
731
  }
721
732
  return await new Promise((resolve) => {
722
- let shell = "bash";
723
- let shellArgs = ["-lc", command];
724
- if (process.platform === "win32") {
725
- shell = "cmd.exe";
726
- shellArgs = ["/c", command];
727
- }
728
- const child = spawn(shell, shellArgs, {
733
+ const shell = commandShell();
734
+ const child = spawn(shell.file, shell.args(command), {
729
735
  cwd: ctx.root,
730
- env: { ...process.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
736
+ env: { ...process.env, ...shell.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
731
737
  stdio: ["ignore", "pipe", "pipe"],
732
738
  // Grupo de procesos propio para poder matar también a los hijos (npm test, servidores…)
733
739
  detached: process.platform !== "win32",
740
+ windowsHide: true,
734
741
  });
735
742
  let output = "";
736
743
  let timedOut = false;
737
744
  const kill = () => {
745
+ if (process.platform === "win32") {
746
+ killTree(child);
747
+ return;
748
+ }
738
749
  try {
739
- if (process.platform !== "win32" && child.pid) {
750
+ if (child.pid) {
740
751
  process.kill(-child.pid, "SIGTERM");
741
752
  }
742
753
  else {
@@ -748,7 +759,7 @@ const bashTool = {
748
759
  }
749
760
  setTimeout(() => {
750
761
  try {
751
- if (process.platform !== "win32" && child.pid) {
762
+ if (child.pid) {
752
763
  process.kill(-child.pid, "SIGKILL");
753
764
  }
754
765
  }
@@ -1082,7 +1093,7 @@ export function taskTool(subagents) {
1082
1093
  if (!ctx.subagent) {
1083
1094
  return "Error: los subagentes no están disponibles aquí.";
1084
1095
  }
1085
- return await ctx.subagent(String(args.subagent_type || "general"), String(args.prompt || ""), String(args.description || ""));
1096
+ return await ctx.subagent(String(args.subagent_type || "ayudante"), String(args.prompt || ""), String(args.description || ""));
1086
1097
  },
1087
1098
  };
1088
1099
  }
@@ -1151,7 +1162,7 @@ export function describeCall(name, rawArgs) {
1151
1162
  try {
1152
1163
  const args = JSON.parse(rawArgs);
1153
1164
  if (name === "task") {
1154
- return `${args.subagent_type || "general"}: ${args.description || ""}`.trim();
1165
+ return `${args.subagent_type || "ayudante"}: ${args.description || ""}`.trim();
1155
1166
  }
1156
1167
  if (name === "todowrite" && Array.isArray(args.todos)) {
1157
1168
  return `${args.todos.length} tareas`;
package/dist/trust.js CHANGED
@@ -89,7 +89,8 @@ function readStore() {
89
89
  }
90
90
  function key(root) {
91
91
  try {
92
- return fs.realpathSync(root);
92
+ // La ruta real con sus mayúsculas: en macOS y Windows ~/Proyecto y ~/proyecto son la misma carpeta
93
+ return fs.realpathSync.native(root);
93
94
  }
94
95
  catch {
95
96
  return path.resolve(root);
@@ -99,7 +100,9 @@ export function projectTrust(root) {
99
100
  const files = [];
100
101
  const changes = [];
101
102
  const parts = [];
102
- const add = (relative, text, change) => {
103
+ const add = (file, text, change) => {
104
+ // Con / también en Windows: así se enseñan igual en todos los sistemas
105
+ const relative = file.split(path.sep).join("/");
103
106
  files.push(relative);
104
107
  parts.push(`${relative}\u0000${text}`);
105
108
  changes.push(...change.map((item) => `${relative}: ${item}`));
package/dist/tui/app.js CHANGED
@@ -9,6 +9,7 @@ import { configNotices } from "../config.js";
9
9
  import { discoveryIsStale } from "../discovery.js";
10
10
  import { isImagePath, readClipboardImage, toDataUrl } from "../images.js";
11
11
  import { toolLabel } from "../labels.js";
12
+ import { quotaFor } from "../limits.js";
12
13
  import { renderMarkdown } from "../markdown.js";
13
14
  import { VERSION } from "../meta.js";
14
15
  import { NoSlotError } from "../router.js";
@@ -277,6 +278,15 @@ function StatusBar(props) {
277
278
  const usage = app.agent.session.usage;
278
279
  const spent = usage.prompt + usage.completion;
279
280
  const queued = app.agent.queue.length;
281
+ // Cuánto queda del plan gratis del modelo en uso (/limites lo enseña todo)
282
+ const quota = slot ? quotaFor(app.router, slot) : null;
283
+ let quotaColor;
284
+ if (quota && quota.remaining <= 0) {
285
+ quotaColor = "red";
286
+ }
287
+ else if (quota && quota.limit && quota.remaining / quota.limit <= 0.1) {
288
+ quotaColor = "yellow";
289
+ }
280
290
  let hint = _jsx(Text, { dimColor: true, children: "ctrl+p comandos \u00B7 /help" });
281
291
  if (props.busy) {
282
292
  hint = _jsx(Text, { dimColor: true, children: "esc interrumpe \u00B7 escribe para a\u00F1adir instrucciones" });
@@ -287,7 +297,7 @@ function StatusBar(props) {
287
297
  else if (app.newVersion) {
288
298
  hint = (_jsxs(Text, { bold: true, color: "green", children: ["\u2191 vipcode ", stripControl(app.newVersion), " disponible \u00B7 escribe /update"] }));
289
299
  }
290
- return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { wrap: "truncate-start", children: hint })] }));
300
+ return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), quota ? (_jsxs(Text, { color: quotaColor, dimColor: !quotaColor, children: [" ", "\u00B7 ", quota.text] })) : null, _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { wrap: "truncate-start", children: hint })] }));
291
301
  }
292
302
  function Main(props) {
293
303
  const { app } = props;
@@ -97,7 +97,7 @@ export function PermissionDialog(props) {
97
97
  const lines = request.preview.split("\n");
98
98
  const shown = lines.slice(0, 30).join("\n");
99
99
  const hidden = lines.length - 30;
100
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", showControl(request.title), props.agent !== "build" ? _jsxs(Text, { dimColor: true, children: [" (", props.agent, ")"] }) : null] }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { children: colorPreview(shown) }), hidden > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", hidden, " l\u00EDneas m\u00E1s"] }) : null] }), explaining ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u00BFQu\u00E9 deber\u00EDa hacer en su lugar? (Enter env\u00EDa, Esc vuelve)" }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u276F " }), feedback.value.slice(0, feedback.cursor), _jsx(Text, { inverse: true, children: feedback.value[feedback.cursor] || " " }), feedback.value.slice(feedback.cursor + 1)] })] })) : (_jsxs(_Fragment, { children: [options.map((option, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, children: [index === selected ? "❯ " : " ", _jsxs(Text, { dimColor: true, children: ["[", option.key, "]"] }), " ", option.label] }, option.key))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 elige \u00B7 Enter acepta \u00B7 la letra es un atajo \u00B7 Esc = no" })] }))] }));
100
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", showControl(request.title), props.agent !== "programar" ? _jsxs(Text, { dimColor: true, children: [" (", props.agent, ")"] }) : null] }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { children: colorPreview(shown) }), hidden > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", hidden, " l\u00EDneas m\u00E1s"] }) : null] }), explaining ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u00BFQu\u00E9 deber\u00EDa hacer en su lugar? (Enter env\u00EDa, Esc vuelve)" }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u276F " }), feedback.value.slice(0, feedback.cursor), _jsx(Text, { inverse: true, children: feedback.value[feedback.cursor] || " " }), feedback.value.slice(feedback.cursor + 1)] })] })) : (_jsxs(_Fragment, { children: [options.map((option, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, children: [index === selected ? "❯ " : " ", _jsxs(Text, { dimColor: true, children: ["[", option.key, "]"] }), " ", option.label] }, option.key))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 elige \u00B7 Enter acepta \u00B7 la letra es un atajo \u00B7 Esc = no" })] }))] }));
101
101
  }
102
102
  function matches(item, query) {
103
103
  return `${item.label} ${item.description || ""}`.toLowerCase().includes(query);
package/dist/ui.js CHANGED
@@ -27,10 +27,10 @@ export const ui = {
27
27
  console.log(pc.bold(pc.magenta("vipcode")) + pc.dim(` · agente ${agent} · ${slots} modelos · /help para ayuda`));
28
28
  },
29
29
  prompt(agent) {
30
- if (agent === "plan") {
31
- return pc.blue("plan ❯ ");
30
+ if (agent === "planear") {
31
+ return pc.blue("planear ❯ ");
32
32
  }
33
- if (agent !== "build") {
33
+ if (agent !== "programar") {
34
34
  return pc.yellow(`${agent} ❯ `);
35
35
  }
36
36
  return pc.magenta("❯ ");
package/dist/update.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { PACKAGE_NAME, REPO_URL, VERSION } from "./meta.js";
6
- import { INSTALL_DIR, UPDATE_PATH, ensureHomeDir } from "./paths.js";
6
+ import { INSTALL_DIR, UPDATE_PATH, ensureHomeDir, isInside } from "./paths.js";
7
7
  const DAY_MS = 24 * 60 * 60 * 1000;
8
8
  // Carpeta donde está instalado vipcode (la que contiene dist/). VIPCODE_INSTALL_ROOT permite fingir otra en las pruebas
9
9
  export function packageRoot() {
@@ -144,11 +144,9 @@ const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
144
144
  function lastLines(text, count = 6) {
145
145
  return text.trim().split("\n").slice(-count).join("\n");
146
146
  }
147
- function isInside(dir, parent) {
147
+ function isInstalledIn(dir, parent) {
148
148
  try {
149
- const real = fs.realpathSync(dir);
150
- const base = fs.realpathSync(parent);
151
- return real === base || real.startsWith(base + path.sep);
149
+ return isInside(fs.realpathSync.native(dir), fs.realpathSync.native(parent));
152
150
  }
153
151
  catch {
154
152
  return false;
@@ -211,7 +209,7 @@ async function updateFromRelease(root, log) {
211
209
  }
212
210
  const prefix = (await run(NPM, ["prefix", "-g"], os.tmpdir())).output.trim();
213
211
  const globalDir = process.platform === "win32" ? path.join(prefix, "node_modules") : path.join(prefix, "lib", "node_modules");
214
- if (!prefix || !isInside(root, globalDir)) {
212
+ if (!prefix || !isInstalledIn(root, globalDir)) {
215
213
  return {
216
214
  status: "manual",
217
215
  message: `Hay una versión nueva (${release.version}, tienes la ${VERSION}), pero este vipcode no está instalado con «npm install -g». Instálala con:\n npm install -g ${release.spec}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcode",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Agente de programación para la terminal que usa IAs gratuitas y salta a otro proveedor cuando uno se agota",
5
5
  "keywords": [
6
6
  "ai",
@@ -11,7 +11,6 @@
11
11
  "llm",
12
12
  "free",
13
13
  "terminal",
14
- "opencode",
15
14
  "spanish"
16
15
  ],
17
16
  "homepage": "https://github.com/PabloVip/vipcode#readme",
@@ -47,6 +46,7 @@
47
46
  },
48
47
  "dependencies": {
49
48
  "@modelcontextprotocol/sdk": "^1.32.2",
49
+ "cross-spawn": "^7.0.6",
50
50
  "fast-glob": "^3.3.3",
51
51
  "highlight.js": "^11.12.0",
52
52
  "ink": "^8.0.0",
@@ -59,6 +59,7 @@
59
59
  "zod": "^4.6.7"
60
60
  },
61
61
  "devDependencies": {
62
+ "@types/cross-spawn": "^6.0.6",
62
63
  "@types/node": "^26.6.6",
63
64
  "@types/react": "^19.3.0",
64
65
  "tsx": "^4.23.15"