termux-dev 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +214 -166
  2. package/assets/banner.svg +23 -28
  3. package/assets/preview.png +0 -0
  4. package/assets/preview.svg +92 -10
  5. package/dist/cli/banner.js +1 -1
  6. package/dist/cli/clipboard-write.js +55 -0
  7. package/dist/cli/clipboard.js +182 -106
  8. package/dist/cli/colors.js +67 -0
  9. package/dist/cli/confirm.js +50 -0
  10. package/dist/cli/doctor.js +4 -4
  11. package/dist/cli/effort.js +80 -0
  12. package/dist/cli/export.js +3 -3
  13. package/dist/cli/files.js +19 -5
  14. package/dist/cli/headless.js +23 -11
  15. package/dist/cli/index.js +1377 -480
  16. package/dist/cli/keys.js +69 -0
  17. package/dist/cli/liveblock.js +45 -0
  18. package/dist/cli/log.js +19 -0
  19. package/dist/cli/markdown.js +158 -36
  20. package/dist/cli/mcp-panel.js +432 -0
  21. package/dist/cli/permission.js +84 -0
  22. package/dist/cli/picker.js +135 -0
  23. package/dist/cli/prompt.js +256 -56
  24. package/dist/cli/resume.js +15 -0
  25. package/dist/cli/rewind.js +51 -0
  26. package/dist/cli/runtime_errors.js +6 -0
  27. package/dist/cli/server.js +85 -24
  28. package/dist/cli/settings.js +307 -0
  29. package/dist/cli/statusline.js +1 -1
  30. package/dist/cli/theme.js +126 -108
  31. package/dist/cli/transcript.js +114 -0
  32. package/dist/cli/typeahead.js +371 -0
  33. package/dist/cli/ui.js +1021 -0
  34. package/dist/cli/updater.js +23 -21
  35. package/dist/cli/usage-panel.js +242 -0
  36. package/dist/cli/width.js +178 -0
  37. package/dist/core/capabilities.js +433 -0
  38. package/dist/core/catalog-util.js +27 -0
  39. package/dist/core/checkpoint.js +90 -0
  40. package/dist/core/context-stats.js +29 -0
  41. package/dist/core/cost.js +191 -0
  42. package/dist/core/formats.js +199 -0
  43. package/dist/core/history.js +51 -1
  44. package/dist/core/hooks.js +79 -0
  45. package/dist/core/i18n.js +42 -0
  46. package/dist/core/local-config.js +69 -0
  47. package/dist/core/locales/de.js +210 -0
  48. package/dist/core/locales/en.js +221 -0
  49. package/dist/core/locales/es.js +210 -0
  50. package/dist/core/locales/fr.js +210 -0
  51. package/dist/core/locales/ja.js +210 -0
  52. package/dist/core/locales/pt.js +210 -0
  53. package/dist/core/locales/ru.js +210 -0
  54. package/dist/core/locales/uk.js +210 -0
  55. package/dist/core/locales/zh.js +210 -0
  56. package/dist/core/loop.js +125 -49
  57. package/dist/core/memory.js +39 -12
  58. package/dist/core/models.js +33 -20
  59. package/dist/core/network.js +12 -11
  60. package/dist/core/notify.js +54 -0
  61. package/dist/core/planfile.js +43 -0
  62. package/dist/core/price-catalog.json +1 -0
  63. package/dist/core/prices.js +249 -0
  64. package/dist/core/reasoning-catalog.json +1 -0
  65. package/dist/core/reasoning.js +393 -0
  66. package/dist/core/redact.js +41 -0
  67. package/dist/core/secure-file.js +22 -0
  68. package/dist/core/session.js +16 -5
  69. package/dist/core/snapshot.js +14 -2
  70. package/dist/core/title.js +61 -0
  71. package/dist/core/types.js +1 -1
  72. package/dist/core/usage.js +43 -7
  73. package/dist/core/vision-catalog.json +1 -0
  74. package/dist/core/vision.js +123 -0
  75. package/dist/mcp/client.js +36 -5
  76. package/dist/mcp/http-client.js +174 -0
  77. package/dist/mcp/manager.js +330 -93
  78. package/dist/permissions/guard.js +385 -117
  79. package/dist/prompts/builder.js +46 -38
  80. package/dist/providers/anthropic.js +517 -0
  81. package/dist/providers/index.js +18 -1
  82. package/dist/providers/openai.js +138 -125
  83. package/dist/providers/shared.js +116 -0
  84. package/dist/providers/toolguard.js +101 -0
  85. package/dist/tools/bash.js +141 -51
  86. package/dist/tools/diagnostics.js +76 -48
  87. package/dist/tools/fs.js +77 -5
  88. package/dist/tools/index.js +2 -1
  89. package/dist/tools/mcp.js +72 -0
  90. package/dist/tools/packages.js +16 -1
  91. package/dist/tools/plan.js +8 -8
  92. package/dist/tools/questions.js +50 -54
  93. package/dist/tools/server.js +6 -0
  94. package/dist/tools/todo.js +20 -18
  95. package/dist/tools/web.js +143 -8
  96. package/package.json +1 -1
