shmakk 1.2.1 → 1.2.3

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/hooks/bash.js CHANGED
@@ -3,6 +3,21 @@ const os = require('os');
3
3
  const path = require('path');
4
4
 
5
5
  const INIT = `
6
+ # Source login scripts first (equivalent to bash -l).
7
+ # When --rcfile is used, bash skips the normal login sequence, so we must
8
+ # replicate it manually. Order matters: profile.d → /etc/profile, then
9
+ # first found of: ~/.bash_profile, ~/.bash_login, ~/.profile, ~/.bashrc.
10
+ [ -d /etc/profile.d ] && for f in /etc/profile.d/*.sh; do [ -r "$f" ] && . "$f"; done
11
+ [ -f /etc/profile ] && . /etc/profile
12
+ if [ -f "$HOME/.bash_profile" ]; then
13
+ . "$HOME/.bash_profile"
14
+ elif [ -f "$HOME/.bash_login" ]; then
15
+ . "$HOME/.bash_login"
16
+ elif [ -f "$HOME/.profile" ]; then
17
+ . "$HOME/.profile"
18
+ fi
19
+ # Then interactive rc files (these may be already sourced above, but
20
+ # sourcing twice is harmless for well-behaved scripts).
6
21
  [ -f /etc/bash.bashrc ] && . /etc/bash.bashrc
7
22
  [ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"
8
23
 
@@ -13,13 +28,13 @@ __shmakk_preexec() {
13
28
  [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return
14
29
  __shmakk_armed=
15
30
  local cmd
16
- cmd=$(printf '%s' "$BASH_COMMAND" | base64 -w0 2>/dev/null || printf '%s' "$BASH_COMMAND" | base64)
31
+ cmd=$(printf '%s' "$BASH_COMMAND" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
17
32
  printf '\\e]6973;B;%s\\a' "$cmd"
18
33
  }
19
34
  __shmakk_precmd() {
20
35
  local ec=$?
21
36
  local p
22
- p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || printf '%s' "$PWD" | base64)
37
+ p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
23
38
  printf '\\e]6973;C;%s\\a' "$ec"
24
39
  printf '\\e]6973;D;%s\\a' "$p"
25
40
  __shmakk_armed=1
package/src/hooks/fish.js CHANGED
@@ -1,17 +1,36 @@
1
1
  // Returns { args, env, cleanup } for spawning fish with markers wired up.
2
2
  // fish supports `-C COMMAND` to run init code after config.fish.
3
+ //
4
+ // base64 encoding: try `-w0` (GNU coreutils), fall back to `-b 0` (BSD/macOS),
5
+ // then plain `base64` as last resort. `tr -d '\n'` strips any line wrapping
6
+ // so the OSC marker payload stays on one line.
3
7
 
4
8
  const INIT = `
5
9
  function __shmakk_pre --on-event fish_preexec
