termux-dev 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -85,6 +85,20 @@ npm install
85
85
  npm link
86
86
  ```
87
87
 
88
+ ### 🗑️ Complete Uninstallation
89
+
90
+ To completely remove `devx` along with all configuration, saved sessions, and data:
91
+
92
+ ```bash
93
+ # 1. Uninstall the global npm package
94
+ npm uninstall -g termux-dev
95
+
96
+ # 2. Remove configuration and session history (Linux / macOS / Termux)
97
+ rm -rf ~/.devx ~/.devxrc.json
98
+
99
+ # (Windows PowerShell)
100
+ Remove-Item -Recurse -Force "$HOME\.devx", "$HOME\.devxrc.json" -ErrorAction SilentlyContinue
101
+ ```
88
102
 
89
103
  ---
90
104
 
@@ -101,6 +115,11 @@ devx
101
115
  ```bash
102
116
  devx --plan
103
117
  ```
118
+ * Run in **Headless One-Shot Mode** (scripts, CI/CD, Termux:Widget):
119
+ ```bash
120
+ devx -p "review latest git diff and find security bugs"
121
+ devx -p "fix build errors" --yolo
122
+ ```
104
123
 
105
124
  ---
106
125
 
@@ -112,6 +131,10 @@ Type `/` in the prompt to open the autocomplete command palette:
112
131
  | :--- | :--- |
113
132
  | **`/plan`** | Switch to **PLAN** mode (architect & requirements planner) |
114
133
  | **`/agent`** | Switch to **AGENT** mode (coder & autonomous executor) |
134
+ | **`/usage`** | View live network bandwidth (KB/MB), token costs, and API request counts |
135
+ | **`/export`** | Export session conversation to a clean GitHub-Flavored Markdown transcript |
136
+ | **`/theme`** | Switch UI color theme (Cyan, Purple, Matrix, Amber, Crimson, Monochrome) |
137
+ | **`/doctor`** | Run environment health diagnostics (Node, Termux, Compilers, API keys) |
115
138
  | **`/image`** | Paste image from clipboard as `[1.png 203kb]` |
116
139
  | **`/serve [port]`** | Start local HTTP server for web/game preview (`/serve stop` to halt) |
117
140
  | **`/memory`** | View, add (`/memory add <fact>`), or clear project memory bank |
@@ -213,6 +236,20 @@ npm install
213
236
  npm link
214
237
  ```
215
238
 
239
+ ### 🗑️ Полное удаление
240
+
241
+ Чтобы полностью удалить `devx` вместе с конфигурацией, историей сессий и сохранёнными данными:
242
+
243
+ ```bash
244
+ # 1. Удаляем глобальный npm-пакет
245
+ npm uninstall -g termux-dev
246
+
247
+ # 2. Удаляем конфиг и историю сессий (Linux / macOS / Termux)
248
+ rm -rf ~/.devx ~/.devxrc.json
249
+
250
+ # (Windows PowerShell)
251
+ Remove-Item -Recurse -Force "$HOME\.devx", "$HOME\.devxrc.json" -ErrorAction SilentlyContinue
252
+ ```
216
253
 
217
254
  ---
218
255
 
@@ -229,6 +266,11 @@ devx
229
266
  ```bash
230
267
  devx --plan
231
268
  ```
269
+ * Запуск в **Headless One-Shot режиме** (скрипты, CI/CD, Termux:Widget):
270
+ ```bash
271
+ devx -p "проверь последний git diff и найди баги"
272
+ devx -p "исправь ошибки сборки" --yolo
273
+ ```
232
274
 
233
275
  ---
234
276
 
@@ -240,6 +282,10 @@ devx
240
282
  | :--- | :--- |
241
283
  | **`/plan`** | Переключиться в режим архитектора (**PLAN**) |
242
284
  | **`/agent`** | Переключиться в режим исполнителя (**AGENT**) |
285
+ | **`/usage`** | Показать сетевой трафик (КБ/МБ), токены, число запросов и расходы ($) |
286
+ | **`/export`** | Экспортировать сессию диалога в чистый GitHub Markdown файл |
287
+ | **`/theme`** | Сменить цветовую тему (Cyan, Purple, Matrix, Amber, Crimson, Monochrome) |
288
+ | **`/doctor`** | Запустить системную диагностику (Node, Termux, Компиляторы, Ключи) |
243
289
  | **`/image`** | Вставить картинку из буфера обмена как `[1.png 203kb]` |
244
290
  | **`/serve [порт]`** | Запустить локальный веб-сервер (`/serve stop` для остановки) |
245
291
  | **`/memory`** | Посмотреть, добавить (`/memory add <факт>`) или очистить банк памяти |
package/assets/banner.svg CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  <!-- Tagline & Badge -->
27
27
  <text x="425" y="142" font-family="system-ui, -apple-system, sans-serif" font-size="14" font-weight="600" fill="#8b949e" text-anchor="middle" letter-spacing="3">
28
- THE TERMINAL-NATIVE AI CODING AGENT <tspan fill="#00f2fe" font-weight="bold">v1.2.0</tspan>
28
+ THE TERMINAL-NATIVE AI CODING AGENT <tspan fill="#00f2fe" font-weight="bold">v1.3.0</tspan>
29
29
  </text>
30
30
 
31
31
  <!-- Top Accent Line -->
