termux-dev 1.6.0 → 1.8.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.
Files changed (99) 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 +101 -10
  5. package/dist/cli/banner.js +3 -4
  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/finder.js +241 -0
  15. package/dist/cli/headless.js +23 -11
  16. package/dist/cli/index.js +1557 -744
  17. package/dist/cli/keys.js +69 -0
  18. package/dist/cli/liveblock.js +45 -0
  19. package/dist/cli/log.js +19 -0
  20. package/dist/cli/logo.js +30 -0
  21. package/dist/cli/markdown.js +174 -37
  22. package/dist/cli/mcp-panel.js +432 -0
  23. package/dist/cli/permission.js +84 -0
  24. package/dist/cli/picker.js +143 -0
  25. package/dist/cli/prompt.js +256 -56
  26. package/dist/cli/resume.js +15 -0
  27. package/dist/cli/rewind.js +51 -0
  28. package/dist/cli/runtime_errors.js +6 -0
  29. package/dist/cli/server.js +85 -24
  30. package/dist/cli/settings.js +307 -0
  31. package/dist/cli/statusline.js +1 -1
  32. package/dist/cli/theme.js +126 -108
  33. package/dist/cli/transcript.js +134 -0
  34. package/dist/cli/typeahead.js +371 -0
  35. package/dist/cli/ui.js +1022 -0
  36. package/dist/cli/updater.js +23 -21
  37. package/dist/cli/usage-panel.js +242 -0
  38. package/dist/cli/welcome.js +56 -0
  39. package/dist/cli/width.js +178 -0
  40. package/dist/core/capabilities.js +433 -0
  41. package/dist/core/catalog-util.js +27 -0
  42. package/dist/core/checkpoint.js +90 -0
  43. package/dist/core/context-stats.js +29 -0
  44. package/dist/core/cost.js +191 -0
  45. package/dist/core/formats.js +199 -0
  46. package/dist/core/history.js +51 -1
  47. package/dist/core/hooks.js +79 -0
  48. package/dist/core/i18n.js +42 -0
  49. package/dist/core/local-config.js +69 -0
  50. package/dist/core/locales/de.js +265 -0
  51. package/dist/core/locales/en.js +276 -0
  52. package/dist/core/locales/es.js +265 -0
  53. package/dist/core/locales/fr.js +265 -0
  54. package/dist/core/locales/ja.js +265 -0
  55. package/dist/core/locales/pt.js +265 -0
  56. package/dist/core/locales/ru.js +265 -0
  57. package/dist/core/locales/uk.js +265 -0
  58. package/dist/core/locales/zh.js +265 -0
  59. package/dist/core/loop.js +125 -49
  60. package/dist/core/memory.js +39 -12
  61. package/dist/core/models.js +33 -20
  62. package/dist/core/network.js +12 -11
  63. package/dist/core/notify.js +54 -0
  64. package/dist/core/planfile.js +43 -0
  65. package/dist/core/price-catalog.json +1 -0
  66. package/dist/core/prices.js +249 -0
  67. package/dist/core/reasoning-catalog.json +1 -0
  68. package/dist/core/reasoning.js +393 -0
  69. package/dist/core/redact.js +41 -0
  70. package/dist/core/secure-file.js +22 -0
  71. package/dist/core/session.js +16 -5
  72. package/dist/core/snapshot.js +14 -2
  73. package/dist/core/title.js +61 -0
  74. package/dist/core/types.js +1 -1
  75. package/dist/core/usage.js +43 -7
  76. package/dist/core/vision-catalog.json +1 -0
  77. package/dist/core/vision.js +123 -0
  78. package/dist/mcp/client.js +36 -5
  79. package/dist/mcp/http-client.js +174 -0
  80. package/dist/mcp/manager.js +330 -93
  81. package/dist/permissions/guard.js +385 -117
  82. package/dist/prompts/builder.js +46 -38
  83. package/dist/providers/anthropic.js +517 -0
  84. package/dist/providers/index.js +18 -1
  85. package/dist/providers/openai.js +138 -125
  86. package/dist/providers/shared.js +116 -0
  87. package/dist/providers/toolguard.js +101 -0
  88. package/dist/tools/bash.js +141 -51
  89. package/dist/tools/diagnostics.js +76 -48
  90. package/dist/tools/fs.js +77 -5
  91. package/dist/tools/index.js +2 -1
  92. package/dist/tools/mcp.js +72 -0
  93. package/dist/tools/packages.js +16 -1
  94. package/dist/tools/plan.js +8 -8
  95. package/dist/tools/questions.js +50 -54
  96. package/dist/tools/server.js +6 -0
  97. package/dist/tools/todo.js +20 -18
  98. package/dist/tools/web.js +143 -8
  99. package/package.json +1 -1