6
- set -l c (printf '%s' "$argv" | base64 -w0 2>/dev/null; or printf '%s' "$argv" | base64)
10
+ set -l c (printf '%s' "$argv" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
7
11
  printf '\\e]6973;B;%s\\a' "$c"
8
12
  end
9
13
  function __shmakk_post --on-event fish_postexec
10
14
  set -l ec $status
11
- set -l p (printf '%s' "$PWD" | base64 -w0 2>/dev/null; or printf '%s' "$PWD" | base64)
15
+ set -l p (printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
12
16
  printf '\\e]6973;C;%s\\a' $ec
13
17
  printf '\\e]6973;D;%s\\a' "$p"
14
18
  end
19
+ # Override shmakk binary inside a session so "shmakk <cmd>" routes to
20
+ # local self-commands instead of forking a nested shmakk process.
21
+ # Passes through --flags to the real shmakk binary.
22
+ function shmakk
23
+ if set -q argv[1]; and string match -qr '^--' -- "$argv[1]"
24
+ command shmakk $argv
25
+ return $status
26
+ end
27
+ set -l raw (printf '%s' "shmakk $argv" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
28
+ set -l pwd_b64 (printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
29
+ printf '\\e]6973;B;%s\\a' "$raw"
30
+ printf '\\e]6973;C;127\\a'
31
+ printf '\\e]6973;D;%s\\a' "$pwd_b64"
32
+ return 127
33
+ end
15
34
  `.trim();
16
35
 
17
36
  function configure() {
package/src/hooks/zsh.js CHANGED
@@ -2,6 +2,28 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
 
5
+ // zsh under a custom ZDOTDIR sources .zshenv, .zprofile, .zshrc, .zlogin from
6
+ // that directory. We create all four so nothing from the user's real ZDOTDIR
7
+ // is skipped.
8
+
9
+ const ZSHENV = `
10
+ # Source the real .zshenv so PATH and env vars are available.
11
+ if [ -n "$SHMAKK_REAL_ZDOTDIR" ] && [ -f "$SHMAKK_REAL_ZDOTDIR/.zshenv" ]; then
12
+ source "$SHMAKK_REAL_ZDOTDIR/.zshenv"
13
+ elif [ -f "$HOME/.zshenv" ]; then
14
+ source "$HOME/.zshenv"
15
+ fi
16
+ `;
17
+
18
+ const ZPROFILE = `
19
+ # Source the real .zprofile (login shell initialization).
20
+ if [ -n "$SHMAKK_REAL_ZDOTDIR" ] && [ -f "$SHMAKK_REAL_ZDOTDIR/.zprofile" ]; then
21
+ source "$SHMAKK_REAL_ZDOTDIR/.zprofile"
22
+ elif [ -f "$HOME/.zprofile" ]; then
23
+ source "$HOME/.zprofile"
24
+ fi
25
+ `;
26
+
5
27
  const ZSHRC = `
6
28
  # preserve real ZDOTDIR so user config is sourced
7
29
  if [ -n "$SHMAKK_REAL_ZDOTDIR" ]; then
@@ -12,13 +34,13 @@ fi
12
34
 
13
35
  __shmakk_preexec() {
14
36
  local cmd
15
- cmd=$(printf '%s' "$1" | base64 -w0 2>/dev/null || printf '%s' "$1" | base64)
37
+ cmd=$(printf '%s' "$1" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
16
38
  printf '\\e]6973;B;%s\\a' "$cmd"
17
39
  }
18
40
  __shmakk_precmd() {
19
41
  local ec=$?
20
42
  local p
21
- p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || printf '%s' "$PWD" | base64)
43
+ p=$(printf '%s' "$PWD" | base64 -w0 2>/dev/null || base64 -b 0 2>/dev/null || base64 | tr -d '\n')
22
44
  printf '\\e]6973;C;%s\\a' "$ec"
23
45
  printf '\\e]6973;D;%s\\a' "$p"
24
46
  }
@@ -30,7 +52,14 @@ precmd_functions+=(__shmakk_precmd)
30
52
  function configure() {
31
53
  const dir = path.join(os.tmpdir(), `shmakk-zsh-${process.pid}`);
32
54
  fs.mkdirSync(dir, { recursive: true });
55
+ // zsh under a custom ZDOTDIR sources .zshenv, .zprofile, .zshrc, .zlogin
56
+ // from that directory. We must provide all four so the user's environment
57
+ // is complete.
58
+ fs.writeFileSync(path.join(dir, '.zshenv'), ZSHENV, { mode: 0o600 });
59
+ fs.writeFileSync(path.join(dir, '.zprofile'), ZPROFILE, { mode: 0o600 });
33
60
  fs.writeFileSync(path.join(dir, '.zshrc'), ZSHRC, { mode: 0o600 });
61
+ // No .zlogin needed — zsh docs say .zlogin is for commands to run at the
62
+ // start of an interactive login shell; .zprofile already covers env setup.
34
63
  const realZ = process.env.ZDOTDIR || '';
35
64
  return {
36
65
  args: ['-i'],
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const { parseArgs, HELP } = require('./cli');
1
+ const { parseArgs, HELP, resolveHelp } = require('./cli');
2
2
  const { normalizeProfile, resolveProfile } = require('./profiles');
3
3
  const { applyEndpoint, getCurrentEndpoint, getCurrentEndpointName } = require('./endpoints');
4
4
  const { ensureModelRuntime } = require('./llm');
@@ -66,7 +66,7 @@ async function main() {
66
66
  }
67
67
 
68
68
  if (opts.help) {
69
- process.stdout.write(HELP);
69
+ process.stdout.write(resolveHelp(opts.helpCategory));
70
70
  process.exit(0);
71
71
  }
72
72
 
@@ -204,6 +204,15 @@ async function main() {
204
204
  if (opts.ttsVoice) process.env.SHMAKK_TTS_VOICE = opts.ttsVoice;
205
205
 
206
206
  const { start } = require('./orchestrator');
207
+
208
+ // Refuse to nest sessions: launching shmakk inside shmakk would
209
+ // create a recursive PTY tree with no benefit.
210
+ if (process.env.SHMAKK === '1') {
211
+ process.stderr.write('[shmakk] already inside an shmakk session (SHMAKK=1).\n');
212
+ process.stderr.write('[shmakk] use --help to see in-session commands, or exit the current session first.\n');
213
+ process.exit(1);
214
+ }
215
+
207
216
  const exitCode = await start(opts);
208
217
  process.exit(exitCode);
209
218
  }
package/src/llm.js CHANGED
@@ -4,7 +4,7 @@ try { OpenAI = require('openai'); } catch { OpenAI = null; }
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const fs = require('fs');
7
- const { getCurrentEndpoint, getCurrentEndpointName, getModelRegistry } = require('./endpoints');
7
+ const { getCurrentEndpoint, getCurrentEndpointName, getModelRegistry, supportsVision } = require('./endpoints');
8
8
 
9
9
  function parseHeaders(s) {
10
10
  const out = {};
@@ -536,4 +536,4 @@ function getDeepSeekOptions(taskType) {
536
536
  };
537
537
  }
538
538
 
539
- module.exports = { makeClient, modelFor, isConfigured, ensureModelRuntime, getDeepSeekOptions, isDeepSeekProvider };
539
+ module.exports = { makeClient, modelFor, isConfigured, ensureModelRuntime, getDeepSeekOptions, isDeepSeekProvider, supportsVision };
package/src/mcp-client.js CHANGED
@@ -212,9 +212,15 @@ class MCPServer {
212
212
  if (item.type === 'text') {
213
213
  texts.push(item.text);
214
214
  } else if (item.type === 'image') {
215
+ // Preserve base64 image data so vision-capable providers can process it.
216
+ // Cap at ~2MB of base64 to avoid blowing out context windows.
217
+ const raw = String(item.data || '');
218
+ const capped = raw.length > 2_000_000 ? raw.slice(0, 2_000_000) : raw;
215
219
  images.push({
216
220
  mimeType: item.mimeType || 'image/png',
217
- dataLength: (item.data || '').length,
221
+ data: capped,
222
+ dataLength: raw.length,
223
+ truncated: raw.length > 2_000_000,
218
224
  });
219
225
  } else if (item.type === 'resource') {
220
226
  texts.push(`[resource: ${item.resource?.uri || 'unknown'}]`);
package/src/notify.js CHANGED
@@ -2,14 +2,17 @@
2
2
  // Falls back silently if notify-send is not available or no notification
3
3
  // daemon is running.
4
4
 
5
- const { execFile } = require('child_process');
5
+ const { execFile, execFileSync } = require('child_process');
6
+ const { existsSync } = require('fs');
6
7
 
7
8
  const NOTIFY_BIN = 'notify-send';
8
9
 
9
10
  function available() {
10
11
  try {
11
- const { execFileSync } = require('child_process');
12
- execFileSync('which', [NOTIFY_BIN], { stdio: 'ignore' });
12
+ // Prefer direct path check; fall back to `command -v` if not at known paths
13
+ if (existsSync('/usr/bin/notify-send')) return true;
14
+ if (existsSync('/usr/local/bin/notify-send')) return true;
15
+ execFileSync('command', ['-v', NOTIFY_BIN], { stdio: 'ignore' });
13
16
  return true;
14
17
  } catch {
15
18
  return false;
package/src/pty.js CHANGED
@@ -13,8 +13,8 @@ function getSize() {
13
13
 
14
14
  const VOICE_HOTKEY = 0x0f; // Ctrl+O — triggers voice recording
15
15
 
16
- function startSession({ debug = false, voiceEnabled = false } = {}) {
17
- const shell = detectShell();
16
+ function startSession({ debug = false, voiceEnabled = false, shellOverride = null } = {}) {
17
+ const shell = detectShell(shellOverride);
18
18
  const cfg = configureForShell(shell.name);
19
19
  const { cols, rows } = getSize();
20
20
 
package/src/review.js CHANGED
@@ -2,13 +2,13 @@
2
2
  // optional `{ onCancel }` callback that fires when the user hits Ctrl-C.
3
3
 
4
4
  function makePrompter(pty, write, opts = {}) {
5
- const { onNotify } = opts;
6
5
  return function ask(question, defaultYes, { onCancel, onWhy } = {}) {
7
6
  return new Promise((resolve) => {
8
- if (onNotify) {
7
+ if (opts.notify) {
9
8
  try {
9
+ const { notify } = require('./notify');
10
10
  const body = typeof question === 'string' ? question.replace(/\x1b\[[0-9;]*m/g, '') : String(question || '');
11
- onNotify('shmakk needs your attention', body.slice(0, 120));
11
+ notify('shmakk needs your attention', body.slice(0, 120));
12
12
  } catch {}
13
13
  }
14
14
  const tag = defaultYes ? '[Y/n/?]' : '[y/N/?]';
@@ -110,12 +110,14 @@ const SELF_COMMANDS = [
110
110
  },
111
111
 
112
112
  // ── Session ──
113
+ // NOTE: Bare "status", "stats" etc. are NOT intercepted — they may be
114
+ // real shell commands. Use /status, /stats, shmakk status, etc. instead.
113
115
  {
114
- patterns: [/^(?:shmakk\s+)?status$/i],
116
+ patterns: [/^status$/i],
115
117
  action: 'status',
116
118
  },
117
119
  {
118
- patterns: [/^(?:show\s+)?stats$/i, /^session\s+stats$/i],
120
+ patterns: [/^stats$/i, /^session\s+stats$/i],
119
121
  action: 'stats',
120
122
  },
121
123
  {
@@ -164,8 +166,8 @@ const SELF_COMMANDS = [
164
166
  },
165
167
  {
166
168
  patterns: [
167
- /^(?:show\s+)?last\s+sessions?$/i,
168
- /^recent\s+sessions?$/i,
169
+ /^(?:show\s+)?(?:last\s+|recent\s+)?sessions?$/i,
170
+ /^sessions?$/i,
169
171
  ],
170
172
  action: 'last-sessions',
171
173
  },
@@ -320,6 +322,22 @@ const SELF_COMMANDS = [
320
322
  action: 'disable-yes-files',
321
323
  },
322
324
 
325
+ // ── Notify ──
326
+ {
327
+ patterns: [
328
+ /^(?:enable|turn\s+on)\s+notify$/i,
329
+ /^notify\s+on$/i,
330
+ ],
331
+ action: 'enable-notify',
332
+ },
333
+ {
334
+ patterns: [
335
+ /^(?:disable|turn\s+off|no)\s+notify$/i,
336
+ /^notify\s+off$/i,
337
+ ],
338
+ action: 'disable-notify',
339
+ },
340
+
323
341
  // ── Colors ──
324
342
  {
325
343
  patterns: [
@@ -399,20 +417,65 @@ const SELF_COMMANDS = [
399
417
  },
400
418
  ];
401
419
 
420
+ // Self-command prefixes accepted by the shell:
421
+ // /cmd — e.g. /status, /sessions, /compact
422
+ // shmakk cmd — e.g. shmakk status, shmakk show sessions
423
+ // Bare words like "status" are NOT intercepted (they go to the shell).
424
+ const SELF_PREFIX_RE = /^\/(.+)$/;
425
+ const SHMAKK_PREFIX_RE = /^shmakk\s+(.+)$/i;
426
+
427
+ function hasSelfCommandPrefix(input) {
428
+ const text = String(input || '').trim();
429
+ return SELF_PREFIX_RE.test(text) || SHMAKK_PREFIX_RE.test(text);
430
+ }
431
+
432
+ function stripSelfCommandPrefix(input) {
433
+ const text = String(input || '').trim();
434
+ let m = SELF_PREFIX_RE.exec(text);
435
+ if (m) return m[1].trim();
436
+ m = SHMAKK_PREFIX_RE.exec(text);
437
+ if (m) return m[1].trim();
438
+ return text;
439
+ }
440
+
402
441
  function matchSelfCommand(input) {
403
442
  const text = String(input || '').trim();
404
443
  if (!text) return { matched: false };
405
444
 
406
- for (const entry of SELF_COMMANDS) {
407
- for (const pattern of entry.patterns) {
408
- const m = pattern.exec(text);
409
- if (m) {
410
- return {
411
- matched: true,
412
- action: entry.action,
413
- arg: entry.needsArg && m[1] ? m[1].trim() : null,
414
- confirm: !!entry.confirm,
415
- };
445
+ // Try matching with prefix stripped first (for /status, shmakk status, etc.)
446
+ const stripped = stripSelfCommandPrefix(text);
447
+ if (stripped !== text) {
448
+ for (const entry of SELF_COMMANDS) {
449
+ for (const pattern of entry.patterns) {
450
+ const m = pattern.exec(stripped);
451
+ if (m) {
452
+ return {
453
+ matched: true,
454
+ action: entry.action,
455
+ arg: entry.needsArg && m[1] ? m[1].trim() : null,
456
+ confirm: !!entry.confirm,
457
+ };
458
+ }
459
+ }
460
+ }
461
+ return { matched: false };
462
+ }
463
+
464
+ // Multi-word natural-language commands (no prefix needed).
465
+ // Single bare words are NOT matched — they could be real shell commands.
466
+ const wordCount = text.split(/\s+/).length;
467
+ if (wordCount >= 2) {
468
+ for (const entry of SELF_COMMANDS) {
469
+ for (const pattern of entry.patterns) {
470
+ const m = pattern.exec(text);
471
+ if (m) {
472
+ return {
473
+ matched: true,
474
+ action: entry.action,
475
+ arg: entry.needsArg && m[1] ? m[1].trim() : null,
476
+ confirm: !!entry.confirm,
477
+ };
478
+ }
416
479
  }
417
480
  }
418
481
  }
@@ -422,6 +485,9 @@ function matchSelfCommand(input) {
422
485
 
423
486
  // ctx is optional: { opts, HELP, setColors }
424
487
  function executeSelfCommand(match, write, ctx = {}) {
488
+ // Update terminal tab title so self-command activity is visible from other tabs
489
+ const label = match.action.replace(/-/g, ' ');
490
+ write(`\x1b]0;${label} — shmakk\x07`);
425
491
  const ctl = require('./control');
426
492
  const opts = ctx.opts || {};
427
493
 
@@ -429,7 +495,7 @@ function executeSelfCommand(match, write, ctx = {}) {
429
495
 
430
496
  // ── Help ──
431
497
  case 'show-help': {
432
- const helpText = ctx.HELP || '[shmakk] help text not available';
498
+ const helpText = ctx.HELP_SESSION_SUMMARY || ctx.HELP_SUMMARY || ctx.HELP || '[shmakk] help text not available';
433
499
  write(helpText.replace(/\n/g, '\r\n'));
434
500
  break;
435
501
  }
@@ -803,6 +869,18 @@ function executeSelfCommand(match, write, ctx = {}) {
803
869
  break;
804
870
  }
805
871
 
872
+ // ── Notify ──
873
+ case 'enable-notify': {
874
+ if (ctx.opts) ctx.opts.notify = true;
875
+ write('[shmakk] desktop notifications enabled\r\n');
876
+ break;
877
+ }
878
+ case 'disable-notify': {
879
+ if (ctx.opts) ctx.opts.notify = false;
880
+ write('[shmakk] desktop notifications disabled\r\n');
881
+ break;
882
+ }
883
+
806
884
  // ── Colors ──
807
885
  case 'enable-colors': {
808
886
  if (ctx.opts) ctx.opts.colors = true;
@@ -858,6 +936,8 @@ function executeSelfCommand(match, write, ctx = {}) {
858
936
  default:
859
937
  write(`[shmakk] unknown self-command: ${match.action}\r\n`);
860
938
  }
939
+ // Clear terminal title — shell will restore normal title on next prompt
940
+ write('\x1b]0;\x07');
861
941
  }
862
942
 
863
- module.exports = { matchSelfCommand, executeSelfCommand, SELF_COMMANDS };
943
+ module.exports = { matchSelfCommand, executeSelfCommand, hasSelfCommandPrefix, stripSelfCommandPrefix, SELF_COMMANDS };
package/src/session.js CHANGED
@@ -20,7 +20,7 @@ const { runTeam, looksMultiDomain } = require('./team');
20
20
  const { addPlanTasks, markTaskComplete, markTaskSkipped } = require('./task-file');
21
21
  const { captureGitSha, runPostPlanReview } = require('./code-reviewer');
22
22
  const sessionSearch = require('./session-search');
23
- const { HELP } = require('./cli');
23
+ const { HELP, HELP_SUMMARY, HELP_SESSION_SUMMARY } = require('./cli');
24
24
  const audit = require('./audit');
25
25
  const { setMaxListeners } = require('events');
26
26
 
@@ -163,13 +163,11 @@ function makeToolConfirm(opts, ask, out, getAbort) {
163
163
  }
164
164
 
165
165
  async function runOneSession(opts, registerSession) {
166
- const session = startSession({ debug: opts.debug, voiceEnabled: !!opts.voice });
166
+ const session = startSession({ debug: opts.debug, voiceEnabled: !!opts.voice, shellOverride: opts.shell });
167
167
  let colorsEnabled = opts.colors !== false;
168
168
  let markdownEnabled = opts.markdown !== false;
169
169
  const out = (s) => session.stdoutWrite(colorsEnabled ? s : stripAnsi(s));
170
- const ask = makePrompter(session, out, {
171
- onNotify: opts.notify ? (summary, body) => notify(summary, body) : null,
172
- });
170
+ const ask = makePrompter(session, out, opts);
173
171
  const glossary = loadGlossary();
174
172
  // Workspace tracking: explicit --workspace is "pinned"; otherwise cwd
175
173
  // floats with the inner shell's `cd`. When both pinned and cwd differ,
@@ -373,6 +371,8 @@ async function runOneSession(opts, registerSession) {
373
371
  executeSelfCommand(voiceSelfCmd, out, {
374
372
  opts,
375
373
  HELP,
374
+ HELP_SUMMARY,
375
+ HELP_SESSION_SUMMARY,
376
376
  setColors: (v) => { colorsEnabled = v; },
377
377
  });
378
378
  return;
@@ -672,11 +672,20 @@ async function runOneSession(opts, registerSession) {
672
672
  executeSelfCommand(selfCmd, out, {
673
673
  opts,
674
674
  HELP,
675
+ HELP_SUMMARY,
676
+ HELP_SESSION_SUMMARY,
675
677
  setColors: (v) => { colorsEnabled = v; },
676
678
  });
677
679
  session.childWrite('\r');
678
680
  return;
679
681
  }
682
+ // /-prefixed and "shmakk ..." commands that didn't match a known
683
+ // self-command are invalid shmakk commands. Don't send them to the
684
+ // correction engine — the user was explicitly addressing shmakk.
685
+ if (/^\//.test(lastCmd) || /^shmakk\s/i.test(lastCmd)) {
686
+ flushPending();
687
+ return;
688
+ }
680
689
  }
681
690
 
682
691
  // Determine the command to feed forward.
package/src/shell.js CHANGED
@@ -1,32 +1,52 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
- function detectShell() {
4
+ // Map shell name to the preferred executable path.
5
+ // Resolve's dctl paths on Arch-like systems can put bash in /usr/bin instead of /bin.
6
+ // Map shell name to candidate executable paths.
7
+ // Ordered by likelihood on the current platform; first existing path wins.
8
+ const SHELL_PATH_CANDIDATES = {
9
+ fish: ['/usr/bin/fish', '/opt/homebrew/bin/fish', '/usr/local/bin/fish', '/bin/fish'],
10
+ bash: ['/usr/bin/bash', '/bin/bash', '/opt/homebrew/bin/bash', '/usr/local/bin/bash'],
11
+ zsh: ['/usr/bin/zsh', '/bin/zsh', '/opt/homebrew/bin/zsh', '/usr/local/bin/zsh'],
12
+ };
13
+
14
+ function shellPath(name) {
15
+ // Name given explicitly (--shell flag): try known paths first, then PATH.
16
+ const candidates = SHELL_PATH_CANDIDATES[name];
17
+ if (candidates) {
18
+ for (const candidate of candidates) {
19
+ if (fs.existsSync(candidate)) return candidate;
20
+ }
21
+ }
22
+
23
+ // Fall back to PATH search for the requested shell.
24
+ const { execSync } = require('child_process');
25
+ try {
26
+ const p = execSync(`command -v ${name}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
27
+ if (p && fs.existsSync(p)) return p;
28
+ } catch {}
29
+
30
+ return null;
31
+ }
32
+
33
+ function detectShell(shellOverride) {
34
+ // Explicit --shell flag overrides everything.
35
+ if (shellOverride) {
36
+ const p = shellPath(shellOverride);
37
+ if (p) return { path: p, name: shellOverride };
38
+ process.stderr.write(`[shmakk] shell "${shellOverride}" not found, falling back to default\n`);
39
+ }
40
+
5
41
  const env = process.env.SHELL;
6
42
  if (env && fs.existsSync(env)) {
7
43
  return { path: env, name: path.basename(env) };
8
44
  }
9
- const fallbacks = ['/bin/bash', '/usr/bin/bash', '/bin/sh'];
45
+ const fallbacks = ['/bin/bash', '/usr/bin/bash', '/opt/homebrew/bin/bash', '/usr/local/bin/bash', '/bin/sh'];
10
46
  for (const f of fallbacks) {
11
47
  if (fs.existsSync(f)) return { path: f, name: path.basename(f) };
12
48
  }
13
49
  return { path: '/bin/sh', name: 'sh' };
14
50
  }
15
51
 
16
- function shellArgs(name) {
17
- // Login + interactive so the user's normal init runs.
18
- // We deliberately keep this minimal: do NOT inject rc files,
19
- // do NOT alter prompt. Phase 2 will add hooks for command metadata.
20
- switch (name) {
21
- case 'fish':
22
- return ['-i', '-l'];
23
- case 'zsh':
24
- return ['-i', '-l'];
25
- case 'bash':
26
- return ['-i', '-l'];
27
- default:
28
- return ['-i'];
29
- }
30
- }
31
-
32
- module.exports = { detectShell, shellArgs };
52
+ module.exports = { detectShell, shellPath };