Binary file
@@ -0,0 +1,62 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ export class SessionExporter {
4
+ static async exportToMarkdown(messages, model, targetFilename) {
5
+ try {
6
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
7
+ let fileName = targetFilename?.trim() || `devx-session-${timestamp.slice(0, 19)}.md`;
8
+ if (!fileName.endsWith('.md'))
9
+ fileName += '.md';
10
+ const filePath = path.resolve(process.cwd(), fileName);
11
+ const lines = [
12
+ `# 📝 devx Session Transcript`,
13
+ ``,
14
+ `- **Date:** ${new Date().toLocaleString()}`,
15
+ `- **Model:** \`${model}\``,
16
+ `- **Total Messages:** ${messages.length}`,
17
+ `- **Working Directory:** \`${process.cwd()}\``,
18
+ ``,
19
+ `---`,
20
+ ``
21
+ ];
22
+ for (const msg of messages) {
23
+ if (msg.role === 'system')
24
+ continue; // Skip raw system prompt
25
+ if (msg.role === 'user') {
26
+ lines.push(`## 👤 User\n`);
27
+ lines.push(msg.content || '');
28
+ if (msg.images && msg.images.length > 0) {
29
+ lines.push(`\n*Attached Images: ${msg.images.map(i => i.path).join(', ')}*`);
30
+ }
31
+ lines.push(`\n---\n`);
32
+ }
33
+ else if (msg.role === 'assistant') {
34
+ lines.push(`## 🤖 Assistant\n`);
35
+ if (msg.content) {
36
+ lines.push(msg.content);
37
+ }
38
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
39
+ lines.push(`\n**Executed Tools:**`);
40
+ for (const tc of msg.tool_calls) {
41
+ lines.push(`- \`${tc.name}\`: \`\`\`json\n${JSON.stringify(tc.arguments, null, 2)}\n\`\`\``);
42
+ }
43
+ }
44
+ lines.push(`\n---\n`);
45
+ }
46
+ else if (msg.role === 'tool') {
47
+ lines.push(`> **Tool Result (${msg.name || 'tool'}):**\n`);
48
+ let displayContent = msg.content || '';
49
+ if (displayContent.length > 2000) {
50
+ displayContent = displayContent.slice(0, 2000) + '\n... [truncated]';
51
+ }
52
+ lines.push('```\n' + displayContent + '\n```\n');
53
+ }
54
+ }
55
+ await fs.writeFile(filePath, lines.join('\n'), 'utf8');
56
+ return { success: true, filePath };
57
+ }
58
+ catch (err) {
59
+ return { success: false, filePath: '', error: err.message };
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,110 @@
1
+ import pc from 'picocolors';
2
+ import { History } from '../core/history.js';
3
+ import { Agent } from '../core/loop.js';
4
+ import { buildSystemPrompt } from '../prompts/builder.js';
5
+ import { createProvider } from '../providers/index.js';
6
+ import { getTools } from '../tools/index.js';
7
+ import { CLIConsoleGuard } from '../permissions/guard.js';
8
+ import { globalSnapshotManager } from '../core/snapshot.js';
9
+ import { UsageTracker } from '../core/usage.js';
10
+ export async function runHeadlessMode(userPrompt, config, options = {}) {
11
+ const planMode = !!options.planMode;
12
+ const isYolo = !!options.yolo || !!config.autoApprove;
13
+ const isQuiet = !!options.quiet;
14
+ const isJson = !!options.json;
15
+ let history = new History();
16
+ const sysPrompt = await buildSystemPrompt(planMode);
17
+ history.addMessage({ role: 'system', content: sysPrompt });
18
+ history.addMessage({ role: 'user', content: userPrompt });
19
+ const provider = createProvider(config);
20
+ const tools = getTools(planMode);
21
+ const guard = new CLIConsoleGuard(isYolo, config.bashAllowlist || []);
22
+ const agent = new Agent(config, provider, tools, history, guard);
23
+ const executedTools = [];
24
+ let accumulatedText = '';
25
+ let accumulatedReasoning = '';
26
+ let isFatalError = false;
27
+ let errorMessage = '';
28
+ const abortController = new AbortController();
29
+ process.on('SIGINT', () => {
30
+ abortController.abort();
31
+ if (!isJson) {
32
+ console.error(pc.yellow('\n[devx headless] Aborted by user (SIGINT)'));
33
+ }
34
+ process.exit(130);
35
+ });
36
+ try {
37
+ if (!isQuiet && !isJson) {
38
+ console.log(pc.bold(pc.cyan(`⚡ devx v1.3.0 (headless) | ${planMode ? 'PLAN' : 'AGENT'} | ${config.model}`)));
39
+ console.log(pc.dim(`Task: ${userPrompt}\n`));
40
+ }
41
+ for await (const event of agent.run(abortController.signal)) {
42
+ if (event.type === 'reasoning_delta') {
43
+ accumulatedReasoning += event.delta;
44
+ if (!isQuiet && !isJson) {
45
+ process.stdout.write(pc.dim(event.delta));
46
+ }
47
+ }
48
+ else if (event.type === 'text_delta') {
49
+ accumulatedText += event.delta;
50
+ if (!isJson) {
51
+ process.stdout.write(event.delta);
52
+ }
53
+ }
54
+ else if (event.type === 'tool_start') {
55
+ if (!isQuiet && !isJson) {
56
+ console.log('\n' + pc.bold(pc.white(`› ${event.actionDesc}`)));
57
+ }
58
+ }
59
+ else if (event.type === 'tool_end') {
60
+ executedTools.push({ id: event.id, name: event.name, result: event.result });
61
+ }
62
+ else if (event.type === 'error') {
63
+ errorMessage = event.message;
64
+ if (event.isFatal) {
65
+ isFatalError = true;
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ globalSnapshotManager.finishTurn();
71
+ if (isJson) {
72
+ const summary = UsageTracker.getInstance().getSummary();
73
+ const output = {
74
+ success: !isFatalError,
75
+ model: config.model,
76
+ prompt: userPrompt,
77
+ result: accumulatedText,
78
+ reasoning: accumulatedReasoning || undefined,
79
+ executedTools,
80
+ usage: {
81
+ requests: summary.requestsCount,
82
+ totalTokens: summary.totalTokens,
83
+ cost: summary.totalCost,
84
+ totalBytes: summary.totalBytes
85
+ },
86
+ error: isFatalError ? errorMessage : undefined
87
+ };
88
+ console.log(JSON.stringify(output, null, 2));
89
+ }
90
+ else {
91
+ if (accumulatedText && !accumulatedText.endsWith('\n')) {
92
+ process.stdout.write('\n');
93
+ }
94
+ if (isFatalError) {
95
+ console.error(pc.red(`\n❌ Error: ${errorMessage}`));
96
+ return 1;
97
+ }
98
+ }
99
+ return isFatalError ? 1 : 0;
100
+ }
101
+ catch (err) {
102
+ if (isJson) {
103
+ console.log(JSON.stringify({ success: false, error: err.message }, null, 2));
104
+ }
105
+ else {
106
+ console.error(pc.red(`\n❌ Fatal Headless Execution Error: ${err.message}`));
107
+ }
108
+ return 1;
109
+ }
110
+ }
package/dist/cli/index.js CHANGED
@@ -27,6 +27,10 @@ import { askPrompt } from './prompt.js';
27
27
  import { resolveAtMentions } from './files.js';
28
28
  import { runStartupUpdateCheck, checkForUpdates, performSelfUpdate } from './updater.js';
29
29
  import { setActiveTheme, getCurrentTheme, listThemes, findTheme } from './theme.js';
30
+ import { runHeadlessMode } from './headless.js';
31
+ import { CustomCommandManager } from '../core/commands.js';
32
+ import { UsageTracker } from '../core/usage.js';
33
+ import { SessionExporter } from './export.js';
30
34
  const CONFIG_PATH = path.join(os.homedir(), '.devxrc.json');
31
35
  function maskApiKey(key) {
32
36
  if (!key)
@@ -316,23 +320,110 @@ async function runOnboarding() {
316
320
  p.outro(pc.green('Setup complete! Configuration saved to ~/.devxrc.json'));
317
321
  return res;
318
322
  }
319
- async function loadConfig() {
323
+ async function loadConfig(interactive = true) {
324
+ let globalConfig = {};
320
325
  try {
321
326
  const data = await fs.readFile(CONFIG_PATH, 'utf8');
322
- const parsed = JSON.parse(data);
323
- if (!parsed.model || !parsed.baseUrl)
327
+ globalConfig = JSON.parse(data);
328
+ if (!globalConfig.model || !globalConfig.baseUrl)
324
329
  throw new Error('Invalid config');
325
- parsed.maxContextTokens = parsed.maxContextTokens || getModelContextLimit(parsed.model);
326
- parsed.apiKeys = parsed.apiKeys || {};
327
- parsed.baseUrls = parsed.baseUrls || {};
328
- if (parsed.provider && parsed.apiKey) {
329
- parsed.apiKeys[parsed.provider] = parsed.apiKey;
330
+ globalConfig.maxContextTokens = globalConfig.maxContextTokens || getModelContextLimit(globalConfig.model);
331
+ globalConfig.apiKeys = globalConfig.apiKeys || {};
332
+ globalConfig.baseUrls = globalConfig.baseUrls || {};
333
+ globalConfig.trustedProjects = globalConfig.trustedProjects || {};
334
+ if (globalConfig.provider && globalConfig.apiKey) {
335
+ globalConfig.apiKeys[globalConfig.provider] = globalConfig.apiKey;
330
336
  }
331
- return parsed;
332
337
  }
333
338
  catch {
334
- return await runOnboarding();
339
+ if (interactive) {
340
+ globalConfig = await runOnboarding();
341
+ }
342
+ else {
343
+ throw new Error('No configuration found in ~/.devxrc.json. Please run devx interactively first to set up your AI provider.');
344
+ }
345
+ }
346
+ // Check for local project config (.devx.json or .devxrc.json)
347
+ const localConfigPaths = [
348
+ path.join(process.cwd(), '.devx.json'),
349
+ path.join(process.cwd(), '.devxrc.json')
350
+ ];
351
+ let localConfig = null;
352
+ for (const lp of localConfigPaths) {
353
+ try {
354
+ if (fsSync.existsSync(lp)) {
355
+ const raw = await fs.readFile(lp, 'utf8');
356
+ localConfig = JSON.parse(raw);
357
+ break;
358
+ }
359
+ }
360
+ catch { }
361
+ }
362
+ const effectiveConfig = { ...globalConfig };
363
+ if (localConfig && typeof localConfig === 'object') {
364
+ const cwd = process.cwd();
365
+ const hasElevatedPermissions = localConfig.autoApprove === true || (Array.isArray(localConfig.bashAllowlist) && localConfig.bashAllowlist.length > 0);
366
+ let isTrusted = globalConfig.trustedProjects?.[cwd];
367
+ if (hasElevatedPermissions && isTrusted === undefined) {
368
+ if (interactive) {
369
+ console.log('');
370
+ p.log.warn(pc.bold(pc.yellow(`🛡️ [PROJECT TRUST] Local configuration (.devx.json) requests elevated permissions:`)));
371
+ if (localConfig.autoApprove === true) {
372
+ console.log(pc.yellow(` • Auto-approval (YOLO mode): enabled`));
373
+ }
374
+ if (localConfig.bashAllowlist) {
375
+ console.log(pc.yellow(` • Bash allowlist: ${localConfig.bashAllowlist.join(', ')}`));
376
+ }
377
+ console.log(pc.dim(` Repository: ${cwd}`));
378
+ const answer = await p.confirm({
379
+ message: 'Do you trust this repository and allow its elevated permissions?',
380
+ initialValue: false
381
+ });
382
+ isTrusted = answer === true;
383
+ globalConfig.trustedProjects = globalConfig.trustedProjects || {};
384
+ globalConfig.trustedProjects[cwd] = isTrusted;
385
+ await saveConfig(globalConfig);
386
+ if (isTrusted) {
387
+ p.log.success(pc.green('Repository marked as trusted. Elevated permissions applied.'));
388
+ }
389
+ else {
390
+ p.log.info(pc.cyan('Repository not trusted. Elevated permissions ignored.'));
391
+ }
392
+ }
393
+ else {
394
+ isTrusted = false; // Never auto-trust in non-interactive headless mode
395
+ }
396
+ }
397
+ // Apply safe configuration overrides
398
+ if (localConfig.model)
399
+ effectiveConfig.model = localConfig.model;
400
+ if (localConfig.provider)
401
+ effectiveConfig.provider = localConfig.provider;
402
+ if (localConfig.apiKey)
403
+ effectiveConfig.apiKey = localConfig.apiKey;
404
+ if (localConfig.baseUrl)
405
+ effectiveConfig.baseUrl = localConfig.baseUrl;
406
+ if (localConfig.maxIterations)
407
+ effectiveConfig.maxIterations = localConfig.maxIterations;
408
+ if (localConfig.maxContextTokens)
409
+ effectiveConfig.maxContextTokens = localConfig.maxContextTokens;
410
+ if (localConfig.dataSaverLimitMB)
411
+ effectiveConfig.dataSaverLimitMB = localConfig.dataSaverLimitMB;
412
+ if (localConfig.pureBlackTheme !== undefined)
413
+ effectiveConfig.pureBlackTheme = localConfig.pureBlackTheme;
414
+ // Apply elevated permissions only if explicitly trusted
415
+ if (isTrusted) {
416
+ if (localConfig.autoApprove !== undefined)
417
+ effectiveConfig.autoApprove = localConfig.autoApprove;
418
+ if (Array.isArray(localConfig.bashAllowlist))
419
+ effectiveConfig.bashAllowlist = localConfig.bashAllowlist;
420
+ }
421
+ }
422
+ // Configure Data Saver threshold
423
+ if (effectiveConfig.dataSaverLimitMB) {
424
+ UsageTracker.getInstance().setLimit(effectiveConfig.dataSaverLimitMB);
335
425
  }
426
+ return effectiveConfig;
336
427
  }
337
428
  function enableDarkTheme(enabled = true) {
338
429
  if (!enabled)
@@ -391,7 +482,7 @@ function drawLogo() {
391
482
  theme.colorFn(' █▀▀▄ █▀▀▀ █ █ █ █'),
392
483
  theme.colorFn(' █ █ █▀▀▀ ▀▄▀ ▀▄▀ '),
393
484
  theme.colorFn(' █▄▄▀ █▄▄▄ ▀ ▀ ▀ '),
394
- ' ' + theme.boldFn('v1.2.0'),
485
+ ' ' + theme.boldFn('v1.3.0'),
395
486
  ''
396
487
  ];
397
488
  for (const line of logo) {
@@ -406,7 +497,7 @@ function drawLogo() {
406
497
  indent + theme.colorFn('▀▀▀█▀▀▀ █▀▀▀ █▀▀█ █▄ ▄█ █ █ ▀▄ ▄▀ █▀▀▄ █▀▀▀ █ █'),
407
498
  indent + theme.colorFn(' █ █▀▀▀ █▄▄▀ █ █ █ █ █ █ ▀▀ █ █ █▀▀▀ █ █'),
408
499
  indent + theme.colorFn(' █ █▄▄▄ █ ▀▄ █ █ ▀▄▄▀ ▄▀ ▀▄ █▄▄▀ █▄▄▄ ▀▄▀ '),
409
- indent + theme.boldFn('v1.2.0'),
500
+ indent + theme.boldFn('v1.3.0'),
410
501
  ''
411
502
  ];
412
503
  for (const line of logo) {
@@ -527,12 +618,37 @@ export async function main() {
527
618
  const program = new Command();
528
619
  program
529
620
  .name('devx')
530
- .description('CLI tool for vibe-coding')
531
- .option('--plan', 'Start in plan mode')
621
+ .description('Terminal-native AI coding assistant and vibe-coding agent')
622
+ .version('1.3.0')
623
+ .option('-p, --prompt <task>', 'Run one-shot task non-interactively (headless mode)')
624
+ .option('-y, --yolo', 'Automatically approve all tool executions without confirmation')
625
+ .option('-m, --model <model>', 'Specify AI model to use for this execution')
626
+ .option('-q, --quiet', 'Quiet output in headless mode (suppress banners and tool logs)')
627
+ .option('--json', 'Output structured JSON in headless mode')
628
+ .option('--plan', 'Start in plan mode (architect & planner)')
532
629
  .parse(process.argv);
533
630
  const options = program.opts();
534
- let planMode = !!options.plan;
535
- let config = await loadConfig();
631
+ const planModeInitial = !!options.plan;
632
+ const isHeadless = !!options.prompt;
633
+ let config = await loadConfig(!isHeadless);
634
+ if (options.model) {
635
+ config.model = options.model;
636
+ config.maxContextTokens = getModelContextLimit(options.model);
637
+ }
638
+ if (options.yolo) {
639
+ config.autoApprove = true;
640
+ }
641
+ // If -p / --prompt is provided, execute one-shot headless mode and exit!
642
+ if (isHeadless) {
643
+ const exitCode = await runHeadlessMode(options.prompt, config, {
644
+ planMode: planModeInitial,
645
+ yolo: !!options.yolo,
646
+ quiet: !!options.quiet,
647
+ json: !!options.json
648
+ });
649
+ process.exit(exitCode);
650
+ }
651
+ let planMode = planModeInitial;
536
652
  if (!config.onboarded) {
537
653
  const cols = Math.min(process.stdout.columns || 40, 42);
538
654
  const fill = Math.max(2, cols - 16);
@@ -685,7 +801,7 @@ export async function main() {
685
801
  const maxIterLabel = maxIter >= 9999 ? 'Unlimited' : `${maxIter} steps`;
686
802
  const currentTh = getCurrentTheme();
687
803
  const choice = await select({
688
- message: `${pc.bold('⚙️ Settings')} ${pc.dim(`(devx v1.2.0 • theme: ${currentTh.name})`)}`,
804
+ message: `${pc.bold('⚙️ Settings')} ${pc.dim(`(devx v1.3.0 • theme: ${currentTh.name})`)}`,
689
805
  choices: [
690
806
  {
691
807
  name: `🎨 Color Theme: ${currentTh.emoji} ${currentTh.name}`,
@@ -726,7 +842,7 @@ export async function main() {
726
842
  description: 'Limit how many tool steps (file edits, terminal commands) agent can do per request'
727
843
  },
728
844
  {
729
- name: `${currentTh.colorFn('✨ About devx')} ${pc.dim('(v1.2.0 by ApvCode)')}`,
845
+ name: `${currentTh.colorFn('✨ About devx')} ${pc.dim('(v1.3.0 by ApvCode)')}`,
730
846
  value: 'about',
731
847
  description: 'Terminal-Native AI Coding Agent created by ApvCode (https://github.com/apvcode/Termux-Dev)'
732
848
  },
@@ -742,7 +858,7 @@ export async function main() {
742
858
  continue;
743
859
  }
744
860
  if (choice === 'about') {
745
- p.note(`⚡ devx v1.2.0 — Terminal-Native AI Coding Agent\n` +
861
+ p.note(`⚡ devx v1.3.0 — Terminal-Native AI Coding Agent\n` +
746
862
  `🎨 Theme: ${currentTh.emoji} ${currentTh.name}\n` +
747
863
  `👤 Author: ApvCode (https://github.com/apvcode)\n` +
748
864
  `🌟 Repository: https://github.com/apvcode/Termux-Dev\n` +
@@ -806,65 +922,83 @@ export async function main() {
806
922
  process.stdin.resume();
807
923
  return config;
808
924
  }
809
- const VALID_COMMANDS = [
810
- '/new', '/reset', '/resume', '/session', '/sessions', '/history',
811
- '/theme', '/themes',
812
- '/settings', '/update', '/model', '/provider', '/providers',
813
- '/plan', '/agent', '/image', '/serve', '/memory', '/undo',
814
- '/diff', '/commit', '/status', '/compact', '/init', '/doctor',
815
- '/config', '/clear', '/exit', '/quit', '/help'
816
- ];
817
- if (!VALID_COMMANDS.includes(cmd)) {
818
- const SLASH_COMMANDS = [
819
- { name: '/new - Start a new clean chat session', value: '/new' },
820
- { name: '/resume - Resume a previous chat session', value: '/resume' },
821
- { name: '/session del - Select and delete saved sessions', value: '/session del' },
822
- { name: '/theme - Switch UI color theme', value: '/theme' },
823
- { name: '/doctor - Run system & environment health diagnostics', value: '/doctor' },
824
- { name: '/settings - Configure permissions & auto-approval', value: '/settings' },
825
- { name: '/update - Check and install updates from GitHub', value: '/update' },
826
- { name: '/model - Switch model for current provider', value: '/model' },
827
- { name: '/provider - Change AI provider (Google, OpenRouter...)', value: '/provider' },
828
- { name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
829
- { name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
830
- { name: '/serve - Start local web server for web preview', value: '/serve' },
831
- { name: '/memory - View or edit project memory bank', value: '/memory' },
832
- { name: '/undo - Revert last file changes made by AI', value: '/undo' },
833
- { name: '/diff - Show git diff of modified files', value: '/diff' },
834
- { name: '/commit - AI-generated git commit message', value: '/commit' },
835
- { name: '/status - Show git repository status', value: '/status' },
836
- { name: '/compact - Compact conversation context', value: '/compact' },
837
- { name: '/config - View current configuration', value: '/config' },
838
- { name: '/clear - Clear message history', value: '/clear' },
839
- { name: '/help - Show commands overview', value: '/help' },
840
- { name: '/exit - Exit devx', value: '/exit' },
925
+ // 1. Check for custom slash commands (.devx/commands/*.md)
926
+ const customCmd = await CustomCommandManager.findCommand(cmd);
927
+ if (customCmd) {
928
+ const cmdArgs = answer.slice(cmd.length).trim();
929
+ const expandedPrompt = CustomCommandManager.expandTemplate(customCmd.promptTemplate, cmdArgs);
930
+ answer = expandedPrompt;
931
+ p.log.info(pc.cyan(`⚡ Executing custom command: ${pc.bold(customCmd.cmd)} (${customCmd.desc})`));
932
+ }
933
+ else {
934
+ const VALID_COMMANDS = [
935
+ '/new', '/reset', '/resume', '/session', '/sessions', '/history',
936
+ '/theme', '/themes', '/usage', '/export',
937
+ '/settings', '/update', '/model', '/provider', '/providers',
938
+ '/plan', '/agent', '/image', '/serve', '/memory', '/undo',
939
+ '/diff', '/commit', '/status', '/compact', '/init', '/doctor',
940
+ '/config', '/clear', '/exit', '/quit', '/help'
841
941
  ];
842
- try {
843
- const picked = await search({
844
- message: 'Commands (type to search or select):',
845
- source: async (term) => {
846
- const q = (term || '').trim().toLowerCase();
847
- const list = [
848
- { name: pc.yellow('⬅️ Cancel'), value: '__cancel__' },
849
- ...SLASH_COMMANDS
850
- ];
851
- if (!q)
852
- return list;
853
- return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
854
- },
855
- pageSize: 12
856
- });
857
- if (!picked || picked === '__cancel__') {
858
- continue;
942
+ if (!VALID_COMMANDS.includes(cmd)) {
943
+ const customList = await CustomCommandManager.listCommands();
944
+ const customSlashItems = customList.map(c => ({
945
+ name: `${c.cmd.padEnd(14)} - ${c.desc}`,
946
+ value: c.cmd
947
+ }));
948
+ const SLASH_COMMANDS = [
949
+ { name: '/new - Start a new clean chat session', value: '/new' },
950
+ { name: '/resume - Resume a previous chat session', value: '/resume' },
951
+ { name: '/session del - Select and delete saved sessions', value: '/session del' },
952
+ { name: '/usage - Show network bandwidth, data saver & token cost', value: '/usage' },
953
+ { name: '/export - Export session conversation to Markdown', value: '/export' },
954
+ { name: '/theme - Switch UI color theme', value: '/theme' },
955
+ { name: '/doctor - Run system & environment health diagnostics', value: '/doctor' },
956
+ { name: '/settings - Configure permissions & auto-approval', value: '/settings' },
957
+ { name: '/update - Check and install updates from GitHub', value: '/update' },
958
+ { name: '/model - Switch model for current provider', value: '/model' },
959
+ { name: '/provider - Change AI provider (Google, OpenRouter...)', value: '/provider' },
960
+ { name: '/plan - Switch to PLAN mode (architect)', value: '/plan' },
961
+ { name: '/agent - Switch to AGENT mode (coder)', value: '/agent' },
962
+ { name: '/serve - Start local web server for web preview', value: '/serve' },
963
+ { name: '/memory - View or edit project memory bank', value: '/memory' },
964
+ { name: '/undo - Revert last file changes made by AI', value: '/undo' },
965
+ { name: '/diff - Show git diff of modified files', value: '/diff' },
966
+ { name: '/commit - AI-generated git commit message', value: '/commit' },
967
+ { name: '/status - Show git repository status', value: '/status' },
968
+ { name: '/compact - Compact conversation context', value: '/compact' },
969
+ { name: '/config - View current configuration', value: '/config' },
970
+ { name: '/clear - Clear message history', value: '/clear' },
971
+ { name: '/help - Show commands overview', value: '/help' },
972
+ { name: '/exit - Exit devx', value: '/exit' },
973
+ ...customSlashItems
974
+ ];
975
+ try {
976
+ const picked = await search({
977
+ message: 'Commands (type to search or select):',
978
+ source: async (term) => {
979
+ const q = (term || '').trim().toLowerCase();
980
+ const list = [
981
+ { name: pc.yellow('⬅️ Cancel'), value: '__cancel__' },
982
+ ...SLASH_COMMANDS
983
+ ];
984
+ if (!q)
985
+ return list;
986
+ return list.filter(item => item.name.toLowerCase().includes(q) || item.value.toLowerCase().includes(q));
987
+ },
988
+ pageSize: 12
989
+ });
990
+ if (!picked || picked === '__cancel__') {
991
+ continue;
992
+ }
993
+ if (picked === '/session del') {
994
+ await handleSessionDelete();
995
+ continue;
996
+ }
997
+ cmd = picked;
859
998
  }
860
- if (picked === '/session del') {
861
- await handleSessionDelete();
999
+ catch {
862
1000
  continue;
863
1001
  }
864
- cmd = picked;
865
- }
866
- catch {
867
- continue;
868
1002
  }
869
1003
  }
870
1004
  if (cmd === '/settings') {
@@ -1322,8 +1456,33 @@ export async function main() {
1322
1456
  }
1323
1457
  continue;
1324
1458
  }
1459
+ if (cmd === '/usage') {
1460
+ const card = UsageTracker.getInstance().renderUsageCard();
1461
+ console.log('\n' + card);
1462
+ continue;
1463
+ }
1464
+ if (cmd === '/export') {
1465
+ const targetName = parts.slice(1).join(' ').trim();
1466
+ const res = await SessionExporter.exportToMarkdown(history.getMessages(), config.model, targetName || undefined);
1467
+ if (res.success) {
1468
+ p.log.success(pc.bold(pc.green(`📄 Session exported to: ${res.filePath}`)));
1469
+ }
1470
+ else {
1471
+ p.log.error(`Failed to export session: ${res.error}`);
1472
+ }
1473
+ continue;
1474
+ }
1325
1475
  if (cmd === '/config') {
1326
- p.note(JSON.stringify(config, null, 2), 'Configuration');
1476
+ const masked = { ...config };
1477
+ if (masked.apiKey)
1478
+ masked.apiKey = maskApiKey(masked.apiKey);
1479
+ if (masked.apiKeys) {
1480
+ masked.apiKeys = { ...masked.apiKeys };
1481
+ for (const k of Object.keys(masked.apiKeys)) {
1482
+ masked.apiKeys[k] = maskApiKey(masked.apiKeys[k]);
1483
+ }
1484
+ }
1485
+ p.note(JSON.stringify(masked, null, 2), 'Configuration (Secrets Masked)');
1327
1486
  continue;
1328
1487
  }
1329
1488
  if (cmd === '/model') {
@@ -1408,10 +1567,12 @@ export async function main() {
1408
1567
  });
1409
1568
  const provider = createProvider(config);
1410
1569
  const tools = getTools(planMode);
1411
- const guard = new CLIConsoleGuard(config.autoApprove);
1570
+ const guard = new CLIConsoleGuard(config.autoApprove, config.bashAllowlist || []);
1412
1571
  const agentConfig = {
1413
1572
  maxContextTokens: config.maxContextTokens || 100000,
1414
- maxIterations: config.maxIterations || 100
1573
+ maxIterations: config.maxIterations || 100,
1574
+ autoApprove: config.autoApprove,
1575
+ bashAllowlist: config.bashAllowlist
1415
1576
  };
1416
1577
  const agent = new Agent(agentConfig, provider, tools, history, guard);
1417
1578
  const taskStartTime = Date.now();
@@ -2,11 +2,14 @@ import pc from 'picocolors';
2
2
  import { scanProjectFiles } from './files.js';
3
3
  import { saveClipboardImage, processPastedFilePath } from './clipboard.js';
4
4
  import { getCurrentTheme, listThemes } from './theme.js';
5
+ import { CustomCommandManager } from '../core/commands.js';
5
6
  export const SLASH_COMMANDS = [
6
7
  { cmd: '/new', desc: 'Start a new clean chat session' },
7
8
  { cmd: '/resume', desc: 'Resume a previous chat session' },
8
9
  { cmd: '/session', desc: 'Show active session ID, stats, and info' },
9
10
  { cmd: '/session del', desc: 'Select and delete saved sessions' },
11
+ { cmd: '/usage', desc: 'Show network bandwidth, data saver & token cost' },
12
+ { cmd: '/export', desc: 'Export session conversation to Markdown' },
10
13
  { cmd: '/theme', desc: 'Switch UI theme (Cyan, Purple, Matrix, Amber, etc.)' },
11
14
  { cmd: '/doctor', desc: 'Run system & environment health diagnostics' },
12
15
  { cmd: '/settings', desc: 'Configure permissions & auto-approval' },
@@ -43,10 +46,14 @@ export function askPrompt(opts = {}) {
43
46
  let historyIndex = GLOBAL_PROMPT_HISTORY.length;
44
47
  let tempDraft = '';
45
48
  let availableFiles = [];
46
- // Preload project files for fast @ autocomplete
49
+ let customCommandsList = [];
50
+ // Preload project files and custom slash commands
47
51
  scanProjectFiles().then(files => {
48
52
  availableFiles = files;
49
53
  }).catch(() => { });
54
+ CustomCommandManager.listCommands().then(cmds => {
55
+ customCommandsList = cmds.map(c => ({ cmd: c.cmd, desc: c.desc }));
56
+ }).catch(() => { });
50
57
  const pastes = [];
51
58
  const imageAttachments = [];
52
59
  const usedImageNames = new Set();
@@ -89,7 +96,13 @@ export function askPrompt(opts = {}) {
89
96
  }
90
97
  if (input.startsWith('/')) {
91
98
  const q = input.trim().toLowerCase();
92
- const filtered = SLASH_COMMANDS.filter(c => c.cmd.toLowerCase().startsWith(q) || q === '/');
99
+ const baseList = [...SLASH_COMMANDS];
100
+ for (const cc of customCommandsList) {
101
+ if (!baseList.some(b => b.cmd === cc.cmd)) {
102
+ baseList.push({ cmd: cc.cmd, desc: cc.desc });
103
+ }
104
+ }
105
+ const filtered = baseList.filter(c => c.cmd.toLowerCase().startsWith(q) || q === '/');
93
106
  return filtered.map(c => ({
94
107
  label: c.cmd,
95
108
  desc: c.desc,
@@ -139,7 +139,7 @@ function renderDirectoryHtml(dirPath, relPath, files, port) {
139
139
  ${parentLink}
140
140
  ${items || '<li style="padding: 20px; text-align: center; color: #6e7681;">No visible files in this directory</li>'}
141
141
  </ul>
142
- <div class="footer">devx v1.2.0 &bull; Terminal-Native AI Assistant</div>
142
+ <div class="footer">devx v1.3.0 &bull; Terminal-Native AI Assistant</div>
143
143
  </div>
144
144
  </body>
145
145
  </html>`;
@@ -0,0 +1,100 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ export class CustomCommandManager {
6
+ static cachedCommands = null;
7
+ static lastScanTime = 0;
8
+ /**
9
+ * Scans for custom commands in project and global directories
10
+ */
11
+ static async listCommands(forceRefresh = false) {
12
+ const now = Date.now();
13
+ if (!forceRefresh && this.cachedCommands && now - this.lastScanTime < 5000) {
14
+ return this.cachedCommands;
15
+ }
16
+ const commandMap = new Map();
17
+ // 1. Search directories (higher priority overrides lower priority)
18
+ const searchDirs = [
19
+ path.join(os.homedir(), '.devx', 'commands'),
20
+ path.join(process.cwd(), '.claude', 'commands'),
21
+ path.join(process.cwd(), '.devx', 'commands')
22
+ ];
23
+ for (const dir of searchDirs) {
24
+ try {
25
+ if (!fsSync.existsSync(dir))
26
+ continue;
27
+ const entries = await fs.readdir(dir, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.prompt'))) {
30
+ const rawName = entry.name.replace(/\.(md|prompt)$/i, '').toLowerCase();
31
+ const filePath = path.join(dir, entry.name);
32
+ const content = await fs.readFile(filePath, 'utf8');
33
+ const { description, template } = this.parseCommandFile(content, rawName);
34
+ commandMap.set(rawName, {
35
+ name: rawName,
36
+ cmd: `/${rawName}`,
37
+ desc: description || `Custom command (${path.basename(dir)}/${entry.name})`,
38
+ promptTemplate: template,
39
+ sourcePath: filePath
40
+ });
41
+ }
42
+ }
43
+ }
44
+ catch { }
45
+ }
46
+ this.cachedCommands = Array.from(commandMap.values());
47
+ this.lastScanTime = now;
48
+ return this.cachedCommands;
49
+ }
50
+ /**
51
+ * Finds a custom command by slash name (e.g. "/deploy" or "deploy")
52
+ */
53
+ static async findCommand(cmdName) {
54
+ const clean = cmdName.replace(/^\//, '').toLowerCase().trim();
55
+ const list = await this.listCommands();
56
+ return list.find(c => c.name === clean) || null;
57
+ }
58
+ /**
59
+ * Expands arguments into the prompt template
60
+ */
61
+ static expandTemplate(template, args) {
62
+ const trimmedArgs = args.trim();
63
+ if (!template.includes('$ARG') && !template.includes('$*') && !template.includes('$1')) {
64
+ return trimmedArgs ? `${template}\n\nUser Arguments: ${trimmedArgs}` : template;
65
+ }
66
+ let expanded = template;
67
+ expanded = expanded.replace(/\$ARG/g, trimmedArgs);
68
+ expanded = expanded.replace(/\$\*/g, trimmedArgs);
69
+ // Support positional parameters: $1, $2, etc.
70
+ const parts = trimmedArgs.split(/\s+/);
71
+ for (let i = 0; i < parts.length; i++) {
72
+ expanded = expanded.replace(new RegExp(`\\$${i + 1}`, 'g'), parts[i]);
73
+ }
74
+ return expanded;
75
+ }
76
+ /**
77
+ * Parses frontmatter (YAML description) and template content
78
+ */
79
+ static parseCommandFile(rawContent, defaultName) {
80
+ let description = '';
81
+ let template = rawContent;
82
+ const frontmatterMatch = rawContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
83
+ if (frontmatterMatch) {
84
+ const frontmatter = frontmatterMatch[1];
85
+ template = frontmatterMatch[2].trim();
86
+ const descMatch = frontmatter.match(/description:\s*([^\r\n]+)/i);
87
+ if (descMatch) {
88
+ description = descMatch[1].trim().replace(/^["']|["']$/g, '');
89
+ }
90
+ }
91
+ else {
92
+ // Check for first-line Markdown heading or comment: # Description
93
+ const firstLine = rawContent.split('\n')[0].trim();
94
+ if (firstLine.startsWith('# ')) {
95
+ description = firstLine.replace(/^#\s*/, '').trim();
96
+ }
97
+ }
98
+ return { description, template };
99
+ }
100
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ const IGNORED_DIRS = new Set([
4
+ '.git', 'node_modules', 'dist', 'build', 'out', 'coverage',
5
+ '.next', '.nuxt', '.cache', '.gemini', '.antigravity', '.vscode', '.idea'
6
+ ]);
7
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py', '.mjs', '.cjs', '.rs', '.go', '.c', '.cpp', '.h', '.java']);
8
+ export class RepoMapGenerator {
9
+ /**
10
+ * Generates a compact, compressed tree representation of project files and key symbols
11
+ */
12
+ static async generate(rootDir = process.cwd(), maxFiles = 60) {
13
+ try {
14
+ const files = await this.scanFiles(rootDir, maxFiles);
15
+ if (files.length === 0)
16
+ return '';
17
+ const lines = ['Project Structure & Key Symbols (Repo Map):'];
18
+ for (const file of files) {
19
+ const symbolStr = file.symbols.length > 0 ? ` (${file.symbols.slice(0, 5).join(', ')}${file.symbols.length > 5 ? ', ...' : ''})` : '';
20
+ lines.push(`• ${file.relPath}${symbolStr}`);
21
+ }
22
+ return lines.join('\n');
23
+ }
24
+ catch {
25
+ return '';
26
+ }
27
+ }
28
+ static async scanFiles(dir, maxFiles) {
29
+ const results = [];
30
+ async function walk(currentDir) {
31
+ if (results.length >= maxFiles)
32
+ return;
33
+ try {
34
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
35
+ for (const entry of entries) {
36
+ if (results.length >= maxFiles)
37
+ break;
38
+ if (entry.isDirectory()) {
39
+ if (!IGNORED_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
40
+ await walk(path.join(currentDir, entry.name));
41
+ }
42
+ }
43
+ else if (entry.isFile()) {
44
+ const ext = path.extname(entry.name).toLowerCase();
45
+ if (EXTENSIONS.has(ext)) {
46
+ const fullPath = path.join(currentDir, entry.name);
47
+ const relPath = path.relative(dir, fullPath).replace(/\\/g, '/');
48
+ const symbols = await RepoMapGenerator.extractSymbols(fullPath, ext);
49
+ results.push({ relPath, symbols });
50
+ }
51
+ }
52
+ }
53
+ }
54
+ catch { }
55
+ }
56
+ await walk(dir);
57
+ return results;
58
+ }
59
+ static async extractSymbols(filePath, ext) {
60
+ const symbols = [];
61
+ try {
62
+ // Read at most 16KB of each file for super-fast symbol extraction
63
+ const stat = await fs.stat(filePath);
64
+ if (stat.size > 256 * 1024)
65
+ return []; // Skip giant generated files
66
+ const content = await fs.readFile(filePath, 'utf8');
67
+ const lines = content.split('\n').slice(0, 300); // Only examine top 300 lines
68
+ for (const line of lines) {
69
+ const trimmed = line.trim();
70
+ if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx' || ext === '.mjs') {
71
+ // JS/TS exports: function, class, interface, type, const
72
+ const fnMatch = trimmed.match(/^export\s+(?:async\s+)?function\s+([a-zA-Z0-9_$]+)/);
73
+ if (fnMatch) {
74
+ symbols.push(`fn ${fnMatch[1]}`);
75
+ continue;
76
+ }
77
+ const classMatch = trimmed.match(/^export\s+(?:abstract\s+)?class\s+([a-zA-Z0-9_$]+)/);
78
+ if (classMatch) {
79
+ symbols.push(`class ${classMatch[1]}`);
80
+ continue;
81
+ }
82
+ const ifaceMatch = trimmed.match(/^export\s+interface\s+([a-zA-Z0-9_$]+)/);
83
+ if (ifaceMatch) {
84
+ symbols.push(`interface ${ifaceMatch[1]}`);
85
+ continue;
86
+ }
87
+ const typeMatch = trimmed.match(/^export\s+type\s+([a-zA-Z0-9_$]+)/);
88
+ if (typeMatch) {
89
+ symbols.push(`type ${typeMatch[1]}`);
90
+ continue;
91
+ }
92
+ const constMatch = trimmed.match(/^export\s+const\s+([a-zA-Z0-9_$]+)/);
93
+ if (constMatch && !constMatch[1].startsWith('_')) {
94
+ symbols.push(constMatch[1]);
95
+ continue;
96
+ }
97
+ }
98
+ else if (ext === '.py') {
99
+ // Python classes and functions
100
+ const pyClass = trimmed.match(/^class\s+([a-zA-Z0-9_]+)/);
101
+ if (pyClass) {
102
+ symbols.push(`class ${pyClass[1]}`);
103
+ continue;
104
+ }
105
+ const pyFn = trimmed.match(/^(?:async\s+)?def\s+([a-zA-Z0-9_]+)/);
106
+ if (pyFn && !pyFn[1].startsWith('__')) {
107
+ symbols.push(`def ${pyFn[1]}`);
108
+ continue;
109
+ }
110
+ }
111
+ else if (ext === '.rs') {
112
+ const rsFn = trimmed.match(/^pub\s+(?:async\s+)?fn\s+([a-zA-Z0-9_]+)/);
113
+ if (rsFn) {
114
+ symbols.push(`fn ${rsFn[1]}`);
115
+ continue;
116
+ }
117
+ const rsStruct = trimmed.match(/^pub\s+struct\s+([a-zA-Z0-9_]+)/);
118
+ if (rsStruct) {
119
+ symbols.push(`struct ${rsStruct[1]}`);
120
+ continue;
121
+ }
122
+ }
123
+ else if (ext === '.go') {
124
+ const goFn = trimmed.match(/^func\s+(?:\([^\)]+\)\s+)?([a-zA-Z0-9_]+)/);
125
+ if (goFn) {
126
+ symbols.push(`func ${goFn[1]}`);
127
+ continue;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ catch { }
133
+ return symbols.slice(0, 8);
134
+ }
135
+ }
@@ -0,0 +1,119 @@
1
+ import pc from 'picocolors';
2
+ import { getCurrentTheme } from '../cli/theme.js';
3
+ export class UsageTracker {
4
+ static instance;
5
+ requestsCount = 0;
6
+ bytesSent = 0;
7
+ bytesReceived = 0;
8
+ promptTokens = 0;
9
+ completionTokens = 0;
10
+ totalCost = 0;
11
+ dataSaverLimitMB;
12
+ warnedThreshold = false;
13
+ constructor() { }
14
+ static getInstance() {
15
+ if (!UsageTracker.instance) {
16
+ UsageTracker.instance = new UsageTracker();
17
+ }
18
+ return UsageTracker.instance;
19
+ }
20
+ setLimit(limitMB) {
21
+ this.dataSaverLimitMB = limitMB;
22
+ }
23
+ recordRequest(bytes) {
24
+ this.requestsCount++;
25
+ this.bytesSent += Math.max(0, bytes);
26
+ }
27
+ recordResponseChunk(bytes) {
28
+ this.bytesReceived += Math.max(0, bytes);
29
+ }
30
+ recordTokens(prompt, completion, cost = 0) {
31
+ this.promptTokens += Math.max(0, prompt);
32
+ this.completionTokens += Math.max(0, completion);
33
+ this.totalCost += Math.max(0, cost);
34
+ }
35
+ getSummary() {
36
+ return {
37
+ requestsCount: this.requestsCount,
38
+ bytesSent: this.bytesSent,
39
+ bytesReceived: this.bytesReceived,
40
+ totalBytes: this.bytesSent + this.bytesReceived,
41
+ promptTokens: this.promptTokens,
42
+ completionTokens: this.completionTokens,
43
+ totalTokens: this.promptTokens + this.completionTokens,
44
+ totalCost: this.totalCost,
45
+ dataSaverLimitMB: this.dataSaverLimitMB
46
+ };
47
+ }
48
+ static formatBytes(bytes) {
49
+ if (bytes <= 0)
50
+ return '0 B';
51
+ if (bytes < 1024)
52
+ return `${bytes} B`;
53
+ if (bytes < 1024 * 1024)
54
+ return `${(bytes / 1024).toFixed(1)} KB`;
55
+ if (bytes < 1024 * 1024 * 1024)
56
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
57
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
58
+ }
59
+ static formatTokens(n) {
60
+ if (n >= 1_000_000)
61
+ return `${(n / 1_000_000).toFixed(2)}M`;
62
+ if (n >= 1000)
63
+ return `${(n / 1000).toFixed(1)}k`;
64
+ return `${n}`;
65
+ }
66
+ renderUsageCard() {
67
+ const theme = getCurrentTheme();
68
+ const summary = this.getSummary();
69
+ const cols = Math.min(process.stdout.columns || 80, 75);
70
+ const boxWidth = Math.max(34, cols - 4);
71
+ const innerWidth = boxWidth - 4;
72
+ const padRow = (label, value) => {
73
+ const plainLen = label.length + value.length;
74
+ const spaces = Math.max(1, innerWidth - plainLen);
75
+ return `│ ${pc.bold(label)}${' '.repeat(spaces)}${value} │`;
76
+ };
77
+ const header = theme.colorFn('┌─ ') + pc.bold('📊 Session Bandwidth & Usage Monitor') + ' ' + theme.colorFn('─'.repeat(Math.max(2, boxWidth - 41)) + '┐');
78
+ const divider = theme.colorFn('├' + '─'.repeat(boxWidth - 2) + '┤');
79
+ const footer = theme.colorFn('└' + '─'.repeat(boxWidth - 2) + '┘');
80
+ const totalBandwidthStr = pc.cyan(UsageTracker.formatBytes(summary.totalBytes));
81
+ const sentStr = pc.dim(UsageTracker.formatBytes(summary.bytesSent));
82
+ const recvStr = pc.dim(UsageTracker.formatBytes(summary.bytesReceived));
83
+ const reqStr = pc.yellow(`${summary.requestsCount}`);
84
+ const tokenStr = pc.magenta(`${UsageTracker.formatTokens(summary.totalTokens)} tokens`);
85
+ const promptTokenStr = pc.dim(`${UsageTracker.formatTokens(summary.promptTokens)} in`);
86
+ const compTokenStr = pc.dim(`${UsageTracker.formatTokens(summary.completionTokens)} out`);
87
+ const costStr = pc.green(`$${summary.totalCost.toFixed(4)}`);
88
+ const lines = [
89
+ header,
90
+ padRow('Total Network Traffic:', totalBandwidthStr),
91
+ padRow(' • Upload (Sent):', sentStr),
92
+ padRow(' • Download (Recv):', recvStr),
93
+ padRow('API Requests Count:', reqStr),
94
+ divider,
95
+ padRow('Tokens Consumed:', tokenStr),
96
+ padRow(' • Breakdown:', `${promptTokenStr} / ${compTokenStr}`),
97
+ padRow('Session Cost:', costStr)
98
+ ];
99
+ if (summary.dataSaverLimitMB) {
100
+ const usedMB = summary.totalBytes / (1024 * 1024);
101
+ const pct = Math.min(100, Math.round((usedMB / summary.dataSaverLimitMB) * 100));
102
+ const limitStr = pct >= 90 ? pc.red(`${usedMB.toFixed(1)} / ${summary.dataSaverLimitMB} MB (${pct}%)`) : pc.cyan(`${usedMB.toFixed(1)} / ${summary.dataSaverLimitMB} MB (${pct}%)`);
103
+ lines.push(divider);
104
+ lines.push(padRow('Data Saver Limit:', limitStr));
105
+ }
106
+ lines.push(footer);
107
+ return lines.join('\n') + '\n';
108
+ }
109
+ checkThresholdWarning() {
110
+ if (!this.dataSaverLimitMB || this.warnedThreshold)
111
+ return null;
112
+ const usedMB = (this.bytesSent + this.bytesReceived) / (1024 * 1024);
113
+ if (usedMB >= this.dataSaverLimitMB) {
114
+ this.warnedThreshold = true;
115
+ return `⚠️ [DATA SAVER WARNING] Session network traffic has reached ${usedMB.toFixed(1)} MB (Limit: ${this.dataSaverLimitMB} MB)!`;
116
+ }
117
+ return null;
118
+ }
119
+ }
@@ -34,14 +34,34 @@ function isDangerousBashCommand(commandStr) {
34
34
  lower.includes('> /dev/sda') ||
35
35
  lower.includes('format c:'));
36
36
  }
37
+ /**
38
+ * Checks if a command contains chaining, redirection, or subshell operators.
39
+ * Allowlist matches MUST be clean, single commands without hidden side-effects.
40
+ */
41
+ function hasComplexChainingOrRedirection(commandStr) {
42
+ if (!commandStr)
43
+ return false;
44
+ return (commandStr.includes('&&') ||
45
+ commandStr.includes('||') ||
46
+ commandStr.includes(';') ||
47
+ commandStr.includes('|') ||
48
+ commandStr.includes('>') ||
49
+ commandStr.includes('<') ||
50
+ commandStr.includes('`') ||
51
+ commandStr.includes('$(') ||
52
+ commandStr.includes('\n') ||
53
+ commandStr.includes('\r'));
54
+ }
37
55
  export class CLIConsoleGuard {
38
56
  autoApprove;
39
- constructor(autoApprove = false) {
57
+ bashAllowlist;
58
+ constructor(autoApprove = false, bashAllowlist = []) {
40
59
  this.autoApprove = autoApprove;
60
+ this.bashAllowlist = bashAllowlist;
41
61
  }
42
62
  check(toolName, args) {
43
63
  const t = (toolName || '').toLowerCase();
44
- const cmd = args?.command || args?.cmd || '';
64
+ const cmd = (args?.command || args?.cmd || '').trim();
45
65
  // Critical safety net: even in YOLO mode, warn and confirm destructive system commands
46
66
  if (this.autoApprove) {
47
67
  if (t === 'bash' && isDangerousBashCommand(cmd)) {
@@ -49,9 +69,19 @@ export class CLIConsoleGuard {
49
69
  }
50
70
  return false;
51
71
  }
52
- // 1. Shell commands always require confirmation in safe mode
53
- if (t === 'bash' || t === 'exec' || t === 'run_command') {
54
- return true;
72
+ // Check Bash Allowlist: only allow if command is NOT dangerous AND does not use chaining/redirection
73
+ if ((t === 'bash' || t === 'exec' || t === 'run_command') && cmd) {
74
+ if (!isDangerousBashCommand(cmd) && !hasComplexChainingOrRedirection(cmd)) {
75
+ const isAllowed = this.bashAllowlist.some(pattern => {
76
+ const p = pattern.trim().toLowerCase();
77
+ const lowerCmd = cmd.toLowerCase();
78
+ return lowerCmd === p || lowerCmd.startsWith(p + ' ');
79
+ });
80
+ if (isAllowed) {
81
+ return false; // Automatically allowed!
82
+ }
83
+ }
84
+ return true; // Requires user confirmation
55
85
  }
56
86
  // 2. Package installations require confirmation
57
87
  if (t === 'install_package' || t === 'packages') {
@@ -72,5 +72,14 @@ export async function buildSystemPrompt(planMode) {
72
72
  }
73
73
  catch (e) {
74
74
  }
75
+ // Load Compact Repo Map (AST structure & exported symbols)
76
+ try {
77
+ const { RepoMapGenerator } = await import('../core/repomap.js');
78
+ const repoMap = await RepoMapGenerator.generate(process.cwd(), 50);
79
+ if (repoMap.trim()) {
80
+ prompt += `\n--- Codebase Map & Exported Symbols ---\n${repoMap}\n----------------------------------------\n`;
81
+ }
82
+ }
83
+ catch { }
75
84
  return prompt;
76
85
  }
@@ -1,4 +1,5 @@
1
1
  import { calculateCost } from '../core/pricing.js';
2
+ import { UsageTracker } from '../core/usage.js';
2
3
  export class OpenAIProvider {
3
4
  baseUrl;
4
5
  apiKey;
@@ -66,20 +67,23 @@ export class OpenAIProvider {
66
67
  async chat(request) {
67
68
  const url = `${this.baseUrl}/chat/completions`;
68
69
  const payload = this.buildPayload(request, false);
70
+ const bodyStr = JSON.stringify(payload);
69
71
  const headers = {
70
72
  'Content-Type': 'application/json',
71
73
  };
72
74
  if (this.apiKey) {
73
75
  headers['Authorization'] = `Bearer ${this.apiKey}`;
74
76
  }
77
+ UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
75
78
  const res = await fetch(url, {
76
79
  method: 'POST',
77
80
  headers,
78
- body: JSON.stringify(payload),
81
+ body: bodyStr,
79
82
  signal: request.signal
80
83
  });
81
84
  if (!res.ok) {
82
85
  const errText = await res.text();
86
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
83
87
  let errMsg = errText;
84
88
  try {
85
89
  const parsed = JSON.parse(errText);
@@ -95,7 +99,15 @@ export class OpenAIProvider {
95
99
  err.status = res.status;
96
100
  throw err;
97
101
  }
98
- const data = await res.json();
102
+ const rawResText = await res.text();
103
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(rawResText, 'utf8'));
104
+ let data = {};
105
+ try {
106
+ data = JSON.parse(rawResText);
107
+ }
108
+ catch {
109
+ throw new Error(`Invalid JSON response from provider`);
110
+ }
99
111
  if (data.error) {
100
112
  const msg = data.error.message || data.error.code || JSON.stringify(data.error);
101
113
  throw new Error(`Provider Error: ${msg}`);
@@ -115,6 +127,7 @@ export class OpenAIProvider {
115
127
  totalTokens,
116
128
  cost
117
129
  };
130
+ UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
118
131
  }
119
132
  return {
120
133
  content: choice.content || null,
@@ -129,20 +142,23 @@ export class OpenAIProvider {
129
142
  async *chatStream(request) {
130
143
  const url = `${this.baseUrl}/chat/completions`;
131
144
  const payload = this.buildPayload(request, true);
145
+ const bodyStr = JSON.stringify(payload);
132
146
  const headers = {
133
147
  'Content-Type': 'application/json',
134
148
  };
135
149
  if (this.apiKey) {
136
150
  headers['Authorization'] = `Bearer ${this.apiKey}`;
137
151
  }
152
+ UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
138
153
  const res = await fetch(url, {
139
154
  method: 'POST',
140
155
  headers,
141
- body: JSON.stringify(payload),
156
+ body: bodyStr,
142
157
  signal: request.signal
143
158
  });
144
159
  if (!res.ok) {
145
160
  const errText = await res.text();
161
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
146
162
  let errMsg = errText;
147
163
  try {
148
164
  const parsed = JSON.parse(errText);
@@ -184,6 +200,9 @@ export class OpenAIProvider {
184
200
  const { done, value } = await reader.read();
185
201
  if (done || request.signal?.aborted)
186
202
  break;
203
+ if (value) {
204
+ UsageTracker.getInstance().recordResponseChunk(value.byteLength);
205
+ }
187
206
  buffer += decoder.decode(value, { stream: true });
188
207
  const lines = buffer.split('\n');
189
208
  buffer = lines.pop() || '';
@@ -333,6 +352,7 @@ export class OpenAIProvider {
333
352
  totalTokens,
334
353
  cost
335
354
  };
355
+ UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
336
356
  const finalResponse = {
337
357
  content: accumulatedContent || null,
338
358
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termux-dev",
3
- "version": "1.2.1",
3
+ "version": "1.3.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",