@fitlab-ai/agent-infra 0.5.9 → 0.5.10

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 (41) hide show
  1. package/README.md +200 -8
  2. package/README.zh-CN.md +176 -8
  3. package/bin/cli.js +2 -2
  4. package/lib/init.js +18 -4
  5. package/lib/sandbox/commands/create.js +467 -240
  6. package/lib/sandbox/commands/enter.js +59 -26
  7. package/lib/sandbox/commands/ls.js +37 -6
  8. package/lib/sandbox/commands/rebuild.js +31 -15
  9. package/lib/sandbox/commands/refresh.js +119 -0
  10. package/lib/sandbox/commands/rm.js +59 -11
  11. package/lib/sandbox/commands/vm.js +50 -6
  12. package/lib/sandbox/config.js +9 -5
  13. package/lib/sandbox/constants.js +15 -3
  14. package/lib/sandbox/credentials.js +520 -0
  15. package/lib/sandbox/dotfiles.js +189 -0
  16. package/lib/sandbox/engine.js +135 -192
  17. package/lib/sandbox/engines/colima.js +79 -0
  18. package/lib/sandbox/engines/docker-desktop.js +34 -0
  19. package/lib/sandbox/engines/index.js +27 -0
  20. package/lib/sandbox/engines/native.js +112 -0
  21. package/lib/sandbox/engines/orbstack.js +76 -0
  22. package/lib/sandbox/engines/selinux.js +60 -0
  23. package/lib/sandbox/engines/wsl2-paths.js +59 -0
  24. package/lib/sandbox/engines/wsl2.js +72 -0
  25. package/lib/sandbox/index.js +10 -1
  26. package/lib/sandbox/runtimes/ai-tools.dockerfile +14 -1
  27. package/lib/sandbox/runtimes/base.dockerfile +116 -1
  28. package/lib/sandbox/shell.js +53 -2
  29. package/lib/sandbox/tools.js +5 -5
  30. package/package.json +6 -4
  31. package/templates/.agents/rules/create-issue.github.en.md +2 -4
  32. package/templates/.agents/rules/create-issue.github.zh-CN.md +2 -4
  33. package/templates/.agents/rules/issue-pr-commands.github.en.md +29 -0
  34. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +29 -0
  35. package/templates/.agents/scripts/{platform-adapters/find-existing-task.github.js → find-existing-task.js} +22 -79
  36. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +26 -41
  37. package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
  38. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  39. package/templates/.agents/skills/import-issue/SKILL.en.md +6 -8
  40. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +6 -8
  41. package/templates/.agents/scripts/platform-adapters/find-existing-task.js +0 -5