@@ -1,5 +1,8 @@
1
- import { select, input } from '@inquirer/prompts';
2
- import pc from 'picocolors';
1
+ import { input } from '@inquirer/prompts';
2
+ import { runPicker } from '../cli/picker.js';
3
+ import { palette } from '../cli/ui.js';
4
+ import { wrapWords } from '../cli/width.js';
5
+ import { t } from '../core/i18n.js';
3
6
  export const askQuestionsTool = {
4
7
  name: 'ask_questions',
5
8
  definition: {
@@ -44,75 +47,68 @@ export const askQuestionsTool = {
44
47
  }
45
48
  const results = [];
46
49
  const total = args.questions.length;
50
+ const cols = process.stdout.columns || 80;
47
51
  console.log();
48
- // Pause raw mode for interactive prompt
52
+ // Pause raw mode for the typed answer (the picker handles its own keys)
49
53
  const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
50
- if (wasRaw) {
51
- process.stdin.setRawMode(false);
52
- }
53
54
  try {
54
55
  for (let i = 0; i < total; i++) {
55
56
  const q = args.questions[i];
56
- const stepHeader = pc.cyan(`‹ ${i + 1} of ${total} ›`);
57
- const messageTitle = `${pc.bold(q.question)} ${stepHeader}`;
58
- const choices = q.options.map((opt, idx) => ({
59
- name: `${pc.cyan(String(idx + 1))} ${opt}`,
60
- value: opt
61
- }));
62
- if (q.allowCustom !== false) {
63
- choices.push({
64
- name: `${pc.dim('✏️ Type custom answer...')}`,
65
- value: '__custom__'
66
- });
67
- }
68
- choices.push({
69
- name: `${pc.dim('⏭️ Skip')}`,
70
- value: '__skip__'
57
+ // The question stays on screen as plain text, the card under it holds the choices.
58
+ for (const line of wrapWords(q.question, Math.max(20, cols - 8)))
59
+ console.log(' ' + palette.bold(palette.text(line)));
60
+ const items = q.options.map((opt, idx) => ({ value: opt, label: `${idx + 1} ${opt}` }));
61
+ if (q.allowCustom !== false)
62
+ items.push({ value: '__custom__', label: t('questions.custom') });
63
+ items.push({ value: '__skip__', label: t('questions.skip') });
64
+ let answer = await runPicker({
65
+ title: q.id && q.id.length <= 24 ? q.id : t('questions.title'),
66
+ subtitle: total > 1 ? `${i + 1} / ${total}` : undefined,
67
+ items,
71
68
  });
72
- try {
73
- let answer = await select({
74
- message: messageTitle,
75
- choices,
76
- pageSize: Math.min(8, choices.length)
77
- });
78
- if (answer === '__custom__') {
79
- const customAns = await input({
80
- message: pc.cyan('Your custom answer:'),
81
- validate: (v) => v.trim().length > 0 || 'Answer cannot be empty'
82
- });
83
- answer = customAns.trim();
69
+ if (answer === '__custom__') {
70
+ if (wasRaw)
71
+ process.stdin.setRawMode(false);
72
+ try {
73
+ answer = (await input({
74
+ message: palette.accent(t('questions.yourAnswer')),
75
+ validate: (v) => v.trim().length > 0 || t('questions.empty'),
76
+ })).trim();
84
77
  }
85
- else if (answer === '__skip__') {
86
- answer = '(Skipped)';
78
+ catch {
79
+ answer = undefined;
80
+ }
81
+ finally {
82
+ if (wasRaw)
83
+ process.stdin.setRawMode(true);
84
+ process.stdin.resume();
87
85
  }
88
- results.push({
89
- question: q.question,
90
- answer
91
- });
92
86
  }
93
- catch {
94
- results.push({
95
- question: q.question,
96
- answer: '(Skipped)'
97
- });
87
+ else if (answer === '__skip__') {
88
+ answer = undefined;
98
89
  }
90
+ const given = answer ?? t('questions.skipped');
91
+ results.push({ question: q.question, answer: given });
92
+ // What was asked and what was answered stays in the conversation, quietly.
93
+ console.log(renderAnswerRow(q.question, given, answer === undefined, cols));
99
94
  }
100
95
  }
101
96
  finally {
102
- // Restore raw mode
103
97
  if (wasRaw && process.stdin.isTTY) {
104
98
  process.stdin.setRawMode(true);
105
99
  process.stdin.resume();
106
100
  }
107
101
  }
108
- // Print styled Q&A block in console
109
- console.log('\n' + pc.cyan('────────────────────────────────────────────'));
110
- const formattedBlocks = results.map(r => {
111
- console.log(pc.bold(pc.white(`Q: ${r.question}`)));
112
- console.log(pc.green(`A: ${r.answer}\n`));
113
- return `Q: ${r.question}\n\nA: ${r.answer}`;
114
- });
115
- console.log(pc.cyan('────────────────────────────────────────────\n'));
116
- return formattedBlocks.join('\n\n');
102
+ console.log();
103
+ return results.map((r) => `Q: ${r.question}\n\nA: ${r.answer}`).join('\n\n');
117
104
  }
118
105
  };
106
+ /** ` ✓ question` and the answer under it; a skipped one is muted. */
107
+ export function renderAnswerRow(question, answer, skipped, cols) {
108
+ const room = Math.max(10, cols - 8);
109
+ const q = wrapWords(question.replace(/\s+/g, ' ').trim(), room);
110
+ const a = wrapWords(answer.replace(/\s+/g, ' ').trim(), room - 2);
111
+ const head = q.map((line, i) => (i === 0 ? ` ${skipped ? palette.faint('-') : palette.ok('✓')} ${palette.muted(line)}` : ` ${palette.muted(line)}`));
112
+ const body = a.map((line, i) => ` ${i === 0 ? palette.accent('›') : ' '} ${skipped ? palette.faint(line) : palette.text(line)}`);
113
+ return [...head, ...body].join('\n');
114
+ }
@@ -24,6 +24,12 @@ export const servePreviewTool = {
24
24
  if (!args || !args.action) {
25
25
  throw new Error('action is required');
26
26
  }
27
+ if (!['start', 'stop', 'status'].includes(args.action)) {
28
+ throw new Error('action must be one of: start, stop, status');
29
+ }
30
+ if (args.port !== undefined && (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535)) {
31
+ throw new Error('port must be an integer between 1024 and 65535');
32
+ }
27
33
  },
28
34
  execute: async (args) => {
29
35
  if (args.action === 'stop') {
@@ -1,31 +1,33 @@
1
1
  import pc from 'picocolors';
2
+ import { displayWidth, padEndWidth, truncateWidth } from '../cli/width.js';
2
3
  export let currentTodoList = [];
3
4
  export function resetTodoList() {
4
5
  currentTodoList = [];
5
6
  }
7
+ /**
8
+ * The plan as a closed box (classic layout; the clean layout draws it with the theme, see
9
+ * `renderTaskCard`). Every row has the same width, so both borders line up.
10
+ */
6
11
  export function formatTodoListCard(tasks) {
7
12
  if (!tasks || tasks.length === 0)
8
13
  return '';
9
- const cols = Math.min(process.stdout.columns || 80, 80);
10
- const boxWidth = Math.max(30, Math.min(cols - 4, 65));
11
- const borderChar = '─';
12
- const lines = [];
13
- lines.push('\n' + pc.bold(pc.cyan('┌─ 📋 Plan & Tasks ' + borderChar.repeat(Math.max(2, boxWidth - 19)) + '┐')));
14
+ const cols = process.stdout.columns || 80;
15
+ const width = Math.max(30, Math.min(cols - 4, 65));
16
+ const inner = width - 4;
17
+ const title = ' Plan & Tasks ';
18
+ const top = '┌─' + title + '─'.repeat(Math.max(1, width - 3 - displayWidth(title))) + '┐';
19
+ const lines = [pc.bold(pc.cyan(top))];
14
20
  for (const task of tasks) {
15
- let taskLine = '';
16
- if (task.status === 'completed') {
17
- taskLine = pc.green('[✓] ') + pc.dim(task.text);
18
- }
19
- else if (task.status === 'in_progress') {
20
- taskLine = pc.bold(pc.green('[ ] ' + task.text));
21
- }
22
- else {
23
- taskLine = pc.dim('[ ] ') + pc.white(task.text);
24
- }
25
- lines.push(pc.cyan('│ ') + taskLine);
21
+ const marker = task.status === 'completed' ? '[✓]' : task.status === 'in_progress' ? '[•]' : '[ ]';
22
+ const text = truncateWidth(String(task.text ?? '').replace(/\s+/g, ' ').trim(), inner - 4);
23
+ const plain = padEndWidth(`${marker} ${text}`, inner);
24
+ const body = task.status === 'completed' ? pc.green(plain)
25
+ : task.status === 'in_progress' ? pc.bold(pc.cyan(plain))
26
+ : plain;
27
+ lines.push(pc.cyan('│ ') + body + pc.cyan(' │'));
26
28
  }
27
- lines.push(pc.cyan('└' + borderChar.repeat(boxWidth) + '┘\n'));
28
- return lines.join('\n');
29
+ lines.push(pc.cyan('└' + '─'.repeat(width - 2) + '┘'));
30
+ return lines.join('\n') + '\n';
29
31
  }
30
32
  export const todoListTool = {
31
33
  name: 'todo_list',
package/dist/tools/web.js CHANGED
@@ -1,3 +1,5 @@
1
+ import dns from 'dns/promises';
2
+ import net from 'net';
1
3
  function stripHtml(html) {
2
4
  return html
3
5
  .replace(/<script\b[\s\S]*?<\/script>/gi, '')
@@ -5,14 +7,150 @@ function stripHtml(html) {
5
7
  .replace(/<svg\b[\s\S]*?<\/svg>/gi, '')
6
8
  .replace(/<[^>]+>/g, ' ')
7
9
  .replace(/&quot;/g, '"')
8
- .replace(/&amp;/g, '&')
9
10
  .replace(/&lt;/g, '<')
10
11
  .replace(/&gt;/g, '>')
11
12
  .replace(/&#39;/g, "'")
12
13
  .replace(/&nbsp;/g, ' ')
14
+ // &amp; last, otherwise "&amp;lt;" would be decoded twice into "<"
15
+ .replace(/&amp;/g, '&')
13
16
  .replace(/\s+/g, ' ')
14
17
  .trim();
15
18
  }
19
+ // ───────────── SSRF protection for fetch_url ─────────────
20
+ // A prompt-injected page must not be able to make the agent talk to localhost,
21
+ // the LAN or cloud metadata endpoints (169.254.169.254). Set
22
+ // DEVX_ALLOW_PRIVATE_NETWORK=1 to fetch from local servers on purpose.
23
+ function isPrivateIPv4(ip) {
24
+ const [a, b, c] = ip.split('.').map(Number);
25
+ return (a === 0 || a === 10 || a === 127 ||
26
+ (a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
27
+ (a === 169 && b === 254) || // link-local + cloud metadata
28
+ (a === 172 && b >= 16 && b <= 31) ||
29
+ (a === 192 && b === 0 && c === 0) ||
30
+ (a === 192 && b === 168) ||
31
+ (a === 198 && (b === 18 || b === 19)) ||
32
+ a >= 224 // multicast / reserved / broadcast
33
+ );
34
+ }
35
+ function parseIPv6(ip) {
36
+ let s = ip.split('%')[0].toLowerCase();
37
+ const embedded = s.match(/(\d+\.\d+\.\d+\.\d+)$/);
38
+ if (embedded) {
39
+ const o = embedded[1].split('.').map(Number);
40
+ s = s.slice(0, -embedded[1].length) + ((o[0] << 8) | o[1]).toString(16) + ':' + ((o[2] << 8) | o[3]).toString(16);
41
+ }
42
+ const parts = s.split('::');
43
+ if (parts.length > 2)
44
+ return null;
45
+ const head = parts[0] ? parts[0].split(':') : [];
46
+ const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
47
+ let groups;
48
+ if (parts.length === 1) {
49
+ groups = head;
50
+ }
51
+ else {
52
+ const missing = 8 - head.length - tail.length;
53
+ if (missing < 0)
54
+ return null;
55
+ groups = [...head, ...Array(missing).fill('0'), ...tail];
56
+ }
57
+ if (groups.length !== 8)
58
+ return null;
59
+ const nums = groups.map(g => parseInt(g, 16));
60
+ return nums.some(n => Number.isNaN(n)) ? null : nums;
61
+ }
62
+ function isPrivateIPv6(ip) {
63
+ const g = parseIPv6(ip);
64
+ if (!g)
65
+ return true;
66
+ const v4 = (hi, lo) => isPrivateIPv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
67
+ if (g.slice(0, 7).every(n => n === 0) && g[7] <= 1)
68
+ return true; // :: and ::1
69
+ if ((g[0] & 0xffc0) === 0xfe80)
70
+ return true; // fe80::/10 link-local
71
+ if ((g[0] & 0xfe00) === 0xfc00)
72
+ return true; // fc00::/7 unique local
73
+ if ((g[0] & 0xff00) === 0xff00)
74
+ return true; // multicast
75
+ if (g.slice(0, 5).every(n => n === 0) && g[5] === 0xffff)
76
+ return v4(g[6], g[7]); // ::ffff:a.b.c.d
77
+ if (g[0] === 0x64 && g[1] === 0xff9b)
78
+ return v4(g[6], g[7]); // NAT64
79
+ if (g[0] === 0x2002)
80
+ return v4(g[1], g[2]); // 6to4
81
+ return false;
82
+ }
83
+ export function isPrivateAddress(ip) {
84
+ if (net.isIPv4(ip))
85
+ return isPrivateIPv4(ip);
86
+ if (net.isIPv6(ip))
87
+ return isPrivateIPv6(ip);
88
+ return true; // not an address we understand: refuse
89
+ }
90
+ async function assertPublicHost(rawHostname) {
91
+ if (process.env.DEVX_ALLOW_PRIVATE_NETWORK === '1')
92
+ return;
93
+ const host = rawHostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
94
+ const blocked = () => new Error(`Blocked: ${rawHostname} points to a local or private network address (set DEVX_ALLOW_PRIVATE_NETWORK=1 to allow).`);
95
+ if (net.isIP(host)) {
96
+ if (isPrivateAddress(host))
97
+ throw blocked();
98
+ return;
99
+ }
100
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
101
+ throw blocked();
102
+ }
103
+ const addresses = await dns.lookup(host, { all: true });
104
+ if (addresses.length === 0 || addresses.some(a => isPrivateAddress(a.address)))
105
+ throw blocked();
106
+ }
107
+ const MAX_REDIRECTS = 5;
108
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
109
+ /** fetch() that validates the target and every redirect hop against private networks. */
110
+ export async function fetchPublic(startUrl, headers, timeoutMs) {
111
+ const signal = AbortSignal.timeout(timeoutMs);
112
+ let current = new URL(startUrl);
113
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
114
+ if (current.protocol !== 'http:' && current.protocol !== 'https:') {
115
+ throw new Error(`Unsupported protocol: ${current.protocol}`);
116
+ }
117
+ await assertPublicHost(current.hostname);
118
+ const res = await fetch(current, { headers, signal, redirect: 'manual' });
119
+ const location = res.headers.get('location');
120
+ if (res.status >= 300 && res.status < 400 && location) {
121
+ try {
122
+ await res.body?.cancel();
123
+ }
124
+ catch { }
125
+ current = new URL(location, current);
126
+ continue;
127
+ }
128
+ return res;
129
+ }
130
+ throw new Error('Too many redirects');
131
+ }
132
+ async function readTextLimited(res, maxBytes) {
133
+ const reader = res.body?.getReader();
134
+ if (!reader)
135
+ return (await res.text()).slice(0, maxBytes);
136
+ const chunks = [];
137
+ let total = 0;
138
+ while (true) {
139
+ const { done, value } = await reader.read();
140
+ if (done)
141
+ break;
142
+ chunks.push(Buffer.from(value));
143
+ total += value.byteLength;
144
+ if (total >= maxBytes) {
145
+ try {
146
+ await reader.cancel();
147
+ }
148
+ catch { }
149
+ break;
150
+ }
151
+ }
152
+ return Buffer.concat(chunks).toString('utf8');
153
+ }
16
154
  export const webSearchTool = {
17
155
  name: 'web_search',
18
156
  definition: {
@@ -128,16 +266,13 @@ export const fetchUrlTool = {
128
266
  if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) {
129
267
  targetUrl = `https://${targetUrl}`;
130
268
  }
131
- const res = await fetch(targetUrl, {
132
- headers: {
133
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
134
- },
135
- signal: AbortSignal.timeout(12000)
136
- });
269
+ const res = await fetchPublic(targetUrl, {
270
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
271
+ }, 12000);
137
272
  if (!res.ok) {
138
273
  throw new Error(`HTTP Error ${res.status}: ${res.statusText}`);
139
274
  }
140
- const html = await res.text();
275
+ const html = await readTextLimited(res, MAX_RESPONSE_BYTES);
141
276
  const cleanText = stripHtml(html);
142
277
  if (!cleanText) {
143
278
  return 'Webpage returned empty text content.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termux-dev",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Ultra-fast, terminal-native AI coding assistant and agent built for Android Termux, Windows, macOS, and Linux.",
5
5
  "main": "dist/cli/index.js",
6
6
  "type": "module",