vipcode 0.2.0 → 0.2.1

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/session.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { EXPORTS_DIR, HISTORY_PATH, HOME_DIR, SESSIONS_DIR } from "./paths.js";
3
+ import { EXPORTS_DIR, HISTORY_PATH, SESSIONS_DIR, ensureHomeDir } from "./paths.js";
4
4
  export function newSession(cwd) {
5
5
  const now = new Date();
6
6
  // id legible y ordenable: 20261029-174512-k3x
@@ -204,7 +204,7 @@ export function appendHistory(text) {
204
204
  return;
205
205
  }
206
206
  try {
207
- fs.mkdirSync(HOME_DIR, { recursive: true });
207
+ ensureHomeDir();
208
208
  fs.appendFileSync(HISTORY_PATH, `${JSON.stringify(text)}\n`);
209
209
  // De vez en cuando lo recortamos para que no crezca sin fin
210
210
  const stat = fs.statSync(HISTORY_PATH);
package/dist/skills.js CHANGED
@@ -278,7 +278,8 @@ export function installSkills(source, root, options = {}) {
278
278
  throw new Error(`No encuentro ${source}: pon la ruta de una carpeta con SKILL.md o la dirección de un repositorio git.`);
279
279
  }
280
280
  temp = fs.mkdtempSync(path.join(os.tmpdir(), "vipcode-skill-"));
281
- const args = ["clone", "--depth", "1", ...(git.ref ? ["--branch", git.ref] : []), git.url, temp];
281
+ // Con «--» un origen que empiece por - no se toma como una opción de git (por ejemplo --upload-pack)
282
+ const args = ["clone", "--depth", "1", ...(git.ref ? [`--branch=${git.ref}`] : []), "--", git.url, temp];
282
283
  const clone = spawnSync("git", args, { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
283
284
  if (clone.error || clone.status !== 0) {
284
285
  throw new Error(`No he podido descargar ${git.url}: ${(clone.stderr || clone.error?.message || "").trim().split("\n").pop()}`);
package/dist/tools.js CHANGED
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { spawn, spawnSync } from "node:child_process";
4
4
  import fg from "fast-glob";
5
5
  import { isImagePath, toDataUrl } from "./images.js";
6
+ import { stripControl } from "./sanitize.js";
7
+ import { protectedDirs } from "./paths.js";
6
8
  const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**", "**/vendor/**"];
7
9
  const MAX_OUTPUT = 20000;
8
10
  export function truncate(text, max = MAX_OUTPUT) {
@@ -13,7 +15,31 @@ export function truncate(text, max = MAX_OUTPUT) {
13
15
  return `${text.slice(0, half)}\n\n[… ${text.length - max} caracteres omitidos …]\n\n${text.slice(-half)}`;
14
16
  }
15
17
  // Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
16
- export function resolvePath(root, p) {
18
+ // Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista
19
+ function realPathOf(file, depth = 0) {
20
+ try {
21
+ return fs.realpathSync(file);
22
+ }
23
+ catch {
24
+ // no existe (todavía) o es un enlace roto
25
+ }
26
+ if (depth < 40) {
27
+ try {
28
+ if (fs.lstatSync(file).isSymbolicLink()) {
29
+ return realPathOf(path.resolve(path.dirname(file), fs.readlinkSync(file)), depth + 1);
30
+ }
31
+ }
32
+ catch {
33
+ // no existe
34
+ }
35
+ }
36
+ const parent = path.dirname(file);
37
+ if (parent === file || depth >= 40) {
38
+ return file;
39
+ }
40
+ return path.join(realPathOf(parent, depth + 1), path.basename(file));
41
+ }
42
+ export function resolvePath(root, p, options = {}) {
17
43
  let input = p;
18
44
  if (!input) {
19
45
  input = ".";
@@ -22,8 +48,36 @@ export function resolvePath(root, p) {
22
48
  if (abs !== root && !abs.startsWith(root + path.sep)) {
23
49
  throw new Error(`La ruta ${p} está fuera del proyecto (${root})`);
24
50
  }
51
+ // Un enlace simbólico dentro del proyecto tampoco puede llevar fuera (por ejemplo a ~/.ssh o a tus claves)
52
+ const real = realPathOf(abs);
53
+ const realRoot = realPathOf(root);
54
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
55
+ throw new Error(`La ruta ${p} es un enlace que lleva fuera del proyecto (${real})`);
56
+ }
57
+ // vipcode no puede tocarse a sí mismo: ni sus datos (claves, configuración) ni, para escribir, su instalación
58
+ for (const { dir, read } of protectedDirs()) {
59
+ const realDir = realPathOf(dir);
60
+ if ((read || options.write) && (real === realDir || real.startsWith(realDir + path.sep))) {
61
+ if (read) {
62
+ 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
+ }
64
+ throw new Error(`La ruta ${p} es parte de la instalación de vipcode (${dir}): vipcode no puede modificarse a sí mismo.`);
65
+ }
66
+ }
25
67
  return abs;
26
68
  }
69
+ // Patrones para que glob y grep no recorran la carpeta de datos de vipcode si está dentro del proyecto
70
+ function protectedIgnore(root) {
71
+ const realRoot = realPathOf(root);
72
+ const patterns = [];
73
+ for (const { dir, read } of protectedDirs()) {
74
+ const relative = path.relative(realRoot, realPathOf(dir));
75
+ if (read && relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
76
+ patterns.push(`${relative.split(path.sep).join("/")}/**`);
77
+ }
78
+ }
79
+ return patterns;
80
+ }
27
81
  export function rel(root, abs) {
28
82
  const r = path.relative(root, abs);
29
83
  if (r === "") {
@@ -284,7 +338,7 @@ const writeFile = {
284
338
  permission: "write",
285
339
  readOnly: false,
286
340
  async run(args, ctx) {
287
- const file = resolvePath(ctx.root, args.path);
341
+ const file = resolvePath(ctx.root, args.path, { write: true });
288
342
  const content = String(args.content ?? "");
289
343
  const exists = fs.existsSync(file);
290
344
  let previous = null;
@@ -415,7 +469,7 @@ const editFile = {
415
469
  permission: "write",
416
470
  readOnly: false,
417
471
  async run(args, ctx) {
418
- const file = resolvePath(ctx.root, args.path);
472
+ const file = resolvePath(ctx.root, args.path, { write: true });
419
473
  if (!fs.existsSync(file)) {
420
474
  return { output: `Error: ${args.path} no existe. Para crear un fichero usa write_file.`, isError: true };
421
475
  }
@@ -491,7 +545,7 @@ const globTool = {
491
545
  readOnly: true,
492
546
  async run(args, ctx) {
493
547
  const cwd = resolvePath(ctx.root, args.path);
494
- const files = await fg(String(args.pattern), { cwd, ignore: IGNORE, dot: true, onlyFiles: true });
548
+ const files = await fg(String(args.pattern), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
495
549
  if (files.length === 0) {
496
550
  return "Sin resultados.";
497
551
  }
@@ -563,6 +617,9 @@ const grepTool = {
563
617
  if (args.include) {
564
618
  rgArgs.push("-g", String(args.include));
565
619
  }
620
+ for (const pattern of protectedIgnore(ctx.root)) {
621
+ rgArgs.push("-g", `!${pattern}`);
622
+ }
566
623
  rgArgs.push(".");
567
624
  const found = await ripgrep(rgArgs, ctx.root, ctx.signal);
568
625
  if (found) {
@@ -594,7 +651,7 @@ const grepTool = {
594
651
  if (args.include) {
595
652
  include = String(args.include);
596
653
  }
597
- const files = await fg(include, { cwd: ctx.root, ignore: IGNORE, dot: false, onlyFiles: true });
654
+ const files = await fg(include, { cwd: ctx.root, ignore: [...IGNORE, ...protectedIgnore(ctx.root)], dot: false, onlyFiles: true, followSymbolicLinks: false });
598
655
  const results = [];
599
656
  for (const file of files) {
600
657
  const abs = path.join(ctx.root, file);
@@ -628,6 +685,11 @@ const grepTool = {
628
685
  return results.join("\n");
629
686
  },
630
687
  };
688
+ // Un .git/config con core.fsmonitor ejecutaría un programa en cada «git status» (que se aprueba solo)
689
+ function gitSafetyEnv() {
690
+ const count = Number(process.env.GIT_CONFIG_COUNT) || 0;
691
+ return { GIT_CONFIG_COUNT: String(count + 1), [`GIT_CONFIG_KEY_${count}`]: "core.fsmonitor", [`GIT_CONFIG_VALUE_${count}`]: "false" };
692
+ }
631
693
  const bashTool = {
632
694
  name: "bash",
633
695
  description: "Ejecuta un comando de shell en la raíz del proyecto (tests, git, instalar dependencias…). No lo uses para leer o editar ficheros: para eso están las otras herramientas. No es interactivo: evita comandos que esperen que escribas algo.",
@@ -665,7 +727,7 @@ const bashTool = {
665
727
  }
666
728
  const child = spawn(shell, shellArgs, {
667
729
  cwd: ctx.root,
668
- env: { ...process.env, VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
730
+ env: { ...process.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
669
731
  stdio: ["ignore", "pipe", "pipe"],
670
732
  // Grupo de procesos propio para poder matar también a los hijos (npm test, servidores…)
671
733
  detached: process.platform !== "win32",
@@ -916,7 +978,7 @@ export function formatTodos(todos) {
916
978
  if (todos.length === 0) {
917
979
  return "(lista de tareas vacía)";
918
980
  }
919
- return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${todo.content}`).join("\n");
981
+ return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${stripControl(String(todo.content))}`).join("\n");
920
982
  }
921
983
  const todoWrite = {
922
984
  name: "todowrite",
@@ -1184,7 +1246,9 @@ export function projectFiles(root) {
1184
1246
  }
1185
1247
  let files = [];
1186
1248
  try {
1187
- files = fg.sync("**/*", { cwd: root, ignore: IGNORE, dot: true, onlyFiles: false, markDirectories: true, deep: 8, suppressErrors: true }).slice(0, 20000);
1249
+ files = fg
1250
+ .sync("**/*", { cwd: root, ignore: [...IGNORE, ...protectedIgnore(root)], dot: true, onlyFiles: false, markDirectories: true, deep: 8, suppressErrors: true, followSymbolicLinks: false })
1251
+ .slice(0, 20000);
1188
1252
  }
1189
1253
  catch {
1190
1254
  files = [];
package/dist/trust.js ADDED
@@ -0,0 +1,171 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { parseFrontmatter } from "./frontmatter.js";
5
+ import { TRUST_PATH, ensureHomeDir } from "./paths.js";
6
+ // Lo que un proyecto puede traer y que cambia qué ejecuta vipcode o a dónde manda tus claves: servidores MCP y
7
+ // de lenguaje, proveedores, permisos, agentes con permisos propios y comandos que lanzan órdenes. No se usa hasta
8
+ // que confías en la carpeta, y si cambia (por ejemplo tras un git pull) se vuelve a preguntar.
9
+ const CONFIG_FILES = ["vipcode.json", path.join(".vipcode", "config.json")];
10
+ const AGENT_DIRS = [path.join(".vipcode", "agents"), path.join(".opencode", "agent"), path.join(".opencode", "agents")];
11
+ const COMMAND_DIRS = [path.join(".vipcode", "commands"), path.join(".opencode", "command"), path.join(".opencode", "commands")];
12
+ function isObject(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function short(value) {
16
+ const text = typeof value === "string" ? value : JSON.stringify(value);
17
+ return text.length > 100 ? `${text.slice(0, 99)}…` : text;
18
+ }
19
+ function readText(file) {
20
+ try {
21
+ return fs.statSync(file).isFile() ? fs.readFileSync(file, "utf8") : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function markdownFiles(root, dirs) {
28
+ const files = [];
29
+ for (const dir of dirs) {
30
+ try {
31
+ for (const name of fs.readdirSync(path.join(root, dir)).sort()) {
32
+ if (name.endsWith(".md")) {
33
+ files.push(path.join(dir, name));
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ // la carpeta no existe
39
+ }
40
+ }
41
+ return files;
42
+ }
43
+ function describeConfig(data) {
44
+ const changes = [];
45
+ const keys = Object.keys(data).filter((key) => !key.startsWith("$"));
46
+ const rest = [];
47
+ for (const key of keys) {
48
+ const value = data[key];
49
+ if (key === "providers" && Array.isArray(value)) {
50
+ const names = value.map((item) => (isObject(item) ? String(item.name || "?") : "?"));
51
+ changes.push(`proveedores: ${names.join(", ")} (pueden cambiar a dónde se mandan tus claves)`);
52
+ }
53
+ else if (key === "mcp" && isObject(value)) {
54
+ const servers = Object.entries(value).map(([name, server]) => {
55
+ const detail = isObject(server) ? server.command || server.url : server;
56
+ return `${name} (${short(detail)})`;
57
+ });
58
+ changes.push(`servidores MCP que se arrancan solos: ${servers.join(", ")}`);
59
+ }
60
+ else if (key === "lsp" && isObject(value)) {
61
+ changes.push(`servidores de lenguaje: ${short(value)}`);
62
+ }
63
+ else if (key === "permission") {
64
+ changes.push(`permisos: ${short(value)}`);
65
+ }
66
+ else if (key === "agents" && isObject(value)) {
67
+ changes.push(`agentes: ${Object.keys(value).join(", ")}`);
68
+ }
69
+ else if (key === "commands" && isObject(value)) {
70
+ changes.push(`comandos: ${Object.keys(value).join(", ")}`);
71
+ }
72
+ else {
73
+ rest.push(key);
74
+ }
75
+ }
76
+ if (rest.length > 0) {
77
+ changes.push(`otros ajustes: ${rest.join(", ")}`);
78
+ }
79
+ return changes;
80
+ }
81
+ function readStore() {
82
+ try {
83
+ const data = JSON.parse(fs.readFileSync(TRUST_PATH, "utf8"));
84
+ return isObject(data) ? data : {};
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ }
90
+ function key(root) {
91
+ try {
92
+ return fs.realpathSync(root);
93
+ }
94
+ catch {
95
+ return path.resolve(root);
96
+ }
97
+ }
98
+ export function projectTrust(root) {
99
+ const files = [];
100
+ const changes = [];
101
+ const parts = [];
102
+ const add = (relative, text, change) => {
103
+ files.push(relative);
104
+ parts.push(`${relative}\u0000${text}`);
105
+ changes.push(...change.map((item) => `${relative}: ${item}`));
106
+ };
107
+ for (const relative of CONFIG_FILES) {
108
+ const text = readText(path.join(root, relative));
109
+ if (text === null) {
110
+ continue;
111
+ }
112
+ let data = {};
113
+ try {
114
+ data = JSON.parse(text);
115
+ }
116
+ catch {
117
+ data = {};
118
+ }
119
+ const described = isObject(data) ? describeConfig(data) : [];
120
+ add(relative, text, described.length > 0 ? described : ["configuración de vipcode"]);
121
+ }
122
+ for (const relative of markdownFiles(root, AGENT_DIRS)) {
123
+ const text = readText(path.join(root, relative)) || "";
124
+ const { data } = parseFrontmatter(text);
125
+ // Un agente que solo trae instrucciones es como un AGENTS.md: no hace falta confiar
126
+ if (data.permission !== undefined || data.readOnly !== undefined) {
127
+ add(relative, text, [`agente con permisos propios: ${short({ permission: data.permission, readOnly: data.readOnly })}`]);
128
+ }
129
+ }
130
+ for (const relative of markdownFiles(root, COMMAND_DIRS)) {
131
+ const text = readText(path.join(root, relative)) || "";
132
+ if (/!`[^`]+`/.test(text)) {
133
+ add(relative, text, ["comando que ejecuta órdenes en la terminal al usarlo"]);
134
+ }
135
+ }
136
+ if (files.length === 0) {
137
+ return { files, changes, fingerprint: null, trusted: true };
138
+ }
139
+ const fingerprint = crypto.createHash("sha256").update(parts.join("\u0000\u0000")).digest("hex");
140
+ return { files, changes, fingerprint, trusted: readStore()[key(root)]?.fingerprint === fingerprint };
141
+ }
142
+ // Confías en la configuración que trae la carpeta tal como está ahora
143
+ export function trustProject(root) {
144
+ const trust = projectTrust(root);
145
+ if (!trust.fingerprint) {
146
+ return trust;
147
+ }
148
+ const store = readStore();
149
+ store[key(root)] = { fingerprint: trust.fingerprint, date: new Date().toISOString() };
150
+ ensureHomeDir();
151
+ fs.writeFileSync(TRUST_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
152
+ return { ...trust, trusted: true };
153
+ }
154
+ export function untrustProject(root) {
155
+ const store = readStore();
156
+ if (!store[key(root)]) {
157
+ return false;
158
+ }
159
+ delete store[key(root)];
160
+ ensureHomeDir();
161
+ fs.writeFileSync(TRUST_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
162
+ return true;
163
+ }
164
+ // Ficheros de un agente o comando que están dentro del proyecto (los tuyos de ~/.vipcode no cuentan)
165
+ export function isProjectFile(root, file) {
166
+ if (!file) {
167
+ return false;
168
+ }
169
+ const relative = path.relative(root, file);
170
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
171
+ }
package/dist/tui/app.js CHANGED
@@ -11,6 +11,7 @@ import { isImagePath, readClipboardImage, toDataUrl } from "../images.js";
11
11
  import { renderMarkdown } from "../markdown.js";
12
12
  import { VERSION } from "../meta.js";
13
13
  import { NoSlotError } from "../router.js";
14
+ import { showControl, stripControl, stripUnsafe } from "../sanitize.js";
14
15
  import { appendHistory, loadHistory } from "../session.js";
15
16
  import { suggestFiles } from "../tools.js";
16
17
  import { colorPreview } from "../ui.js";
@@ -107,7 +108,8 @@ function diffStats(display) {
107
108
  }
108
109
  // Debajo de cada herramienta, un resumen corto de lo que pasó
109
110
  function toolDetail(event) {
110
- const output = event.output || "";
111
+ // La salida viene de ficheros y comandos: sin secuencias de la terminal
112
+ const output = stripControl(event.output || "");
111
113
  if (event.isError) {
112
114
  return [pc.red(firstLine(output))];
113
115
  }
@@ -162,8 +164,9 @@ function toolDetail(event) {
162
164
  return [pc.dim(firstLine(output))];
163
165
  }
164
166
  }
167
+ // El resumen sale de lo que pidió el modelo (comandos, rutas): los caracteres de control se ven, no actúan
165
168
  function ToolHeader(props) {
166
- return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: TOOL_LABEL[props.name] || props.name }), props.summary ? _jsxs(Text, { children: [" ", props.summary] }) : null] }));
169
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: TOOL_LABEL[props.name] || showControl(props.name) }), props.summary ? _jsxs(Text, { children: [" ", showControl(props.summary)] }) : null] }));
167
170
  }
168
171
  function ToolView(props) {
169
172
  const { event } = props;
@@ -193,8 +196,8 @@ function ItemView(props) {
193
196
  case "banner":
194
197
  return _jsx(Banner, { app: props.app });
195
198
  case "user": {
196
- const lines = item.text.split("\n");
197
- let text = item.text;
199
+ const lines = stripControl(item.text).split("\n");
200
+ let text = lines.join("\n");
198
201
  if (lines.length > 20) {
199
202
  text = `${lines.slice(0, 20).join("\n")}\n… (${lines.length - 20} líneas más)`;
200
203
  }
@@ -207,18 +210,19 @@ function ItemView(props) {
207
210
  case "answer":
208
211
  return (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u25C6 ", item.slot, item.score !== null ? ` · nota ${item.score}` : "", " \u00B7 ", formatDuration(item.ms), item.tokens > 0 ? ` · ${shortTokens(item.tokens)} tokens` : ""] }) }));
209
212
  case "shell": {
210
- const lines = item.output ? item.output.split("\n") : [];
213
+ const lines = item.output ? stripControl(item.output).split("\n") : [];
211
214
  const shown = lines.slice(-30);
212
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["$", " "] }), _jsx(Text, { bold: true, children: item.command })] }), lines.length > shown.length ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", lines.length - shown.length, " l\u00EDneas antes"] }) : null, shown.length > 0 ? _jsx(Text, { children: shown.map((line) => ` ${line}`).join("\n") }) : null, _jsxs(Text, { dimColor: true, children: [" ", item.code === 0 ? "✓" : "✗", " c\u00F3digo ", item.code ?? "?", " \u00B7 el agente ver\u00E1 la salida con tu siguiente mensaje"] })] }));
215
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["$", " "] }), _jsx(Text, { bold: true, children: showControl(item.command) })] }), lines.length > shown.length ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", lines.length - shown.length, " l\u00EDneas antes"] }) : null, shown.length > 0 ? _jsx(Text, { children: shown.map((line) => ` ${line}`).join("\n") }) : null, _jsxs(Text, { dimColor: true, children: [" ", item.code === 0 ? "✓" : "✗", " c\u00F3digo ", item.code ?? "?", " \u00B7 el agente ver\u00E1 la salida con tu siguiente mensaje"] })] }));
213
216
  }
217
+ // Avisos y bloques pueden llevar mensajes de los proveedores o datos de fuera: solo se dejan los colores
214
218
  case "info":
215
- return _jsx(Text, { dimColor: true, children: item.text });
219
+ return _jsx(Text, { dimColor: true, children: stripUnsafe(item.text) });
216
220
  case "notice":
217
- return _jsxs(Text, { color: "yellow", children: ["\u26A0 ", item.text] });
221
+ return _jsxs(Text, { color: "yellow", children: ["\u26A0 ", stripUnsafe(item.text)] });
218
222
  case "error":
219
- return _jsxs(Text, { color: "red", children: ["\u2716 ", item.text] });
223
+ return _jsxs(Text, { color: "red", children: ["\u2716 ", stripUnsafe(item.text)] });
220
224
  case "block":
221
- return (_jsx(Box, { marginY: 1, children: _jsx(Text, { children: item.text }) }));
225
+ return (_jsx(Box, { marginY: 1, children: _jsx(Text, { children: stripUnsafe(item.text) }) }));
222
226
  }
223
227
  }
224
228
  function TodoPanel(props) {
@@ -229,15 +233,15 @@ function TodoPanel(props) {
229
233
  const done = props.todos.filter((todo) => todo.status === "completed").length;
230
234
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: [_jsxs(Text, { dimColor: true, children: ["Tareas (", done, "/", props.todos.length, ")"] }), props.todos.slice(0, 10).map((todo, index) => {
231
235
  if (todo.status === "completed") {
232
- return (_jsxs(Text, { color: "green", strikethrough: true, children: ["\u2611 ", todo.content] }, index));
236
+ return (_jsxs(Text, { color: "green", strikethrough: true, children: ["\u2611 ", stripControl(todo.content)] }, index));
233
237
  }
234
238
  if (todo.status === "in_progress") {
235
- return (_jsxs(Text, { color: "yellow", children: ["\u25D0 ", todo.content] }, index));
239
+ return (_jsxs(Text, { color: "yellow", children: ["\u25D0 ", stripControl(todo.content)] }, index));
236
240
  }
237
241
  if (todo.status === "cancelled") {
238
- return (_jsxs(Text, { dimColor: true, strikethrough: true, children: ["\u2612 ", todo.content] }, index));
242
+ return (_jsxs(Text, { dimColor: true, strikethrough: true, children: ["\u2612 ", stripControl(todo.content)] }, index));
239
243
  }
240
- return _jsxs(Text, { children: ["\u2610 ", todo.content] }, index);
244
+ return _jsxs(Text, { children: ["\u2610 ", stripControl(todo.content)] }, index);
241
245
  }), props.todos.length > 10 ? _jsxs(Text, { dimColor: true, children: ["\u2026 y ", props.todos.length - 10, " m\u00E1s"] }) : null] }));
242
246
  }
243
247
  function InputBox(props) {
@@ -1004,7 +1008,7 @@ function Main(props) {
1004
1008
  if (busy) {
1005
1009
  placeholder = "Escribe para añadir instrucciones mientras trabaja…";
1006
1010
  }
1007
- return (_jsxs(_Fragment, { children: [_jsx(Static, { items: items, children: (item) => _jsx(ItemView, { item: item, app: app, width: columns }, item.id) }), _jsxs(Box, { flexDirection: "column", children: [live.current.reasoning ? (_jsxs(Text, { dimColor: true, italic: true, wrap: "truncate-end", children: ["\uD83D\uDCAD ", lastLines(live.current.reasoning.replace(/\s+/g, " "), 1).slice(-Math.max(20, columns - 6))] })) : null, liveText ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsxs(Text, { children: [liveText, _jsx(Text, { color: color, children: "\u258D" })] }) })] })) : null, running.map(({ event, progress, since }) => (_jsxs(Box, { flexDirection: "column", marginLeft: event.depth > 0 ? 4 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Spinner, {}), " ", _jsx(ToolHeader, { name: event.name, summary: event.summary }), " ", _jsx(Elapsed, { since: since, hideUnderMs: 2000 })] }), progress ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", lastLines(progress, 1).slice(0, Math.max(20, columns - 8))] })) : null] }, `${event.depth}:${event.id}`))), shell ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Spinner, { color: "yellow" }), " ", _jsx(Text, { bold: true, children: shell.command }), " ", _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }), shell.output ? _jsx(Text, { dimColor: true, children: lastLines(shell.output, Math.min(8, liveRoom)).replace(/^/gm, " ") }) : null] })) : null, _jsx(TodoPanel, { todos: todos }), busy && !shell && running.length === 0 && !liveText && !answered.current ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Spinner, {}), " ", _jsxs(Text, { dimColor: true, children: ["Pensando", live.current.model ? ` con ${live.current.model}` : "", "\u2026 "] }), _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }) })) : null, dialogView || (_jsx(InputBox, { editor: editor, color: color, placeholder: placeholder, attachments: attachments.length, maxLines: Math.max(3, Math.floor(rows / 2) - 4), width: columns })), suggestionsVisible && !dialog ? (_jsx(Box, { flexDirection: "column", paddingX: 2, children: suggestions.map((suggestion, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, wrap: "truncate-end", children: [index === selected ? "❯ " : " ", suggestion.label, suggestion.description ? _jsxs(Text, { dimColor: true, children: [" ", suggestion.description] }) : null] }, `${index}-${suggestion.label}`))) })) : null, _jsx(StatusBar, { app: app, agentName: agentName, busy: busy, model: busy ? live.current.model : "" })] })] }));
1011
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: items, children: (item) => _jsx(ItemView, { item: item, app: app, width: columns }, item.id) }), _jsxs(Box, { flexDirection: "column", children: [live.current.reasoning ? (_jsxs(Text, { dimColor: true, italic: true, wrap: "truncate-end", children: ["\uD83D\uDCAD ", lastLines(stripControl(live.current.reasoning).replace(/\s+/g, " "), 1).slice(-Math.max(20, columns - 6))] })) : null, liveText ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsxs(Text, { children: [liveText, _jsx(Text, { color: color, children: "\u258D" })] }) })] })) : null, running.map(({ event, progress, since }) => (_jsxs(Box, { flexDirection: "column", marginLeft: event.depth > 0 ? 4 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Spinner, {}), " ", _jsx(ToolHeader, { name: event.name, summary: event.summary }), " ", _jsx(Elapsed, { since: since, hideUnderMs: 2000 })] }), progress ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", lastLines(stripControl(progress), 1).slice(0, Math.max(20, columns - 8))] })) : null] }, `${event.depth}:${event.id}`))), shell ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Spinner, { color: "yellow" }), " ", _jsx(Text, { bold: true, children: showControl(shell.command) }), " ", _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }), shell.output ? _jsx(Text, { dimColor: true, children: lastLines(stripControl(shell.output), Math.min(8, liveRoom)).replace(/^/gm, " ") }) : null] })) : null, _jsx(TodoPanel, { todos: todos }), busy && !shell && running.length === 0 && !liveText && !answered.current ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Spinner, {}), " ", _jsxs(Text, { dimColor: true, children: ["Pensando", live.current.model ? ` con ${live.current.model}` : "", "\u2026 "] }), _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }) })) : null, dialogView || (_jsx(InputBox, { editor: editor, color: color, placeholder: placeholder, attachments: attachments.length, maxLines: Math.max(3, Math.floor(rows / 2) - 4), width: columns })), suggestionsVisible && !dialog ? (_jsx(Box, { flexDirection: "column", paddingX: 2, children: suggestions.map((suggestion, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, wrap: "truncate-end", children: [index === selected ? "❯ " : " ", suggestion.label, suggestion.description ? _jsxs(Text, { dimColor: true, children: [" ", suggestion.description] }) : null] }, `${index}-${suggestion.label}`))) })) : null, _jsx(StatusBar, { app: app, agentName: agentName, busy: busy, model: busy ? live.current.model : "" })] })] }));
1008
1012
  }
1009
1013
  export async function startTui(app, options = {}) {
1010
1014
  const instance = render(_jsx(Main, { app: app, initial: options.initial || "" }), {
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Box, Text, useInput, usePaste } from "ink";
3
3
  import { useMemo, useState } from "react";
4
4
  import { suggestPattern } from "../permissions.js";
5
+ import { showControl, stripControl } from "../sanitize.js";
5
6
  import { colorPreview } from "../ui.js";
6
7
  import { EMPTY, backspace, deleteWordBack, insert, moveLeft, moveRight, splitEnter } from "./editor.js";
7
8
  const KIND_LABEL = {
@@ -96,7 +97,7 @@ export function PermissionDialog(props) {
96
97
  const lines = request.preview.split("\n");
97
98
  const shown = lines.slice(0, 30).join("\n");
98
99
  const hidden = lines.length - 30;
99
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", 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 !== "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
101
  }
101
102
  function matches(item, query) {
102
103
  return `${item.label} ${item.description || ""}`.toLowerCase().includes(query);
@@ -155,7 +156,7 @@ export function SelectDialog(props) {
155
156
  const visible = filtered.slice(start, start + windowSize);
156
157
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: props.title }), _jsx(Text, { dimColor: true, children: filter ? `Filtro: ${filter}` : "Escribe para filtrar · ↑↓ elegir · Enter aceptar · Esc salir" }), visible.length === 0 ? _jsx(Text, { dimColor: true, children: "(nada coincide)" }) : null, visible.map((item, index) => {
157
158
  const active = start + index === selected;
158
- return (_jsxs(Text, { color: active ? "cyan" : undefined, wrap: "truncate-end", children: [active ? "❯ " : " ", item.label, item.description ? _jsxs(Text, { dimColor: true, children: [" ", item.description] }) : null] }, `${start + index}-${item.label}`));
159
+ return (_jsxs(Text, { color: active ? "cyan" : undefined, wrap: "truncate-end", children: [active ? "❯ " : " ", stripControl(item.label), item.description ? _jsxs(Text, { dimColor: true, children: [" ", stripControl(item.description)] }) : null] }, `${start + index}-${item.label}`));
159
160
  }), filtered.length > windowSize ? (_jsxs(Text, { dimColor: true, children: [selected + 1, "/", filtered.length] })) : null] }));
160
161
  }
161
162
  // Pide un texto (las claves se ven como •••)
package/dist/ui.js CHANGED
@@ -1,22 +1,24 @@
1
1
  import pc from "picocolors";
2
2
  import { suggestPattern } from "./permissions.js";
3
+ import { showControl, stripControl, stripUnsafe } from "./sanitize.js";
3
4
  import { formatTodos } from "./tools.js";
4
5
  export const ui = {
5
6
  text(chunk) {
6
7
  process.stdout.write(chunk);
7
8
  },
9
+ // Los mensajes pueden llevar texto de los proveedores o del modelo: sin secuencias de la terminal
8
10
  info(message) {
9
- console.log(pc.dim(message));
11
+ console.log(pc.dim(stripUnsafe(message)));
10
12
  },
11
13
  notice(message) {
12
- console.log(pc.yellow(`⚠ ${message}`));
14
+ console.log(pc.yellow(`⚠ ${stripUnsafe(message)}`));
13
15
  },
14
16
  error(message) {
15
- console.log(pc.red(`✖ ${message}`));
17
+ console.log(pc.red(`✖ ${stripUnsafe(message)}`));
16
18
  },
17
19
  tool(name, summary, depth = 0) {
18
20
  const indent = depth > 0 ? " " : " ";
19
- console.log(pc.cyan(`${indent}● ${name}`) + pc.dim(` ${summary}`));
21
+ console.log(pc.cyan(`${indent}● ${showControl(name)}`) + pc.dim(` ${showControl(summary)}`));
20
22
  },
21
23
  banner(agent, slots) {
22
24
  console.log(pc.bold(pc.magenta("vipcode")) + pc.dim(` · agente ${agent} · ${slots} modelos · /help para ayuda`));
@@ -31,9 +33,10 @@ export const ui = {
31
33
  return pc.magenta("❯ ");
32
34
  },
33
35
  };
34
- // Líneas quitadas en rojo, añadidas en verde, cabeceras @@ en cian y el contexto atenuado
36
+ // Líneas quitadas en rojo, añadidas en verde, cabeceras @@ en cian y el contexto atenuado.
37
+ // Es lo que apruebas: los caracteres de control se ven (␛) en vez de actuar
35
38
  export function colorPreview(preview) {
36
- return preview
39
+ return showControl(preview)
37
40
  .split("\n")
38
41
  .map((line) => {
39
42
  if (line.startsWith("+ ")) {
@@ -75,7 +78,7 @@ export function plainEvents(options) {
75
78
  return;
76
79
  }
77
80
  flushReasoning();
78
- process.stdout.write(chunk);
81
+ process.stdout.write(stripControl(chunk));
79
82
  },
80
83
  onTextEnd: (depth) => {
81
84
  if (depth === 0) {
@@ -92,7 +95,7 @@ export function plainEvents(options) {
92
95
  process.stdout.write(pc.dim(" 💭 "));
93
96
  reasoningShown = true;
94
97
  }
95
- const piece = chunk.replace(/\s+/g, " ").slice(0, 240 - reasoning.length);
98
+ const piece = stripControl(chunk).replace(/\s+/g, " ").slice(0, 240 - reasoning.length);
96
99
  reasoning += piece;
97
100
  process.stdout.write(pc.dim(piece));
98
101
  if (reasoning.length >= 240) {
@@ -107,7 +110,7 @@ export function plainEvents(options) {
107
110
  onToolEnd: (event) => {
108
111
  const pad = event.depth > 0 ? " " : " ";
109
112
  if (event.isError) {
110
- console.log(pc.red(`${pad}${event.output.split("\n")[0].slice(0, 200)}`));
113
+ console.log(pc.red(`${pad}${stripControl(event.output.split("\n")[0]).slice(0, 200)}`));
111
114
  }
112
115
  else if (event.display && event.depth === 0) {
113
116
  console.log(indent(colorPreview(event.display), pad));
@@ -129,7 +132,7 @@ export function plainPermissionPrompt(getReadline, grants) {
129
132
  return { allow: false, feedback: "no hay nadie para aprobarlo (usa --yolo o ajusta los permisos)" };
130
133
  }
131
134
  console.log("");
132
- console.log(pc.bold(pc.yellow(`? ${request.title}`)));
135
+ console.log(pc.bold(pc.yellow(`? ${showControl(request.title)}`)));
133
136
  console.log(colorPreview(request.preview));
134
137
  let options = "[s]í / [n]o / [t]odo en esta sesión / [e] no y explicar";
135
138
  let pattern = "";
@@ -177,5 +180,5 @@ export const plainAdapter = {
177
180
  info: (text) => ui.info(text),
178
181
  notice: (text) => ui.notice(text),
179
182
  error: (text) => ui.error(text),
180
- block: (text) => console.log(text),
183
+ block: (text) => console.log(stripUnsafe(text)),
181
184
  };
package/dist/update.js CHANGED
@@ -2,13 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
5
  import { PACKAGE_NAME, REPO_URL, VERSION } from "./meta.js";
7
- import { HOME_DIR, UPDATE_PATH } from "./paths.js";
6
+ import { INSTALL_DIR, UPDATE_PATH, ensureHomeDir } from "./paths.js";
8
7
  const DAY_MS = 24 * 60 * 60 * 1000;
9
8
  // Carpeta donde está instalado vipcode (la que contiene dist/). VIPCODE_INSTALL_ROOT permite fingir otra en las pruebas
10
9
  export function packageRoot() {
11
- return process.env.VIPCODE_INSTALL_ROOT || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ return process.env.VIPCODE_INSTALL_ROOT || INSTALL_DIR;
12
11
  }
13
12
  // "v0.10.1" frente a "0.9.3": positivo si a es más nueva
14
13
  export function compareVersions(a, b) {
@@ -102,7 +101,7 @@ export async function checkForUpdate() {
102
101
  try {
103
102
  const release = await latestRelease();
104
103
  cache = { checked: Date.now(), latest: release?.version || VERSION };
105
- fs.mkdirSync(HOME_DIR, { recursive: true });
104
+ ensureHomeDir();
106
105
  fs.writeFileSync(UPDATE_PATH, JSON.stringify(cache, null, 2));
107
106
  }
108
107
  catch {
@@ -218,6 +217,9 @@ async function updateFromRelease(root, log) {
218
217
  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}`,
219
218
  };
220
219
  }
220
+ if (!/^[\w@.:/+~%-]+$/.test(release.spec) || release.spec.startsWith("-")) {
221
+ return { status: "error", message: `La última versión publicada tiene un nombre de paquete raro (${release.spec}): no la instalo.` };
222
+ }
221
223
  log(`Instalando vipcode ${release.version} desde ${release.source}…`);
222
224
  // Desde la carpeta temporal, para que no se mezcle con la configuración de npm del proyecto en el que estás
223
225
  const install = await run(NPM, ["install", "-g", release.spec], os.tmpdir());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcode",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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",
@@ -23,7 +23,7 @@
23
23
  "url": "git+https://github.com/PabloVip/vipcode.git"
24
24
  },
25
25
  "license": "MIT",
26
- "author": "Pablo Vicente (https://github.com/PabloVip)",
26
+ "author": "Pablo Vicente Juan (https://github.com/PabloVip)",
27
27
  "type": "module",
28
28
  "bin": {
29
29
  "vipcode": "dist/index.js"