@@ -0,0 +1,27 @@
1
+ import { colimaAdapter } from './colima.js';
2
+ import { dockerDesktopAdapter } from './docker-desktop.js';
3
+ import { nativeAdapter } from './native.js';
4
+ import { orbstackAdapter } from './orbstack.js';
5
+ import { wsl2Adapter } from './wsl2.js';
6
+
7
+ export const ADAPTERS = Object.freeze({
8
+ colima: colimaAdapter,
9
+ orbstack: orbstackAdapter,
10
+ 'docker-desktop': dockerDesktopAdapter,
11
+ native: nativeAdapter,
12
+ wsl2: wsl2Adapter
13
+ });
14
+
15
+ export function getAdapter(engineId) {
16
+ const adapter = ADAPTERS[engineId];
17
+ if (!adapter) {
18
+ throw new Error(`No adapter registered for engine '${engineId}'`);
19
+ }
20
+ return adapter;
21
+ }
22
+
23
+ export function enginesForPlatform(platformName) {
24
+ return Object.values(ADAPTERS)
25
+ .filter((adapter) => adapter.supportedPlatforms.includes(platformName))
26
+ .map((adapter) => adapter.id);
27
+ }
@@ -0,0 +1,112 @@
1
+ export function isRootlessDocker({ env = process.env, runSafe } = {}) {
2
+ const dockerHost = env.DOCKER_HOST ?? '';
3
+ if (dockerHost.startsWith('unix:///run/user/')) {
4
+ return true;
5
+ }
6
+
7
+ if (!runSafe) {
8
+ return false;
9
+ }
10
+
11
+ try {
12
+ const securityOptions = runSafe('docker', ['info', '--format', '{{.SecurityOptions}}']);
13
+ return securityOptions.includes('rootless');
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export function resolveBuildUid({ engine, runFn, runSafeFn, env = process.env }) {
20
+ const runSafe = runSafeFn
21
+ ? (cmd, args) => runSafeFn(engine, cmd, args)
22
+ : undefined;
23
+
24
+ if (engine === 'native' && isRootlessDocker({ env, runSafe })) {
25
+ return { uid: '0', gid: '0' };
26
+ }
27
+
28
+ return {
29
+ uid: runFn(engine, 'id', ['-u']),
30
+ gid: runFn(engine, 'id', ['-g'])
31
+ };
32
+ }
33
+
34
+ export const nativeAdapter = {
35
+ id: 'native',
36
+ displayName: 'native Docker',
37
+ supportedPlatforms: ['linux', 'win32'],
38
+ dockerContext: null,
39
+ managed: false,
40
+ canApplyResources: 'never',
41
+
42
+ defaultResources() {
43
+ return null;
44
+ },
45
+
46
+ async ensure(_config, _onMessage, { runOk, runSafe }) {
47
+ if (!runOk('which', ['docker'])) {
48
+ throw new Error([
49
+ 'Docker is not installed.',
50
+ 'Install Docker Engine for your distribution: https://docs.docker.com/engine/install/',
51
+ 'Then start the daemon with: sudo systemctl enable --now docker',
52
+ 'If you want to run Docker without sudo, add your user to the docker group: sudo usermod -aG docker $USER'
53
+ ].join('\n'));
54
+ }
55
+
56
+ if (runOk('docker', ['info'])) {
57
+ return false;
58
+ }
59
+
60
+ const serverVersion = runSafe('docker', ['version', '--format', '{{.Server.Version}}']);
61
+ const rootless = isRootlessDocker({ runSafe });
62
+ if (!serverVersion) {
63
+ if (rootless) {
64
+ throw new Error([
65
+ 'Docker rootless daemon is not running or is unreachable.',
66
+ 'Start it with: systemctl --user start docker',
67
+ 'Enable it on login with: systemctl --user enable docker',
68
+ 'Verify DOCKER_HOST points at $XDG_RUNTIME_DIR/docker.sock.',
69
+ 'Then retry: ai sandbox create <branch>'
70
+ ].join('\n'));
71
+ }
72
+
73
+ throw new Error([
74
+ 'Docker daemon is not running or is unreachable.',
75
+ 'Start it with: sudo systemctl start docker',
76
+ 'Enable it on boot with: sudo systemctl enable docker',
77
+ 'If you use rootless or remote Docker, verify DOCKER_HOST points at a reachable socket.',
78
+ 'For rootless Docker, export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock and run: systemctl --user start docker',
79
+ 'Then retry: ai sandbox create <branch>'
80
+ ].join('\n'));
81
+ }
82
+
83
+ if (rootless) {
84
+ throw new Error([
85
+ 'docker info failed even though the rootless daemon responded to version.',
86
+ 'This usually means DOCKER_HOST or XDG_RUNTIME_DIR is misconfigured.',
87
+ 'Verify DOCKER_HOST matches $XDG_RUNTIME_DIR/docker.sock.',
88
+ 'Check the daemon with: systemctl --user status docker',
89
+ 'Then retry: ai sandbox create <branch>'
90
+ ].join('\n'));
91
+ }
92
+
93
+ throw new Error([
94
+ 'Docker is installed, but the current user may lack permission to use the daemon.',
95
+ 'Add your user to the docker group: sudo usermod -aG docker $USER',
96
+ 'Open a new login shell or run: newgrp docker'
97
+ ].join('\n'));
98
+ },
99
+
100
+ syncResources(config, onMessage) {
101
+ if (!config.hasUserVmConfig?.(config.userVm)) {
102
+ return;
103
+ }
104
+
105
+ onMessage?.(
106
+ 'Warning: Linux native Docker has no managed VM; sandbox.vm.* is not applicable. '
107
+ + 'Use docker run --cpus / --memory per container or host cgroups.'
108
+ );
109
+ }
110
+ };
111
+
112
+ export default nativeAdapter;
@@ -0,0 +1,76 @@
1
+ export const orbstackAdapter = {
2
+ id: 'orbstack',
3
+ displayName: 'OrbStack',
4
+ supportedPlatforms: ['darwin'],
5
+ dockerContext: 'orbstack',
6
+ managed: true,
7
+ canApplyResources: 'hot',
8
+
9
+ defaultResources() {
10
+ return null;
11
+ },
12
+
13
+ async ensure(_config, onMessage, { runOk, runVerbose }) {
14
+ let started = false;
15
+
16
+ if (!runOk('which', ['orb'])) {
17
+ onMessage?.('Installing OrbStack via Homebrew...');
18
+ runVerbose('brew', ['install', '--cask', 'orbstack']);
19
+ }
20
+
21
+ if (!runOk('docker', ['info'])) {
22
+ onMessage?.('Starting OrbStack...');
23
+ runVerbose('orb', ['start']);
24
+ started = true;
25
+ }
26
+
27
+ if (!runOk('docker', ['info'])) {
28
+ throw new Error('Docker daemon is not available after starting OrbStack');
29
+ }
30
+
31
+ return started;
32
+ },
33
+
34
+ startVm(_config, _onMessage, { runOk, runVerbose }) {
35
+ if (runOk('orb', ['status'])) {
36
+ return 'already-running';
37
+ }
38
+
39
+ runVerbose('orb', ['start']);
40
+ return 'started';
41
+ },
42
+
43
+ stopVm(_config, _onMessage, { run }) {
44
+ run('orb', ['stop']);
45
+ return 'stopped';
46
+ },
47
+
48
+ syncResources(config, onMessage, { runVerbose }) {
49
+ const vm = config?.vm ?? {};
50
+
51
+ if (vm.cpu != null) {
52
+ try {
53
+ runVerbose('orb', ['config', 'set', 'cpu', String(vm.cpu)]);
54
+ } catch {
55
+ onMessage?.(`Warning: failed to apply OrbStack cpu=${vm.cpu}; resource limit may not take effect.`);
56
+ }
57
+ }
58
+
59
+ if (vm.memory != null) {
60
+ try {
61
+ runVerbose('orb', ['config', 'set', 'memory_mib', String(vm.memory * 1024)]);
62
+ } catch {
63
+ onMessage?.(`Warning: failed to apply OrbStack memory=${vm.memory}GiB; resource limit may not take effect.`);
64
+ }
65
+ }
66
+
67
+ if (vm.disk != null) {
68
+ onMessage?.(
69
+ `Warning: OrbStack does not expose a fixed disk size; sandbox.vm.disk=${vm.disk} is ignored. `
70
+ + 'Manage storage via OrbStack settings GUI.'
71
+ );
72
+ }
73
+ }
74
+ };
75
+
76
+ export default orbstackAdapter;
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+
3
+ const SELINUX_ENFORCE_PATH = '/sys/fs/selinux/enforce';
4
+ const detectionCache = new WeakMap();
5
+ const VALID_DISABLE_VALUES = new Set([undefined, '', '0', '1']);
6
+
7
+ function isDisabled(env) {
8
+ return env?.AGENT_INFRA_SELINUX_DISABLE === '1';
9
+ }
10
+
11
+ function readEnforceFlag(fsImpl) {
12
+ try {
13
+ return fsImpl.readFileSync(SELINUX_ENFORCE_PATH, 'utf8').trim();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function isSelinuxEnforcing(fsImpl, platform) {
20
+ let cache = detectionCache.get(fsImpl);
21
+ if (!cache) {
22
+ cache = new Map();
23
+ detectionCache.set(fsImpl, cache);
24
+ }
25
+
26
+ if (cache.has(platform)) {
27
+ return cache.get(platform);
28
+ }
29
+
30
+ const enforcing = readEnforceFlag(fsImpl) === '1';
31
+ cache.set(platform, enforcing);
32
+ return enforcing;
33
+ }
34
+
35
+ export function selinuxLabelForMount(engine, options = {}) {
36
+ const {
37
+ fs: fsImpl = fs,
38
+ platform = process.platform,
39
+ env = process.env
40
+ } = options;
41
+
42
+ if (engine !== 'native' || platform !== 'linux') {
43
+ return null;
44
+ }
45
+ if (isDisabled(env)) {
46
+ return null;
47
+ }
48
+ if (!isSelinuxEnforcing(fsImpl, platform)) {
49
+ return null;
50
+ }
51
+
52
+ return 'z';
53
+ }
54
+
55
+ export function validateSelinuxDisableEnv(env = process.env) {
56
+ const value = env?.AGENT_INFRA_SELINUX_DISABLE;
57
+ if (!VALID_DISABLE_VALUES.has(value)) {
58
+ throw new Error('Invalid AGENT_INFRA_SELINUX_DISABLE value. Expected 1 to disable, or unset/0 for default.');
59
+ }
60
+ }
@@ -0,0 +1,59 @@
1
+ import path from 'node:path';
2
+ import { selinuxLabelForMount } from './selinux.js';
3
+
4
+ const WINDOWS_DRIVE_PATH_PATTERN = /^([A-Za-z]):[\\/](.*)$/;
5
+ const UNC_PATH_PATTERN = /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+/;
6
+
7
+ export function hostJoin(basePath, ...segments) {
8
+ return basePath.startsWith('/') ? path.posix.join(basePath, ...segments) : path.join(basePath, ...segments);
9
+ }
10
+
11
+ export function isWindowsDrivePath(value) {
12
+ return typeof value === 'string' && WINDOWS_DRIVE_PATH_PATTERN.test(value);
13
+ }
14
+
15
+ export function isUncPath(value) {
16
+ return typeof value === 'string' && UNC_PATH_PATTERN.test(value);
17
+ }
18
+
19
+ export function windowsPathToWslPath(value) {
20
+ if (typeof value !== 'string' || value.length === 0) {
21
+ return value;
22
+ }
23
+
24
+ if (isUncPath(value)) {
25
+ throw new Error(`UNC paths are not supported for WSL2 sandbox mounts: ${value}`);
26
+ }
27
+
28
+ const match = value.match(WINDOWS_DRIVE_PATH_PATTERN);
29
+ if (!match) {
30
+ return value.replace(/\\/g, '/');
31
+ }
32
+
33
+ const [, drive, rest] = match;
34
+ const normalizedRest = rest.replace(/\\/g, '/').replace(/^\/+/, '');
35
+ return `/mnt/${drive.toLowerCase()}/${normalizedRest}`;
36
+ }
37
+
38
+ export function toEnginePath(engine, value) {
39
+ if (engine !== 'wsl2') {
40
+ return value;
41
+ }
42
+
43
+ return windowsPathToWslPath(value);
44
+ }
45
+
46
+ export function volumeArg(engine, hostPath, containerPath, suffix = '', options = {}) {
47
+ const { selinux = 'shared', ...selinuxOptions } = options;
48
+ const flags = suffix.replace(/^:/, '').split(',').filter(Boolean);
49
+
50
+ if (selinux !== 'none') {
51
+ const label = selinuxLabelForMount(engine, selinuxOptions);
52
+ if (label && !flags.includes(label)) {
53
+ flags.push(label);
54
+ }
55
+ }
56
+
57
+ const composedSuffix = flags.length > 0 ? `:${flags.join(',')}` : '';
58
+ return `${toEnginePath(engine, hostPath)}:${containerPath}${composedSuffix}`;
59
+ }
@@ -0,0 +1,72 @@
1
+ function ensureWslAvailable(runOk) {
2
+ if (runOk('wsl.exe', ['--status']) || runOk('wsl.exe', ['--', 'true'])) {
3
+ return;
4
+ }
5
+
6
+ throw new Error([
7
+ 'WSL2 is required for Windows sandbox support.',
8
+ 'Install WSL2, configure a default Linux distribution, and re-run "ai sandbox create".'
9
+ ].join('\n'));
10
+ }
11
+
12
+ function ensureDockerAvailable(runOk) {
13
+ if (runOk('wsl.exe', ['--', 'docker', 'info'])) {
14
+ return;
15
+ }
16
+
17
+ throw new Error([
18
+ 'Docker is not available inside WSL2.',
19
+ 'Start Docker Desktop and enable WSL integration for your default distribution.'
20
+ ].join('\n'));
21
+ }
22
+
23
+ function wsl2BackendCheck(runOk, onMessage) {
24
+ ensureWslAvailable(runOk);
25
+ onMessage?.('Checking Docker Desktop from WSL2...');
26
+ ensureDockerAvailable(runOk);
27
+ }
28
+
29
+ export const wsl2Adapter = {
30
+ id: 'wsl2',
31
+ displayName: 'WSL2',
32
+ supportedPlatforms: ['win32'],
33
+ dockerContext: null,
34
+ managed: true,
35
+ canApplyResources: 'never',
36
+
37
+ defaultResources() {
38
+ return null;
39
+ },
40
+
41
+ async ensure(config, onMessage, { runOk }) {
42
+ wsl2BackendCheck(runOk, onMessage);
43
+ void config;
44
+ return false;
45
+ },
46
+
47
+ startVm(config, onMessage, { runOk }) {
48
+ wsl2BackendCheck(runOk, onMessage);
49
+ void config;
50
+ return 'already-running';
51
+ },
52
+
53
+ stopVm() {
54
+ throw new Error(
55
+ 'Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.'
56
+ );
57
+ },
58
+
59
+ syncResources(config, onMessage) {
60
+ if (!config.hasUserVmConfig?.(config.userVm)) {
61
+ return;
62
+ }
63
+
64
+ onMessage?.(
65
+ 'Warning: Docker Desktop manages CPU/memory/disk via Settings -> Resources. '
66
+ + 'sandbox.vm.* values and --cpu/--memory flags are not applied for this engine. '
67
+ + 'Please configure resources in Docker Desktop GUI to match.'
68
+ );
69
+ }
70
+ };
71
+
72
+ export default wsl2Adapter;
@@ -3,9 +3,10 @@ const USAGE = `Usage: ai sandbox <command> [options]
3
3
  Commands:
4
4
  create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
5
  exec <branch> [cmd...] Enter sandbox or run a command
6
+ refresh Sync host Claude Code credentials to all sandbox copies
6
7
  ls List sandboxes for the current project
7
8
  rm <branch> [--all] Remove a sandbox or all sandboxes
8
- vm status|start|stop Manage the sandbox VM (macOS only)
9
+ vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
9
10
  rebuild [--quiet] Rebuild the sandbox image
10
11
 
11
12
  Run 'ai sandbox <command> --help' for details.`;
@@ -38,6 +39,14 @@ export async function runSandbox(args) {
38
39
  }
39
40
  break;
40
41
  }
42
+ case 'refresh': {
43
+ const { refresh } = await import('./commands/refresh.js');
44
+ const exitCode = await refresh(rest);
45
+ if (typeof exitCode === 'number' && exitCode !== 0) {
46
+ process.exitCode = exitCode;
47
+ }
48
+ break;
49
+ }
41
50
  case 'ls': {
42
51
  const { ls } = await import('./commands/ls.js');
43
52
  ls(rest);
@@ -7,7 +7,10 @@ RUN if [ -z "${AI_TOOL_PACKAGES}" ]; then \
7
7
  echo "AI_TOOL_PACKAGES build arg is required"; \
8
8
  exit 1; \
9
9
  fi && \
10
- npm install -g ${AI_TOOL_PACKAGES}
10
+ set -e && \
11
+ for pkg in ${AI_TOOL_PACKAGES}; do \
12
+ npm install -g "$pkg"; \
13
+ done
11
14
 
12
15
  RUN npm install -g pyright
13
16
 
@@ -15,6 +18,16 @@ RUN mkdir -p /home/devuser/.local/share /home/devuser/.local/state
15
18
 
16
19
  RUN git config --global --add safe.directory /workspace
17
20
 
21
+ # Host shell-config is bind-mounted as a directory at this path; the four files
22
+ # inside (.gitconfig, .gitignore_global, .stCommitMsg, .bash_aliases) are exposed
23
+ # via symlinks in $HOME. Directory binds avoid the //deleted invalidation that
24
+ # single-file binds suffer when their source is rewritten on macOS/virtiofs.
25
+ RUN mkdir -p /home/devuser/.host-shell-config && \
26
+ ln -sf .host-shell-config/.gitconfig /home/devuser/.gitconfig && \
27
+ ln -sf .host-shell-config/.gitignore_global /home/devuser/.gitignore_global && \
28
+ ln -sf .host-shell-config/.stCommitMsg /home/devuser/.stCommitMsg && \
29
+ ln -sf .host-shell-config/.bash_aliases /home/devuser/.bash_aliases
30
+
18
31
  RUN echo 'export NPM_CONFIG_PREFIX=/home/devuser/.npm-global' >> /home/devuser/.bashrc && \
19
32
  echo 'export PATH="/home/devuser/.npm-global/bin:${PATH}"' >> /home/devuser/.bashrc && \
20
33
  echo 'export GIT_CONFIG_GLOBAL=/home/devuser/.gitconfig' >> /home/devuser/.bashrc && \
@@ -18,7 +18,7 @@ RUN if [ "${HOST_UID}" = "0" ]; then \
18
18
  fi
19
19
 
20
20
  RUN apt-get update && apt-get install -y \
21
- curl wget git vim file \
21
+ curl wget git vim file jq \
22
22
  build-essential ca-certificates gnupg lsb-release \
23
23
  libevent-core-2.1-7 libncursesw6 libtinfo6 \
24
24
  pkg-config bison libevent-dev libncurses-dev \
@@ -53,8 +53,123 @@ RUN printf '%s\n' \
53
53
  "set -as terminal-features 'xterm*:extkeys'" \
54
54
  "set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
55
55
  'set -g mouse on' \
56
+ 'set -g status-interval 1' \
57
+ 'set -g status-right-length 80' \
58
+ "set -g status-right '#(/usr/local/bin/cc-token-status) | %H:%M'" \
56
59
  > /etc/tmux.conf
57
60
 
61
+ RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/cc-token-status
62
+ #!/bin/sh
63
+ set -eu
64
+
65
+ CRED_FILE="/home/devuser/.claude/.credentials.json"
66
+ [ -r "$CRED_FILE" ] || exit 0
67
+
68
+ EXPIRES_MS=$(jq -r '(.claudeAiOauth.expiresAt // .expiresAt) // empty' "$CRED_FILE" 2>/dev/null || true)
69
+ case "$EXPIRES_MS" in
70
+ ''|*[!0-9]*) exit 0 ;;
71
+ esac
72
+
73
+ NOW_MS=$(($(date +%s) * 1000))
74
+ DIFF_MS=$((EXPIRES_MS - NOW_MS))
75
+ DIFF_S=$((DIFF_MS / 1000))
76
+
77
+ DIM='#[fg=colour245]'
78
+ YELLOW='#[fg=yellow]'
79
+ YELLOW_BOLD='#[fg=yellow,bold]'
80
+ RED_BOLD='#[fg=red,bold]'
81
+ RED_REV='#[fg=red,reverse]'
82
+ RESET='#[default]'
83
+
84
+ if [ "$DIFF_S" -le 0 ]; then
85
+ ELAPSED=$(( -DIFF_S ))
86
+ M=$((ELAPSED / 60))
87
+ printf '%sClaude Code auth EXPIRED %dm ago%s' "$RED_REV" "$M" "$RESET"
88
+ elif [ "$DIFF_S" -lt 60 ]; then
89
+ printf '%sClaude Code auth expires in %ds%s' "$RED_BOLD" "$DIFF_S" "$RESET"
90
+ elif [ "$DIFF_S" -lt 300 ]; then
91
+ M=$((DIFF_S / 60))
92
+ S=$((DIFF_S % 60))
93
+ printf '%sClaude Code auth expires in %dm %ds%s' "$RED_BOLD" "$M" "$S" "$RESET"
94
+ elif [ "$DIFF_S" -lt 1800 ]; then
95
+ M=$((DIFF_S / 60))
96
+ printf '%sClaude Code auth expires in %dm%s' "$YELLOW_BOLD" "$M" "$RESET"
97
+ elif [ "$DIFF_S" -lt 3600 ]; then
98
+ M=$((DIFF_S / 60))
99
+ printf '%sClaude Code auth expires in %dm%s' "$YELLOW" "$M" "$RESET"
100
+ else
101
+ TOTAL_M=$((DIFF_S / 60))
102
+ H=$((TOTAL_M / 60))
103
+ M=$((TOTAL_M % 60))
104
+ printf '%sClaude Code auth expires in %dh %dm%s' "$DIM" "$H" "$M" "$RESET"
105
+ fi
106
+ SCRIPT
107
+
108
+ RUN cat > /usr/local/bin/sandbox-dotfiles-link <<'SCRIPT' && chmod +x /usr/local/bin/sandbox-dotfiles-link
109
+ #!/bin/sh
110
+ # Mirror /dotfiles/ tree as symlinks under $HOME/, overwriting any image-baked
111
+ # defaults. Future preferences only need to land in the host directory.
112
+ set -eu
113
+
114
+ DOTFILES_SRC=/dotfiles
115
+ [ -d "$DOTFILES_SRC" ] || exit 0
116
+
117
+ cd "$DOTFILES_SRC"
118
+ find . -type f -print | while IFS= read -r rel; do
119
+ rel=${rel#./}
120
+ target="$HOME/$rel"
121
+ case "$rel" in
122
+ .ssh|.ssh/*|\
123
+ .gnupg|.gnupg/*|\
124
+ .claude|.claude/*|\
125
+ .codex|.codex/*|\
126
+ .gemini|.gemini/*|\
127
+ .config/opencode|.config/opencode/*|\
128
+ .local/share/opencode|.local/share/opencode/*|\
129
+ .host-shell-config|.host-shell-config/*|\
130
+ .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases)
131
+ continue ;;
132
+ esac
133
+
134
+ mkdir -p "$(dirname "$target")"
135
+ if [ -d "$target" ] && [ ! -L "$target" ]; then
136
+ printf 'sandbox-dotfiles-link: skipping %s (existing directory; use nested path like %s/<file> instead)\n' "$target" "$rel" >&2
137
+ continue
138
+ fi
139
+
140
+ ln -sfn "$DOTFILES_SRC/$rel" "$target" 2>/dev/null \
141
+ || printf 'sandbox-dotfiles-link: failed to link %s\n' "$target" >&2
142
+ done
143
+ SCRIPT
144
+
145
+ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bin/sandbox-tmux-entry
146
+ #!/bin/sh
147
+ set -eu
148
+
149
+ sandbox-dotfiles-link >/dev/null || true
150
+
151
+ SESSION=work
152
+
153
+ if ! command -v tmux >/dev/null 2>&1; then
154
+ exec bash
155
+ fi
156
+
157
+ if ! tmux has-session -t "$SESSION" 2>/dev/null; then
158
+ exec tmux new-session -s "$SESSION"
159
+ fi
160
+
161
+ tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
162
+ while read -r name attached; do
163
+ [ "$name" = "$SESSION" ] && continue
164
+ case "$name" in
165
+ ''|*[!0-9]*) continue ;;
166
+ esac
167
+ [ "$attached" = "0" ] && tmux kill-session -t "$name" 2>/dev/null || true
168
+ done
169
+
170
+ exec tmux new-session -t "$SESSION"
171
+ SCRIPT
172
+
58
173
  ENV LANG=en_US.UTF-8
59
174
  ENV LC_ALL=en_US.UTF-8
60
175
  ENV TERM=xterm-256color
@@ -106,9 +106,9 @@ export function runVerbose(cmd, args, opts = {}) {
106
106
 
107
107
  if (result.status !== 0) {
108
108
  if (result.signal === 'SIGTERM') {
109
- throw new Error(`Command timed out after ${opts.timeout ?? DEFAULT_TIMEOUT_MS}ms: ${cmd} ${args.join(' ')}`);
109
+ throw new Error(`Command timed out after ${opts.timeout ?? DEFAULT_TIMEOUT_MS}ms: ${cmd}`);
110
110
  }
111
- throw new Error(`Command failed with exit code ${result.status}: ${cmd} ${args.join(' ')}`);
111
+ throw new Error(`Command failed with exit code ${result.status}: ${cmd}`);
112
112
  }
113
113
  }
114
114
 
@@ -118,5 +118,56 @@ export function runSafe(cmd, args, opts = {}) {
118
118
  ...normalizeOptions(opts, ['pipe', 'pipe', 'pipe']),
119
119
  encoding: 'utf8',
120
120
  }));
121
+ if (result.status !== 0 && result.stderr) {
122
+ process.stderr.write(result.stderr);
123
+ }
121
124
  return (result.stdout ?? '').trim();
122
125
  }
126
+
127
+ export function commandForEngine(engine, cmd, args = []) {
128
+ if (engine === 'wsl2') {
129
+ const resolvedWrapper = resolveCommand('wsl.exe');
130
+ return { cmd: resolvedWrapper, args: ['--', cmd, ...args] };
131
+ }
132
+
133
+ return { cmd, args };
134
+ }
135
+
136
+ export function runEngine(engine, cmd, args, opts = {}) {
137
+ const command = commandForEngine(engine, cmd, args);
138
+ return run(command.cmd, command.args, opts);
139
+ }
140
+
141
+ export function execEngine(engine, cmd, args, opts = {}) {
142
+ const command = commandForEngine(engine, cmd, args);
143
+ return execFileSync(command.cmd, command.args, opts);
144
+ }
145
+
146
+ export function runOkEngine(engine, cmd, args, opts = {}) {
147
+ const command = commandForEngine(engine, cmd, args);
148
+ return runOk(command.cmd, command.args, opts);
149
+ }
150
+
151
+ export function runSafeEngine(engine, cmd, args, opts = {}) {
152
+ const command = commandForEngine(engine, cmd, args);
153
+ return runSafe(command.cmd, command.args, opts);
154
+ }
155
+
156
+ export function runVerboseEngine(engine, cmd, args, opts = {}) {
157
+ const command = commandForEngine(engine, cmd, args);
158
+ return runVerbose(command.cmd, command.args, opts);
159
+ }
160
+
161
+ export function runInteractiveEngine(engine, cmd, args, opts = {}) {
162
+ const command = commandForEngine(engine, cmd, args);
163
+ return runInteractive(command.cmd, command.args, opts);
164
+ }
165
+
166
+ export function runProbe(cmd, args, opts = {}) {
167
+ const { spawnFn = spawnSync, ...commandOpts } = opts;
168
+ const resolved = resolveCommand(cmd);
169
+ return spawnFn(resolved, args, commandOptions(resolved, normalizeOptions(
170
+ { encoding: 'utf8', ...commandOpts },
171
+ commandOpts.stdio ?? ['pipe', 'pipe', 'pipe']
172
+ )));
173
+ }