@@ -1,13 +1,144 @@
1
1
  import { spawn } from 'child_process';
2
+ import { StringDecoder } from 'string_decoder';
3
+ export const DEFAULT_TIMEOUT_SECONDS = 120;
4
+ export const MAX_TIMEOUT_SECONDS = 1800;
5
+ // Output is kept as "head + tail": build/test tools print their summary and
6
+ // their errors at the very end, so the tail must never be the part we drop.
7
+ const HEAD_CHARS = 6000;
8
+ const TAIL_CHARS = 14000;
9
+ class OutputBuffer {
10
+ head = '';
11
+ tail = '';
12
+ dropped = 0;
13
+ push(text) {
14
+ if (this.head.length < HEAD_CHARS) {
15
+ const room = HEAD_CHARS - this.head.length;
16
+ this.head += text.slice(0, room);
17
+ text = text.slice(room);
18
+ }
19
+ if (!text)
20
+ return;
21
+ this.tail += text;
22
+ // Trim lazily (at 2x) so a chatty command does not cause a slice per chunk.
23
+ if (this.tail.length > TAIL_CHARS * 2) {
24
+ const cut = this.tail.length - TAIL_CHARS;
25
+ this.dropped += cut;
26
+ this.tail = this.tail.slice(cut);
27
+ }
28
+ }
29
+ toString() {
30
+ let tail = this.tail;
31
+ let dropped = this.dropped;
32
+ if (tail.length > TAIL_CHARS) {
33
+ dropped += tail.length - TAIL_CHARS;
34
+ tail = tail.slice(tail.length - TAIL_CHARS);
35
+ }
36
+ if (dropped === 0)
37
+ return this.head + tail;
38
+ return `${this.head}\n[... ${dropped} characters omitted ...]\n${tail}`;
39
+ }
40
+ }
41
+ export function normalizeTimeout(value) {
42
+ const n = typeof value === 'string' ? Number(value) : value;
43
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0)
44
+ return DEFAULT_TIMEOUT_SECONDS;
45
+ return Math.min(Math.max(n, 1), MAX_TIMEOUT_SECONDS);
46
+ }
47
+ /**
48
+ * Runs a shell command, killing the whole process tree on cancel / timeout.
49
+ * stdin is closed so commands that wait for input fail fast instead of hanging.
50
+ */
51
+ export function runShell(command, options = {}) {
52
+ const { signal } = options;
53
+ const timeoutSeconds = normalizeTimeout(options.timeoutSeconds);
54
+ return new Promise((resolve, reject) => {
55
+ const isWin = process.platform === 'win32';
56
+ const proc = spawn(command, {
57
+ shell: true,
58
+ detached: !isWin,
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ });
61
+ const output = new OutputBuffer();
62
+ const stdoutDecoder = new StringDecoder('utf8');
63
+ const stderrDecoder = new StringDecoder('utf8');
64
+ let stopReason = '';
65
+ let killTimer;
66
+ const killProcessTree = (force) => {
67
+ if (!proc.pid)
68
+ return;
69
+ if (isWin) {
70
+ // Killing the shell alone leaves its child commands running on Windows.
71
+ const killer = spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
72
+ windowsHide: true,
73
+ stdio: 'ignore',
74
+ });
75
+ killer.on('error', () => proc.kill());
76
+ return;
77
+ }
78
+ try {
79
+ process.kill(-proc.pid, force ? 'SIGKILL' : 'SIGTERM');
80
+ }
81
+ catch (err) {
82
+ if (err.code !== 'ESRCH')
83
+ proc.kill(force ? 'SIGKILL' : 'SIGTERM');
84
+ }
85
+ };
86
+ const stop = (reason) => {
87
+ if (stopReason)
88
+ return;
89
+ stopReason = reason;
90
+ killProcessTree(false);
91
+ // Commands can ignore SIGTERM. Do not leave them running after cancellation.
92
+ killTimer = setTimeout(() => killProcessTree(true), 1000);
93
+ };
94
+ const onAbort = () => stop('[Operation cancelled by user]');
95
+ const timeout = setTimeout(() => stop(`[Process killed due to timeout after ${timeoutSeconds}s. ` +
96
+ `Pass a larger "timeout_seconds" (max ${MAX_TIMEOUT_SECONDS}) for long-running commands.]`), timeoutSeconds * 1000);
97
+ signal?.addEventListener('abort', onAbort, { once: true });
98
+ if (signal?.aborted)
99
+ onAbort();
100
+ proc.stdout.on('data', (data) => {
101
+ if (!stopReason)
102
+ output.push(stdoutDecoder.write(data));
103
+ });
104
+ proc.stderr.on('data', (data) => {
105
+ if (!stopReason)
106
+ output.push(stderrDecoder.write(data));
107
+ });
108
+ const cleanup = () => {
109
+ clearTimeout(timeout);
110
+ clearTimeout(killTimer);
111
+ signal?.removeEventListener('abort', onAbort);
112
+ };
113
+ proc.on('close', (code) => {
114
+ if (stopReason)
115
+ killProcessTree(true);
116
+ output.push(stdoutDecoder.end() + stderrDecoder.end());
117
+ cleanup();
118
+ resolve({ output: output.toString(), code, stopReason });
119
+ });
120
+ proc.on('error', (err) => {
121
+ cleanup();
122
+ reject(new Error(`Failed to start process: ${err.message}`));
123
+ });
124
+ });
125
+ }
2
126
  export const bashTool = {
3
127
  name: 'bash',
4
128
  definition: {
5
129
  name: 'bash',
6
- description: 'Execute a bash command',
130
+ description: `Execute a bash command. stdin is closed (no interactive input). ` +
131
+ `Commands are stopped after ${DEFAULT_TIMEOUT_SECONDS}s by default; ` +
132
+ `for long builds/installs pass "timeout_seconds" (max ${MAX_TIMEOUT_SECONDS}). ` +
133
+ `Very long output is shortened in the middle, the end is always kept.`,
7
134
  parameters: {
8
135
  type: 'object',
9
136
  properties: {
10
- command: { type: 'string' }
137
+ command: { type: 'string' },
138
+ timeout_seconds: {
139
+ type: 'number',
140
+ description: `Optional time limit in seconds (default ${DEFAULT_TIMEOUT_SECONDS}, max ${MAX_TIMEOUT_SECONDS})`,
141
+ },
11
142
  },
12
143
  required: ['command']
13
144
  }
@@ -16,55 +147,14 @@ export const bashTool = {
16
147
  if (!args.command || typeof args.command !== 'string')
17
148
  throw new Error('command is required');
18
149
  },
19
- async execute(args) {
20
- return new Promise((resolve, reject) => {
21
- const isWin = process.platform === 'win32';
22
- const proc = spawn(args.command, { shell: true, detached: !isWin });
23
- let output = '';
24
- let isTruncated = false;
25
- const killProc = () => {
26
- try {
27
- if (!isWin && proc.pid) {
28
- process.kill(-proc.pid, 'SIGTERM');
29
- }
30
- else {
31
- proc.kill();
32
- }
33
- }
34
- catch { }
35
- };
36
- const timeout = setTimeout(() => {
37
- killProc();
38
- resolve(output + '\n[Process killed due to timeout]');
39
- }, 30000);
40
- proc.stdout.on('data', (data) => {
41
- if (isTruncated)
42
- return;
43
- output += data.toString();
44
- if (output.length > 20000) {
45
- isTruncated = true;
46
- output = output.substring(0, 20000) + '\n[Output truncated]';
47
- killProc();
48
- }
49
- });
50
- proc.stderr.on('data', (data) => {
51
- if (isTruncated)
52
- return;
53
- output += data.toString();
54
- if (output.length > 20000) {
55
- isTruncated = true;
56
- output = output.substring(0, 20000) + '\n[Output truncated]';
57
- killProc();
58
- }
59
- });
60
- proc.on('close', (code) => {
61
- clearTimeout(timeout);
62
- resolve(`${output}\n[Exit code: ${code}]`);
63
- });
64
- proc.on('error', (err) => {
65
- clearTimeout(timeout);
66
- reject(new Error(`Failed to start process: ${err.message}`));
67
- });
150
+ async execute(args, _config, context) {
151
+ const signal = context?.signal;
152
+ if (signal?.aborted)
153
+ return '[Operation cancelled by user]';
154
+ const { output, code, stopReason } = await runShell(args.command, {
155
+ signal,
156
+ timeoutSeconds: args.timeout_seconds,
68
157
  });
158
+ return `${output}\n${stopReason || `[Exit code: ${code}]`}`;
69
159
  }
70
160
  };
@@ -1,5 +1,57 @@
1
- import { exec } from 'child_process';
2
1
  import fsSync from 'fs';
2
+ import path from 'path';
3
+ import { runShell } from './bash.js';
4
+ const DIAGNOSE_TIMEOUT_SECONDS = 180;
5
+ // The file name ends up inside a quoted shell argument, so only plain path
6
+ // characters are accepted. Anything else (quotes, $, `, ;, &, |, <, >, %, ^,
7
+ // newlines, backslashes...) is rejected instead of being escaped.
8
+ const SAFE_PATH = /^[\w\-./@+ ]+$/;
9
+ /**
10
+ * Validates the user/model supplied file and returns a cwd-relative POSIX path.
11
+ * Throws if it is unsafe to place on a command line or escapes the project.
12
+ */
13
+ export function resolveDiagnoseFile(file) {
14
+ const cwd = process.cwd();
15
+ const absolute = path.resolve(cwd, file);
16
+ const rel = path.relative(cwd, absolute);
17
+ if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) {
18
+ throw new Error(`File must be inside the project directory: ${file}`);
19
+ }
20
+ const posix = rel.split(path.sep).join('/');
21
+ if (posix.startsWith('-')) {
22
+ throw new Error(`Invalid file name: ${file}`);
23
+ }
24
+ if (!SAFE_PATH.test(posix)) {
25
+ throw new Error(`Invalid file name (unsupported characters): ${file}`);
26
+ }
27
+ if (!fsSync.existsSync(absolute)) {
28
+ throw new Error(`File not found: ${file}`);
29
+ }
30
+ return posix;
31
+ }
32
+ function pickCommand(file) {
33
+ if (file) {
34
+ const quoted = `"${file}"`;
35
+ if (file.endsWith('.ts') || file.endsWith('.tsx')) {
36
+ if (fsSync.existsSync('tsconfig.json'))
37
+ return 'npx --no-install tsc --noEmit';
38
+ return 'echo "node --check does not work for TypeScript. Please use tsc or tsx."';
39
+ }
40
+ if (file.endsWith('.js') || file.endsWith('.mjs') || file.endsWith('.cjs'))
41
+ return `node --check ${quoted}`;
42
+ if (file.endsWith('.py'))
43
+ return `python -m py_compile ${quoted}`;
44
+ if (file.endsWith('.rs'))
45
+ return 'cargo check';
46
+ }
47
+ if (fsSync.existsSync('tsconfig.json'))
48
+ return 'npx --no-install tsc --noEmit';
49
+ if (fsSync.existsSync('Cargo.toml'))
50
+ return 'cargo check';
51
+ if (fsSync.existsSync('package.json'))
52
+ return 'npm test -- --passWithNoTests';
53
+ return null;
54
+ }
3
55
  export const diagnoseCodeTool = {
4
56
  name: 'diagnose_code',
5
57
  definition: {
@@ -12,53 +64,29 @@ export const diagnoseCodeTool = {
12
64
  }
13
65
  }
14
66
  },
15
- validateArgs() { },
16
- async execute(args) {
17
- return new Promise((resolve) => {
18
- let cmd = '';
19
- if (args.file) {
20
- const quotedFile = `"${args.file}"`;
21
- if (args.file.endsWith('.ts') || args.file.endsWith('.tsx')) {
22
- if (fsSync.existsSync('tsconfig.json')) {
23
- cmd = 'npx tsc --noEmit';
24
- }
25
- else {
26
- cmd = `echo "node --check does not work for TypeScript. Please use tsc or tsx."`;
27
- }
28
- }
29
- else if (args.file.endsWith('.js') || args.file.endsWith('.mjs') || args.file.endsWith('.cjs')) {
30
- cmd = `node --check ${quotedFile}`;
31
- }
32
- else if (args.file.endsWith('.py')) {
33
- cmd = `python -m py_compile ${quotedFile}`;
34
- }
35
- else if (args.file.endsWith('.rs')) {
36
- cmd = 'cargo check';
37
- }
38
- }
39
- if (!cmd) {
40
- if (fsSync.existsSync('tsconfig.json')) {
41
- cmd = 'npx tsc --noEmit';
42
- }
43
- else if (fsSync.existsSync('Cargo.toml')) {
44
- cmd = 'cargo check';
45
- }
46
- else if (fsSync.existsSync('package.json')) {
47
- cmd = 'npm test -- --passWithNoTests';
48
- }
49
- else {
50
- return resolve('No automated checker found for this workspace. Manual review looks good.');
51
- }
52
- }
53
- exec(cmd, (err, stdout, stderr) => {
54
- const out = (stdout + '\n' + stderr).trim();
55
- if (!err) {
56
- resolve('✅ Diagnostics passed with 0 errors! Code is clean and valid.');
57
- }
58
- else {
59
- resolve(`❌ Diagnostic errors found:\n${out.slice(0, 3000)}`);
60
- }
61
- });
67
+ validateArgs(args) {
68
+ if (args?.file !== undefined && args.file !== null && args.file !== '') {
69
+ if (typeof args.file !== 'string')
70
+ throw new Error('file must be a string');
71
+ resolveDiagnoseFile(args.file);
72
+ }
73
+ },
74
+ async execute(args, _config, context) {
75
+ const file = args?.file ? resolveDiagnoseFile(args.file) : undefined;
76
+ const cmd = pickCommand(file);
77
+ if (!cmd) {
78
+ return 'No automated checker found for this workspace. Manual review looks good.';
79
+ }
80
+ const { output, code, stopReason } = await runShell(cmd, {
81
+ signal: context?.signal,
82
+ timeoutSeconds: DIAGNOSE_TIMEOUT_SECONDS,
62
83
  });
84
+ if (stopReason) {
85
+ return `Diagnostics did not finish: ${stopReason}\n${output.trim().slice(-3000)}`;
86
+ }
87
+ if (code === 0) {
88
+ return 'Diagnostics passed with 0 errors. Code is clean and valid.';
89
+ }
90
+ return `Diagnostic errors found:\n${output.trim().slice(-3000)}`;
63
91
  }
64
92
  };
package/dist/tools/fs.js CHANGED
@@ -1,15 +1,30 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import { globalSnapshotManager } from '../core/snapshot.js';
4
+ // A single read result lands in the model context, so it must stay bounded.
5
+ export const MAX_READ_CHARS = 60000;
6
+ const MAX_READ_FILE_BYTES = 10 * 1024 * 1024;
7
+ function toLineNumber(value, name) {
8
+ if (value === undefined || value === null || value === '')
9
+ return undefined;
10
+ const n = typeof value === 'string' ? Number(value) : value;
11
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1) {
12
+ throw new Error(`${name} must be a positive integer`);
13
+ }
14
+ return n;
15
+ }
4
16
  export const readFileTool = {
5
17
  name: 'read_file',
6
18
  definition: {
7
19
  name: 'read_file',
8
- description: 'Read contents of a file',
20
+ description: `Read contents of a file. Large files are returned in parts (max ~${MAX_READ_CHARS} characters per call): ` +
21
+ 'use startLine/endLine (1-based, inclusive) to read a specific range or to continue reading.',
9
22
  parameters: {
10
23
  type: 'object',
11
24
  properties: {
12
- path: { type: 'string' }
25
+ path: { type: 'string' },
26
+ startLine: { type: 'number', description: 'First line to read (1-based, optional)' },
27
+ endLine: { type: 'number', description: 'Last line to read (inclusive, optional)' }
13
28
  },
14
29
  required: ['path']
15
30
  }
@@ -17,10 +32,63 @@ export const readFileTool = {
17
32
  validateArgs(args) {
18
33
  if (!args.path || typeof args.path !== 'string')
19
34
  throw new Error('path is required');
35
+ const start = toLineNumber(args.startLine, 'startLine');
36
+ const end = toLineNumber(args.endLine, 'endLine');
37
+ if (start !== undefined && end !== undefined && end < start) {
38
+ throw new Error('endLine must be greater than or equal to startLine');
39
+ }
20
40
  },
21
41
  async execute(args) {
22
42
  try {
23
- return await fs.readFile(args.path, 'utf8');
43
+ const stat = await fs.stat(args.path);
44
+ if (stat.isDirectory()) {
45
+ throw new Error('Path is a directory, use list_dir instead');
46
+ }
47
+ if (stat.size > MAX_READ_FILE_BYTES) {
48
+ throw new Error(`File is too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Use the search tool, or bash (e.g. head/tail/sed -n) to look at part of it`);
49
+ }
50
+ const buffer = await fs.readFile(args.path);
51
+ if (buffer.subarray(0, 8000).includes(0)) {
52
+ return `[Binary file ${args.path} (${stat.size} bytes) - contents not shown]`;
53
+ }
54
+ const text = buffer.toString('utf8');
55
+ const startArg = toLineNumber(args.startLine, 'startLine');
56
+ const endArg = toLineNumber(args.endLine, 'endLine');
57
+ // Small files without a range: return the exact content.
58
+ if (startArg === undefined && endArg === undefined && text.length <= MAX_READ_CHARS) {
59
+ return text;
60
+ }
61
+ const lines = text.split('\n');
62
+ const total = lines.length;
63
+ const start = Math.min(startArg ?? 1, total);
64
+ const end = Math.min(Math.max(endArg ?? total, start), total);
65
+ let selected = lines.slice(start - 1, end);
66
+ let shownEnd = end;
67
+ let joined = selected.join('\n');
68
+ let truncated = false;
69
+ if (joined.length > MAX_READ_CHARS) {
70
+ truncated = true;
71
+ // Cut on a line boundary so the model can continue exactly where we stopped.
72
+ let size = 0;
73
+ let count = 0;
74
+ for (const line of selected) {
75
+ if (size + line.length + 1 > MAX_READ_CHARS && count > 0)
76
+ break;
77
+ size += line.length + 1;
78
+ count++;
79
+ }
80
+ selected = selected.slice(0, count);
81
+ shownEnd = start + count - 1;
82
+ joined = selected.join('\n');
83
+ if (joined.length > MAX_READ_CHARS) {
84
+ // A single enormous line (e.g. minified bundle).
85
+ joined = joined.slice(0, MAX_READ_CHARS);
86
+ }
87
+ }
88
+ const note = truncated
89
+ ? ` - output truncated, continue with startLine=${shownEnd + 1}`
90
+ : shownEnd < total ? ` - file continues, use startLine=${shownEnd + 1} to read more` : '';
91
+ return `[Lines ${start}-${shownEnd} of ${total}${note}]\n${joined}`;
24
92
  }
25
93
  catch (err) {
26
94
  throw new Error(`Failed to read file: ${err.message}`);
@@ -117,12 +185,16 @@ export const editFileTool = {
117
185
  validateArgs(args) {
118
186
  if (!args.path || typeof args.path !== 'string')
119
187
  throw new Error('path is required');
120
- if (typeof args.target !== 'string')
121
- throw new Error('target is required');
188
+ if (typeof args.target !== 'string' || args.target === '') {
189
+ throw new Error('target is required and must not be empty (use write_file to create or overwrite a whole file)');
190
+ }
122
191
  if (typeof args.replacement !== 'string')
123
192
  throw new Error('replacement is required');
124
193
  },
125
194
  async execute(args) {
195
+ if (typeof args.target !== 'string' || args.target === '') {
196
+ throw new Error('Failed to edit file: target must not be empty');
197
+ }
126
198
  try {
127
199
  await globalSnapshotManager.recordFileBeforeChange(args.path);
128
200
  const oldContent = await fs.readFile(args.path, 'utf8');
@@ -9,6 +9,7 @@ import { saveMemoryTool } from '../core/memory.js';
9
9
  import { planReadyTool, lastPlanReady, resetPlanReady } from './plan.js';
10
10
  import { todoListTool, currentTodoList, resetTodoList } from './todo.js';
11
11
  import { servePreviewTool } from './server.js';
12
+ import { mcpManageTool } from './mcp.js';
12
13
  import { MCPManager } from '../mcp/manager.js';
13
14
  export function getTools(planMode) {
14
15
  const baseTools = [readFileTool, listDirTool, searchTool, askQuestionsTool, webSearchTool, fetchUrlTool, saveMemoryTool, planReadyTool, todoListTool];
@@ -16,6 +17,6 @@ export function getTools(planMode) {
16
17
  if (planMode) {
17
18
  return [...baseTools, ...mcpTools];
18
19
  }
19
- return [...baseTools, writeFileTool, editFileTool, mkdirTool, bashTool, diagnoseCodeTool, installPackageTool, servePreviewTool, ...mcpTools];
20
+ return [...baseTools, writeFileTool, editFileTool, mkdirTool, bashTool, diagnoseCodeTool, installPackageTool, servePreviewTool, mcpManageTool, ...mcpTools];
20
21
  }
21
22
  export { webSearchTool, fetchUrlTool, diagnoseCodeTool, installPackageTool, saveMemoryTool, planReadyTool, lastPlanReady, resetPlanReady, todoListTool, currentTodoList, resetTodoList, servePreviewTool };
@@ -0,0 +1,72 @@
1
+ import { MCPManager, readClaudeServers, saveUserServers } from '../mcp/manager.js';
2
+ /**
3
+ * Lets the agent set up MCP servers when the user asks ("connect to Notion over MCP").
4
+ * Adding a server starts a program on the user's machine, so devx always asks first (see
5
+ * the guard), also in YOLO mode. The server is written to the user's own `~/.devx/mcp.json`.
6
+ */
7
+ export const mcpManageTool = {
8
+ name: 'mcp_manage',
9
+ definition: {
10
+ name: 'mcp_manage',
11
+ description: "Manage MCP servers of devx. action 'list': the servers with their state, tools and errors. " +
12
+ "action 'add': save a server (name, command, args, env) to ~/.devx/mcp.json and start it; the user is asked to approve. " +
13
+ "action 'import_claude': take the servers the user already has in Claude Code (~/.claude.json). " +
14
+ "The tools of a newly added server can be called from the user's next message on.",
15
+ parameters: {
16
+ type: 'object',
17
+ properties: {
18
+ action: { type: 'string', enum: ['list', 'add', 'import_claude'], description: 'What to do' },
19
+ name: { type: 'string', description: "add: short server name, letters, digits, '-' and '_' (it becomes part of the tool names)" },
20
+ command: { type: 'string', description: "add: the program to start, e.g. 'npx', 'uvx', or a path to a binary (for a local server)" },
21
+ url: { type: 'string', description: 'add: the address of a remote MCP server over HTTP, instead of command. Put a token in headers; a server that needs OAuth is reached with command npx and args ["-y","mcp-remote","<url>"] instead.' },
22
+ headers: { type: 'object', description: 'add (with url): HTTP headers, e.g. {"Authorization": "Bearer ${TOKEN}"}: ${NAME} is read from the environment' },
23
+ args: { type: 'array', items: { type: 'string' }, description: "add: arguments, e.g. ['-y', 'mcp-remote', 'https://mcp.notion.com/mcp']" },
24
+ env: { type: 'object', description: 'add: environment variables the server needs (tokens go here, never into args)' },
25
+ },
26
+ required: ['action'],
27
+ },
28
+ },
29
+ validateArgs(args) {
30
+ if (!args || !['list', 'add', 'import_claude'].includes(args.action))
31
+ throw new Error("action must be 'list', 'add' or 'import_claude'");
32
+ if (args.action === 'add') {
33
+ if (!args.name || !/^[A-Za-z0-9_-]{1,40}$/.test(args.name))
34
+ throw new Error("name is required: letters, digits, '-' and '_', up to 40 characters");
35
+ if (args.url !== undefined) {
36
+ if (typeof args.url !== 'string' || !/^https?:\/\//i.test(args.url))
37
+ throw new Error('url must start with http:// or https://');
38
+ }
39
+ else if (!args.command || typeof args.command !== 'string') {
40
+ throw new Error('command (or url) is required');
41
+ }
42
+ if (args.args !== undefined && !Array.isArray(args.args))
43
+ throw new Error('args must be an array of strings');
44
+ }
45
+ },
46
+ async execute(args) {
47
+ const manager = MCPManager.getInstance();
48
+ if (args.action === 'add') {
49
+ saveUserServers({
50
+ [args.name]: args.url
51
+ ? { url: args.url, ...(args.headers && typeof args.headers === 'object' ? { headers: args.headers } : {}) }
52
+ : { command: args.command, ...(args.args?.length ? { args: args.args.map(String) } : {}), ...(args.env && typeof args.env === 'object' ? { env: args.env } : {}) },
53
+ }, true);
54
+ await manager.reload();
55
+ }
56
+ else if (args.action === 'import_claude') {
57
+ const found = readClaudeServers();
58
+ if (Object.keys(found).length === 0)
59
+ return 'No MCP servers found in ~/.claude.json.';
60
+ saveUserServers(found);
61
+ await manager.reload();
62
+ }
63
+ const rows = manager.getStatuses().map((s) => ({
64
+ name: s.name,
65
+ status: s.status,
66
+ tools: s.toolsCount,
67
+ ...(s.error ? { error: s.error } : {}),
68
+ ...(s.status === 'connected' ? { sample_tools: s.tools.slice(0, 8) } : {}),
69
+ }));
70
+ return JSON.stringify({ servers: rows, note: rows.length === 0 ? 'No MCP servers configured yet.' : 'New tools can be called from the next user message.' });
71
+ },
72
+ };
@@ -22,6 +22,18 @@ export const installPackageTool = {
22
22
  if (!/^[@a-zA-Z0-9_\-./=><^~+:[\] ]+$/.test(sanitized) || /[;&|`$\n\r()]/.test(sanitized)) {
23
23
  throw new Error(`Invalid package name: "${args.package}". Invalid characters or potential command injection detected.`);
24
24
  }
25
+ // A token starting with "-" would be parsed by the package manager as an option
26
+ // (--registry=..., --index-url=..., -r file), not as a package name.
27
+ if (sanitized.split(/\s+/).some((token) => token.startsWith('-'))) {
28
+ throw new Error(`Invalid package name: "${args.package}". Command line options are not allowed, pass package names only.`);
29
+ }
30
+ // On Windows the package manager is started through cmd.exe, where these are redirection / escape characters.
31
+ if (process.platform === 'win32' && /[<>^]/.test(sanitized)) {
32
+ throw new Error(`Invalid package name: "${args.package}". Version ranges with < > ^ are not supported on Windows, use an exact version (pkg@1.2.3 or pkg==1.2.3).`);
33
+ }
34
+ if (args.manager !== undefined && !['npm', 'pip', 'yarn', 'pnpm', 'cargo'].includes(args.manager)) {
35
+ throw new Error(`Unsupported package manager: "${args.manager}".`);
36
+ }
25
37
  },
26
38
  async execute(args) {
27
39
  let pkgManager = args.manager;
@@ -71,7 +83,10 @@ export const installPackageTool = {
71
83
  else if (pkgManager === 'pip') {
72
84
  executable = 'pip';
73
85
  cmdArgs = ['install'];
74
- if (process.platform !== 'win32') {
86
+ // Termux's Python is "externally managed" and has no system package manager that
87
+ // could be broken. On a regular Linux/macOS system this flag would override PEP 668
88
+ // and may damage the OS Python, so it is only used on Termux.
89
+ if (process.env.PREFIX?.includes('com.termux')) {
75
90
  cmdArgs.push('--break-system-packages');
76
91
  }
77
92
  cmdArgs.push(...pkgs);
@@ -40,13 +40,13 @@ export const planReadyTool = {
40
40
  steps: args.steps,
41
41
  timestamp: Date.now()
42
42
  };
43
- let output = `Plan finalized: ${args.summary}\n`;
44
- if (args.files && args.files.length > 0) {
45
- output += `Files: ${args.files.join(', ')}\n`;
46
- }
47
- if (args.steps && args.steps.length > 0) {
48
- output += `Steps:\n${args.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`;
49
- }
50
- return output;
43
+ // JSON: devx draws it as the plan card; the model reads the same data.
44
+ return JSON.stringify({
45
+ type: 'plan_ready',
46
+ status: 'shown to the user, who approves it or asks for changes',
47
+ summary: args.summary,
48
+ ...(args.files && args.files.length > 0 ? { files: args.files } : {}),
49
+ ...(args.steps && args.steps.length > 0 ? { steps: args.steps } : {}),
50
+ });
51
51
  }
52
52
  };