@lardexx/arenacode 2.8.0 → 2.10.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/src/local.js CHANGED
@@ -48,22 +48,110 @@ function relativize(root, abs) {
48
48
  // Local terminal (local-exec)
49
49
  // --------------------------------------------------------------------------- //
50
50
 
51
- function shellCommand(shell, command) {
52
- if (process.platform === 'win32') {
53
- switch (shell) {
51
+ // Every shell the client can drive. The bridge may ask for one by name (`shell` in the
52
+ // local-exec request), the user can pin one (`arenacode config set shell zsh`, `/shell`),
53
+ // and otherwise the client takes the user's login shell, like a terminal would.
54
+ const UNIX_SHELLS = {
55
+ sh: '/bin/sh',
56
+ bash: '/bin/bash',
57
+ zsh: '/bin/zsh',
58
+ fish: '/usr/bin/fish',
59
+ dash: '/bin/dash',
60
+ ksh: '/bin/ksh',
61
+ };
62
+
63
+ const WINDOWS_SHELLS = ['powershell', 'pwsh', 'cmd', 'wsl', 'bash'];
64
+
65
+ function shellExists(file) {
66
+ try {
67
+ fs.accessSync(file, fs.constants.X_OK);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** sh/bash/zsh/dash/ksh: -l login, -c command. fish spells the same as -l -c. */
75
+ function unixShell(name) {
76
+ const wanted = String(name || '').toLowerCase().replace(/\.exe$/, '');
77
+ const alias = { 'git-bash': 'bash', gitbash: 'bash' }[wanted] || wanted;
78
+ let file = UNIX_SHELLS[alias];
79
+ if (!file && (alias === 'sh' || alias.startsWith('/'))) file = alias === 'sh' ? '/bin/sh' : alias;
80
+ if (!file) file = whichPath(alias);
81
+ if (!file) file = UNIX_SHELLS[alias] || null;
82
+ if (!file) return null;
83
+ const base = path.basename(file);
84
+ const args = base === 'fish' ? ['-l', '-c', null] : ['-lc', null];
85
+ return { file, args };
86
+ }
87
+
88
+ function whichPath(name) {
89
+ for (const dir of ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']) {
90
+ const file = path.join(dir, name);
91
+ if (shellExists(file)) return file;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** The shell to use when nobody asked for a specific one. */
97
+ function detectShell(platform = process.platform) {
98
+ if (platform === 'win32') {
99
+ if (shellExists('pwsh.exe') || whichPath('pwsh')) return 'pwsh';
100
+ return 'powershell';
101
+ }
102
+ const login = path.basename(process.env.SHELL || '');
103
+ if (login && (UNIX_SHELLS[login] || whichPath(login))) return login;
104
+ return 'sh';
105
+ }
106
+
107
+ /** What the client can offer on this machine — for `sysinfo`, `/shell` and the help. */
108
+ function availableShells(platform = process.platform) {
109
+ if (platform === 'win32') {
110
+ return WINDOWS_SHELLS.filter((name) => name === 'powershell' || name === 'cmd' || Boolean(whichPath(name)));
111
+ }
112
+ const found = Object.keys(UNIX_SHELLS).filter((name) => shellExists(UNIX_SHELLS[name]) || Boolean(whichPath(name)));
113
+ return found.length ? found : ['sh'];
114
+ }
115
+
116
+ /**
117
+ * Command template for one shell. `command` is passed as its own argv entry, never
118
+ * through a second round of quoting, so quotes and `$` survive.
119
+ */
120
+ function shellCommand(shell, command, platform = process.platform) {
121
+ const name = String(shell || detectShell(platform)).toLowerCase().replace(/\.exe$/, '');
122
+ if (platform === 'win32') {
123
+ switch (name) {
54
124
  case 'cmd':
55
125
  return { file: 'cmd.exe', args: ['/d', '/s', '/c', command] };
56
126
  case 'wsl':
57
127
  return { file: 'wsl.exe', args: ['sh', '-lc', command] };
128
+ case 'bash': {
129
+ // Git Bash when it is installed, otherwise the WSL bash
130
+ const gitBash = [
131
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
132
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
133
+ ].find((file) => {
134
+ try {
135
+ return fs.existsSync(file);
136
+ } catch {
137
+ return false;
138
+ }
139
+ });
140
+ return gitBash
141
+ ? { file: gitBash, args: ['-lc', command] }
142
+ : { file: 'wsl.exe', args: ['bash', '-lc', command] };
143
+ }
144
+ case 'pwsh':
145
+ return { file: 'pwsh.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
58
146
  case 'powershell':
147
+ case 'ps':
59
148
  default:
60
- return {
61
- file: shell === 'pwsh' ? 'pwsh.exe' : 'powershell.exe',
62
- args: ['-NoProfile', '-NonInteractive', '-Command', command],
63
- };
149
+ return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
64
150
  }
65
151
  }
66
- return { file: '/bin/sh', args: ['-lc', command] };
152
+ const template = unixShell(name) || unixShell('sh');
153
+ const args = template.args.map((arg) => (arg === null ? command : arg));
154
+ return { file: template.file, args };
67
155
  }
68
156
 
69
157
  /** Runs a command and collects stdout+stderr and the exit code. */
@@ -96,9 +184,12 @@ async function handleLocalExec(bridge, taskId, request, opts) {
96
184
  const { rid, command, cwd: reqCwd, shell, admin, sandbox_reason: sandboxReason } = request;
97
185
  const lang = opts.lang;
98
186
  const dir = reqCwd ? resolveInRoot(opts.folder, reqCwd) : opts.folder;
187
+ // the bridge names the shell it wrote the command for; otherwise the user's choice
188
+ // from `arenacode config set shell …` / `/shell`, otherwise the login shell
189
+ const chosenShell = shell || opts.shell || detectShell();
99
190
 
100
191
  write('');
101
- write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(shell || 'shell')} ${c.dim('·')} ${c.gray(dir)}`);
192
+ write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(chosenShell)} ${c.dim('·')} ${c.gray(dir)}`);
102
193
  write(indentLines(command, ' '));
103
194
  if (admin) write(` ${c.yellow('!')} ${t(lang, 'Richiede i permessi di amministratore: qui il comando gira senza UAC.', 'Needs administrator rights: here it runs without UAC.', 'Necesita permisos de administrador: aquí se ejecuta sin UAC.')}`);
104
195
  if (sandboxReason) write(` ${c.yellow('!')} ${t(lang, 'L’agente chiede di installare sul PC', 'The agent asks to install on the PC', 'El agente pide instalar en el PC')}: ${c.gray(sandboxReason)}`);
@@ -125,7 +216,7 @@ async function handleLocalExec(bridge, taskId, request, opts) {
125
216
  return true;
126
217
  }
127
218
 
128
- const { output, code } = await runShell(command, { cwd: dir, shell });
219
+ const { output, code } = await runShell(command, { cwd: dir, shell: chosenShell });
129
220
  await bridge.localResult(taskId, { rid, output, code });
130
221
  write(` ${code === 0 ? c.green('✓') : c.red('✗')} ${c.gray(`exit ${code}`)}`);
131
222
  return true;
@@ -495,7 +586,8 @@ async function handleLocalUi(bridge, taskId, request, opts) {
495
586
  cpus: os.cpus().length,
496
587
  memory_mb: Math.round(os.totalmem() / 1048576),
497
588
  node: process.version,
498
- shell: require('./meta').which('pwsh') ? 'pwsh' : process.platform === 'win32' ? 'powershell' : 'sh',
589
+ shell: detectShell(),
590
+ shells: availableShells(),
499
591
  };
500
592
  await bridge.localResult(taskId, { rid, output: JSON.stringify(info), code: 0 });
501
593
  write(` ${c.green('✓')} ${c.gray('sysinfo')}`);
@@ -683,6 +775,9 @@ async function handleLocalFile(bridge, taskId, request, opts) {
683
775
 
684
776
  module.exports = {
685
777
  runUiAction,
778
+ shellCommand,
779
+ detectShell,
780
+ availableShells,
686
781
  REFUSED,
687
782
  resolveInRoot,
688
783
  runShell,
package/src/tui/app.js CHANGED
@@ -14,7 +14,7 @@ const { getTheme, themeNames } = require('./theme');
14
14
 
15
15
  const { Bridge } = require('../bridge');
16
16
  const { buildMeta, readProjectContext } = require('../meta');
17
- const { runShell, runFsOp, resolveInRoot } = require('../local');
17
+ const { runShell, runFsOp, resolveInRoot, detectShell, availableShells, shellCommand } = require('../local');
18
18
  const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
19
19
 
20
20
  const { MODE_LINES, modeLabel, nextMode, normaliseMode } = require('../modes');
@@ -49,6 +49,7 @@ class App {
49
49
  this.autoApprove = Boolean(opts.yes);
50
50
  this.confirmWrites = Boolean(opts.confirmWrites);
51
51
  this.approval = normaliseApproval(opts.approval);
52
+ this.shell = String(opts.shell || cfg.shell || '').trim() || detectShell();
52
53
 
53
54
  this.state = {
54
55
  mode: normaliseMode(opts.mode || cfg.mode || 'agent'),
@@ -493,6 +494,7 @@ class App {
493
494
  { slash: '/ask', title: 'Ask and Proceed: ask before commands, files and plans', hint: '', group: 'Session', run: () => this.setApproval('ask') },
494
495
  { slash: '/proceed', title: 'Proceed: approve the plan and run everything without asking', hint: '', group: 'Session', run: () => this.setApproval('proceed') },
495
496
  { slash: '/theme', title: 'Cycle the colour theme', hint: 'ctrl+x t', group: 'Session', run: by('Theme') },
497
+ { slash: '/shell', title: 'Show or change the shell commands run in', hint: '', group: 'Session', run: () => this.pickShell() },
496
498
  { slash: '/status', title: 'Ping the bridge', hint: '', group: 'Session', run: by('Bridge status') },
497
499
  { slash: '/help', title: 'Every keybinding', hint: 'f1', group: 'Session', run: () => this.openHelp() },
498
500
  { slash: '/quit', title: 'Leave arenacode', hint: 'ctrl+x q', group: 'Session', run: () => this.quit() },
@@ -538,6 +540,42 @@ class App {
538
540
  this.toast(`approval: ${next === 'proceed' ? 'Proceed' : 'Ask and Proceed'}`, next === 'proceed' ? 'warn' : 'ok');
539
541
  }
540
542
 
543
+ /** `/shell` (and the double-click on a card's shell line): pick from the shells here. */
544
+ async pickShell(name = '') {
545
+ const shells = availableShells();
546
+ const wanted = String(name || '').trim().toLowerCase();
547
+ if (wanted) {
548
+ if (!shells.includes(wanted)) {
549
+ this.toast(`shell: ${wanted} not found (${shells.join(', ')})`, 'warn');
550
+ return;
551
+ }
552
+ this.setShell(wanted);
553
+ return;
554
+ }
555
+ // a free-text prompt instead of a dialog: esc keeps the current shell
556
+ this.toast(`shell: ${this.shell} (${shells.join(', ')})`);
557
+ const current = shellCommand(this.shell).file;
558
+ const answer = String(await this.prompt(`shell [${current}]`)).trim().toLowerCase();
559
+ if (!answer) return;
560
+ if (!shells.includes(answer)) {
561
+ this.toast(`shell: ${answer} not found (${shells.join(', ')})`, 'warn');
562
+ return;
563
+ }
564
+ this.setShell(answer);
565
+ }
566
+
567
+ setShell(name) {
568
+ this.shell = name;
569
+ try {
570
+ patchConfig({ shell: name });
571
+ } catch {
572
+ /* a read-only HOME keeps the choice in memory only */
573
+ }
574
+ const { file } = shellCommand(name);
575
+ this.push({ kind: 'system', text: `shell: ${name} · ${file}` });
576
+ this.toast(`shell: ${name}`, 'ok');
577
+ }
578
+
541
579
  onOverlayKey(key) {
542
580
  const ov = this.state.overlay;
543
581
  if (key.name === 'escape') {
@@ -672,7 +710,7 @@ class App {
672
710
  const cmd = text.slice(1).trim();
673
711
  this.push({ kind: 'user', text: cmd, shell: true });
674
712
  const item = this.push({ kind: 'tool', icon: 'terminal', label: cmd, state: 'running', detail: '' });
675
- const { output, code } = await runShell(cmd, { cwd: this.folder, shell: undefined, quiet: true });
713
+ const { output, code } = await runShell(cmd, { cwd: this.folder, shell: this.shell, quiet: true });
676
714
  item.state = code === 0 ? 'done' : 'failed';
677
715
  item.detail = output.trim();
678
716
  item.secs = undefined;