@fitlab-ai/agent-infra 0.6.4 → 0.6.5

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.
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { shareBranchDir, shareCommonDir } from "./constants.js";
4
+ const DOTFILES_README = `# User-level dotfiles channel
5
+
6
+ This directory is mounted **read-only** into every sandbox container at
7
+ \`/dotfiles\`. On entry, \`sandbox-dotfiles-link\` mirrors every file here as a
8
+ symlink under \`$HOME\` (e.g. \`.tmux.conf\` -> \`/home/devuser/.tmux.conf\`),
9
+ overriding image defaults so your editor, shell, and tool preferences follow
10
+ you across \`ai sandbox destroy + create\`.
11
+
12
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#user-level-dotfiles-channel
13
+
14
+ Common usage - drop files or symlinks here:
15
+
16
+ \`\`\`sh
17
+ # Real files
18
+ echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
19
+
20
+ # Symlinks to live host paths
21
+ ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
22
+ ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
23
+ \`\`\`
24
+
25
+ > Do **not** put secrets here. Use the dedicated SSH / credential mounts.
26
+
27
+ If you delete this file, the next \`ai sandbox create\` will re-create it
28
+ verbatim. To stop seeing it, edit or empty the file in place - the scaffold
29
+ only writes \`README.md\` when it is missing, never when it already exists.
30
+
31
+ ---
32
+
33
+ # 用户级 dotfiles 通道
34
+
35
+ 该目录被以**只读**方式挂载到每个 sandbox 容器的 \`/dotfiles\`。容器启动时,
36
+ \`sandbox-dotfiles-link\` 会把这里的每个文件 \`ln -sfn\` 到 \`$HOME\` 对应路径
37
+ (例如 \`.tmux.conf -> /home/devuser/.tmux.conf\`),覆盖镜像默认值,让你的编辑器、
38
+ shell、工具偏好跨 \`ai sandbox destroy + create\` 持久存在。
39
+
40
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#用户级-dotfiles-通道
41
+
42
+ 常见用法:把文件或符号链接放进来:
43
+
44
+ \`\`\`sh
45
+ # 直接放文件
46
+ echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
47
+
48
+ # 用符号链接指向 host 实际文件
49
+ ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
50
+ ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
51
+ \`\`\`
52
+
53
+ > **不要**在此放任何凭证。SSH / 凭证请使用专用挂载通道。
54
+
55
+ 如果你删除该文件,下一次 \`ai sandbox create\` 会原样重新生成。如果你不想再
56
+ 看到它,**就地编辑或清空内容**即可:scaffold 仅在 \`README.md\` **缺失**时
57
+ 写入,文件存在(哪怕被清空)就不会被重写。
58
+ `;
59
+ const SHARE_COMMON_README = `# /share/common - host <-> sandbox shared scratch (cross-branch)
60
+
61
+ This directory is mounted **read-write** into every sandbox container of this
62
+ project at \`/share/common\`, regardless of branch. Drop files here to share
63
+ between host and any sandbox without polluting the git worktree.
64
+
65
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
66
+
67
+ This file is safe to delete; the next \`ai sandbox create\` will re-create it.
68
+
69
+ ---
70
+
71
+ # /share/common - 宿主 <-> sandbox 共享暂存(跨分支)
72
+
73
+ 该目录被以**读写**方式挂载到本项目所有 sandbox 容器的 \`/share/common\`,
74
+ 跨分支可见。可用来在宿主和任意 sandbox 之间传文件,无需弄脏 git worktree。
75
+
76
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
77
+
78
+ 该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
79
+ `;
80
+ const SHARE_BRANCH_README = `# /share/branch - host <-> sandbox shared scratch (branch-exclusive)
81
+
82
+ This directory is mounted **read-write** into the sandbox container of this
83
+ project's current branch at \`/share/branch\`. Files here are exclusive to this
84
+ branch's sandbox and do not leak across branches.
85
+
86
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
87
+
88
+ This file is safe to delete; the next \`ai sandbox create\` will re-create it.
89
+
90
+ ---
91
+
92
+ # /share/branch - 宿主 <-> sandbox 共享暂存(分支独占)
93
+
94
+ 该目录被以**读写**方式挂载到本项目当前分支 sandbox 容器的 \`/share/branch\`,
95
+ 仅当前分支可见,不会跨分支泄漏。
96
+
97
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
98
+
99
+ 该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
100
+ `;
101
+ function errorDetail(error) {
102
+ return error instanceof Error ? error.message : 'unknown error';
103
+ }
104
+ function errorCode(error) {
105
+ return typeof error === 'object' && error !== null && 'code' in error
106
+ ? String(error.code)
107
+ : '';
108
+ }
109
+ function ensureFile(target, content, options) {
110
+ const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
111
+ const fsModule = options.fsModule ?? fs;
112
+ const result = { created: false, path: target };
113
+ try {
114
+ fsModule.mkdirSync(path.dirname(target), { recursive: true });
115
+ }
116
+ catch (error) {
117
+ writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
118
+ return result;
119
+ }
120
+ try {
121
+ fsModule.writeFileSync(target, content, { encoding: 'utf8', flag: 'wx' });
122
+ result.created = true;
123
+ }
124
+ catch (error) {
125
+ if (errorCode(error) === 'EEXIST') {
126
+ return result;
127
+ }
128
+ writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
129
+ }
130
+ return result;
131
+ }
132
+ export function ensureDotfilesReadme(dotfilesDir, options = {}) {
133
+ return ensureFile(path.join(dotfilesDir, 'README.md'), DOTFILES_README, options);
134
+ }
135
+ export function ensureShareCommonReadme(config, options = {}) {
136
+ return ensureFile(path.join(shareCommonDir(config), 'README.md'), SHARE_COMMON_README, options);
137
+ }
138
+ export function ensureShareBranchReadme(config, branch, options = {}) {
139
+ return ensureFile(path.join(shareBranchDir(config, branch), 'README.md'), SHARE_BRANCH_README, options);
140
+ }
141
+ export function ensureSandboxDiscoveryReadmes(config, branch, options = {}) {
142
+ return [
143
+ ensureDotfilesReadme(config.dotfilesDir, options),
144
+ ensureShareCommonReadme(config, options),
145
+ ensureShareBranchReadme(config, branch, options)
146
+ ];
147
+ }
148
+ //# sourceMappingURL=readme-scaffold.js.map
@@ -1,4 +1,6 @@
1
1
  USER devuser
2
+ ENV DISABLE_UPDATES=1
3
+ ENV OPENCODE_DISABLE_AUTOUPDATE=1
2
4
  ENV NPM_CONFIG_PREFIX=/home/devuser/.npm-global
3
5
  ENV PATH="/home/devuser/.npm-global/bin:${PATH}"
4
6
 
@@ -1,4 +1,4 @@
1
- FROM ubuntu:22.04
1
+ FROM ubuntu:24.04
2
2
 
3
3
  LABEL description="AI coding sandbox"
4
4
 
@@ -28,7 +28,7 @@ RUN apt-get update && apt-get install -y \
28
28
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
29
29
  > /etc/apt/sources.list.d/github-cli.list \
30
30
  && apt-get update && apt-get install -y gh \
31
- && TMUX_VERSION=3.6a \
31
+ && TMUX_VERSION=3.6b \
32
32
  && wget -qO /tmp/tmux.tar.gz \
33
33
  "https://github.com/tmux/tmux/releases/download/${TMUX_VERSION}/tmux-${TMUX_VERSION}.tar.gz" \
34
34
  && tar xzf /tmp/tmux.tar.gz -C /tmp \
@@ -126,7 +126,7 @@ find . -type f -print | while IFS= read -r rel; do
126
126
  .config/opencode|.config/opencode/*|\
127
127
  .local/share/opencode|.local/share/opencode/*|\
128
128
  .host-shell-config|.host-shell-config/*|\
129
- .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases)
129
+ .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases|README.md)
130
130
  continue ;;
131
131
  esac
132
132
 
package/lib/cp.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import { platform as currentPlatform, tmpdir as defaultTmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+ import { createClipboardAdapter, type ClipboardAdapter } from './sandbox/clipboard/index.ts';
8
+
9
+ const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
10
+ const COMMAND_TIMEOUT_MS = 30_000;
11
+
12
+ export type SpawnResult = {
13
+ status: number | null;
14
+ stdout: string;
15
+ stderr: string;
16
+ error?: Error;
17
+ };
18
+
19
+ type SpawnFn = (cmd: string, args: string[], input?: string) => SpawnResult;
20
+ type CreateAdapterFn = (options?: { platformName?: NodeJS.Platform }) => ClipboardAdapter | null;
21
+ type MkDTempFn = (prefix: string) => string;
22
+ type WriteFileFn = (file: string, data: Buffer) => void;
23
+ type RmFn = (target: string, options: { recursive: boolean; force: boolean }) => void;
24
+
25
+ export type CpDeps = {
26
+ platform?: NodeJS.Platform;
27
+ createAdapter?: CreateAdapterFn;
28
+ spawnFn?: SpawnFn;
29
+ randomId?: () => string;
30
+ mkdtempFn?: MkDTempFn;
31
+ writeFileFn?: WriteFileFn;
32
+ rmFn?: RmFn;
33
+ tmpdir?: () => string;
34
+ writeStdout?: (chunk: string) => unknown;
35
+ writeStderr?: (chunk: string) => unknown;
36
+ };
37
+
38
+ export function runCommand(cmd: string, args: string[], input?: string): SpawnResult {
39
+ const result = spawnSync(cmd, args, {
40
+ input,
41
+ encoding: 'utf8',
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ timeout: COMMAND_TIMEOUT_MS
44
+ });
45
+
46
+ return {
47
+ status: result.status,
48
+ stdout: result.stdout ?? '',
49
+ stderr: result.stderr ?? '',
50
+ error: result.error
51
+ };
52
+ }
53
+
54
+ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number> {
55
+ const {
56
+ platform = currentPlatform(),
57
+ createAdapter = createClipboardAdapter,
58
+ spawnFn = runCommand,
59
+ randomId = randomUUID,
60
+ mkdtempFn = fs.mkdtempSync,
61
+ writeFileFn = fs.writeFileSync,
62
+ rmFn = fs.rmSync,
63
+ tmpdir = defaultTmpdir,
64
+ writeStdout = (chunk: string) => process.stdout.write(chunk),
65
+ writeStderr = (chunk: string) => process.stderr.write(chunk)
66
+ } = deps;
67
+
68
+ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
69
+ writeStdout(USAGE);
70
+ return 0;
71
+ }
72
+
73
+ let positionals: string[];
74
+ try {
75
+ ({ positionals } = parseArgs({ args, allowPositionals: true, strict: true }));
76
+ } catch {
77
+ writeStderr(USAGE);
78
+ return 1;
79
+ }
80
+
81
+ const alias = positionals[0];
82
+ if (!alias || positionals.length !== 1) {
83
+ writeStderr(USAGE);
84
+ return 1;
85
+ }
86
+ if (alias.startsWith('-')) {
87
+ writeStderr(`invalid ssh alias '${alias}': must not start with '-'\n`);
88
+ return 1;
89
+ }
90
+
91
+ if (platform !== 'darwin') {
92
+ writeStderr(`ai cp currently supports macOS senders only (got ${platform})\n`);
93
+ return 1;
94
+ }
95
+
96
+ const adapter = createAdapter({ platformName: platform });
97
+ const png = adapter?.readImagePng() ?? null;
98
+ if (png === null) {
99
+ writeStderr('no image on clipboard\n');
100
+ return 1;
101
+ }
102
+
103
+ let uploaded = false;
104
+ let localTmpDir: string | null = null;
105
+ let remotePath: string | null = null;
106
+
107
+ try {
108
+ localTmpDir = mkdtempFn(path.join(tmpdir(), 'agent-infra-cp-'));
109
+ const localPng = path.join(localTmpDir, 'clipboard.png');
110
+ writeFileFn(localPng, png);
111
+
112
+ remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
113
+ const upload = spawnFn('scp', [
114
+ '-o',
115
+ 'BatchMode=yes',
116
+ '-o',
117
+ 'ConnectTimeout=10',
118
+ localPng,
119
+ `${alias}:${remotePath}`
120
+ ]);
121
+ if (upload.status !== 0) {
122
+ writeStderr(`failed to upload image to ${alias}:\n${commandDetail(upload)}\n`);
123
+ return 1;
124
+ }
125
+ uploaded = true;
126
+
127
+ // Remote write currently targets macOS only: it pipes an AppleScript to the
128
+ // remote `osascript` to set its NSPasteboard. This is the extension point for
129
+ // other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
130
+ // on Linux); a non-macOS remote fails here with a clear non-zero error today.
131
+ const setRemote = spawnFn('ssh', [
132
+ '-o',
133
+ 'BatchMode=yes',
134
+ '-o',
135
+ 'ConnectTimeout=10',
136
+ alias,
137
+ 'osascript',
138
+ '-'
139
+ ], remoteSetScript(remotePath));
140
+ if (setRemote.status !== 0) {
141
+ writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
142
+ return 1;
143
+ }
144
+
145
+ writeStdout(`copied clipboard image to ${alias}\n`);
146
+ return 0;
147
+ } finally {
148
+ if (uploaded && remotePath) {
149
+ spawnFn('ssh', [
150
+ '-o',
151
+ 'BatchMode=yes',
152
+ '-o',
153
+ 'ConnectTimeout=10',
154
+ alias,
155
+ 'rm',
156
+ '-f',
157
+ remotePath
158
+ ]);
159
+ }
160
+ if (localTmpDir) {
161
+ rmFn(localTmpDir, { recursive: true, force: true });
162
+ }
163
+ }
164
+ }
165
+
166
+ function commandDetail(result: SpawnResult): string {
167
+ const detail = result.stderr || result.error?.message || result.stdout || 'unknown error';
168
+ return detail.trimEnd();
169
+ }
170
+
171
+ function remoteSetScript(remotePath: string): string {
172
+ const escapedPath = remotePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
173
+ return [
174
+ `set theFile to POSIX file "${escapedPath}"`,
175
+ 'set the clipboard to (read theFile as «class PNGf»)'
176
+ ].join('\n');
177
+ }
@@ -1,4 +1,5 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { createClipboardAdapter, type ClipboardAdapter } from './index.ts';
3
4
  import { buildBracketedPaste, CtrlVDetector, type CtrlVMatch } from './keys.ts';
4
5
  import {
@@ -32,6 +33,26 @@ type BridgeOptions = {
32
33
  const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
33
34
  const PARTIAL_ESCAPE_FLUSH_MS = 30;
34
35
 
36
+ // Node's stdin.setRawMode(true) uses libuv's RAW mode, which (unlike the
37
+ // cfmakeraw that `docker exec -it` applies on the non-bridge path) keeps ONLCR
38
+ // set on the shared host TTY. With ONLCR on, the kernel rewrites the bare \n
39
+ // that tmux emits after homing the cursor inside the right pane into \r\n,
40
+ // snapping the cursor to column 1 so the following erase/redraw wipes the left
41
+ // pane. Clearing OPOST brings the host TTY in line with the non-bridge path.
42
+ // Best-effort: setRawMode(false) on teardown restores the original termios, and
43
+ // a missing/failed stty only reinstates the redraw glitch.
44
+ function disableOutputPostProcessing(stdin: NodeJS.ReadStream): void {
45
+ const candidate = (stdin as { fd?: unknown }).fd;
46
+ if (typeof candidate !== 'number') {
47
+ return;
48
+ }
49
+ try {
50
+ spawnSync('stty', ['-opost'], { stdio: [candidate, 'ignore', 'ignore'] });
51
+ } catch {
52
+ // stty unavailable or fd is not a tty; leave the terminal as-is.
53
+ }
54
+ }
55
+
35
56
  export async function runInteractiveWithClipboardBridge(options: BridgeOptions): Promise<number> {
36
57
  const {
37
58
  engine,
@@ -56,14 +77,11 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
56
77
  return runInteractive(engine, 'docker', dockerArgs);
57
78
  }
58
79
 
59
- if (platformName !== 'darwin') {
60
- return runInteractive(engine, 'docker', dockerArgs);
61
- }
62
80
  if (!stdin.isTTY || !stdout.isTTY) {
63
81
  return fallback('host stdin/stdout is not a TTY');
64
82
  }
65
83
  if (!adapter) {
66
- return fallback('macOS clipboard adapter is unavailable');
84
+ return fallback('no clipboard adapter available on this platform');
67
85
  }
68
86
  const available = adapter.available();
69
87
  if (!available.ok) {
@@ -185,6 +203,7 @@ async function runBridge({
185
203
 
186
204
  try {
187
205
  stdin.setRawMode?.(true);
206
+ disableOutputPostProcessing(stdin);
188
207
  stdin.resume();
189
208
  stdin.on('data', onData);
190
209
  stdout.on('resize', onResize);
@@ -6,8 +6,17 @@ export type ClipboardAdapter = DarwinClipboardAdapter;
6
6
  export function createClipboardAdapter({
7
7
  platformName = platform()
8
8
  }: { platformName?: NodeJS.Platform } = {}): ClipboardAdapter | null {
9
- if (platformName !== 'darwin') {
10
- return null;
9
+ switch (platformName) {
10
+ case 'darwin':
11
+ return createDarwinClipboardAdapter();
12
+ case 'linux':
13
+ // Future work: dispatch based on $WAYLAND_DISPLAY (wl-paste) or $DISPLAY (xclip);
14
+ // see Issue #386 follow-up. Returning null disables the bridge for now.
15
+ return null;
16
+ case 'win32':
17
+ // Future work: native Win32 clipboard reader. Returning null disables the bridge.
18
+ return null;
19
+ default:
20
+ return null;
11
21
  }
12
- return createDarwinClipboardAdapter();
13
22
  }
@@ -43,6 +43,7 @@ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.
43
43
  import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
44
44
  import { resolveBuildUid } from '../engines/native.ts';
45
45
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
46
+ import { ensureSandboxDiscoveryReadmes } from '../readme-scaffold.ts';
46
47
  import {
47
48
  prepareClaudeCredentials,
48
49
  redactCommandError,
@@ -1287,6 +1288,13 @@ export async function create(args: string[]): Promise<void> {
1287
1288
  message(`Created default sandbox aliases at ${aliasesFile.path}`);
1288
1289
  }
1289
1290
 
1291
+ const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
1292
+ for (const { created, path: readmePath } of readmeResults) {
1293
+ if (created) {
1294
+ message(`Created discovery README at ${readmePath}`);
1295
+ }
1296
+ }
1297
+
1290
1298
  const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
1291
1299
  const gitconfigContent = fs.existsSync(gitconfigPath)
1292
1300
  ? fs.readFileSync(gitconfigPath, 'utf8')
@@ -45,6 +45,37 @@ export function hostTimezoneEnvFlags(detect = detectHostTimezone): string[] {
45
45
  return tz ? ['-e', `TZ=${tz}`] : [];
46
46
  }
47
47
 
48
+ export function clipboardBridgeDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
49
+ const value = (env.AI_SANDBOX_NO_CLIPBOARD_BRIDGE ?? '').trim().toLowerCase();
50
+ return value === '1' || value === 'true' || value === 'yes';
51
+ }
52
+
53
+ export function runSandboxInteractive(params: {
54
+ engine: string;
55
+ dockerArgs: string[];
56
+ container: string;
57
+ home: string;
58
+ env?: NodeJS.ProcessEnv;
59
+ runBridge?: typeof runInteractiveWithClipboardBridge;
60
+ runInteractive?: typeof runInteractiveEngine;
61
+ }): number | Promise<number> {
62
+ const {
63
+ engine,
64
+ dockerArgs,
65
+ container,
66
+ home,
67
+ env = process.env,
68
+ runBridge = runInteractiveWithClipboardBridge,
69
+ runInteractive = runInteractiveEngine
70
+ } = params;
71
+
72
+ if (clipboardBridgeDisabled(env)) {
73
+ return runInteractive(engine, 'docker', dockerArgs);
74
+ }
75
+
76
+ return runBridge({ engine, dockerArgs, container, home });
77
+ }
78
+
48
79
  export function formatCredentialSyncStatus(
49
80
  result: ReturnType<typeof reconcileClaudeCredentials>,
50
81
  isTTY = process.stderr.isTTY
@@ -115,9 +146,10 @@ export async function enter(args: string[]): Promise<number> {
115
146
  process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
116
147
  }
117
148
 
118
- return runInteractiveWithClipboardBridge({
149
+ const dockerArgs = ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH];
150
+ return runSandboxInteractive({
119
151
  engine,
120
- dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
152
+ dockerArgs,
121
153
  container,
122
154
  home: config.home
123
155
  });
@@ -13,7 +13,7 @@ import type { SandboxTool } from '../tools.ts';
13
13
  import { toEnginePath } from '../engines/wsl2-paths.ts';
14
14
  import { resolveBuildUid } from '../engines/native.ts';
15
15
 
16
- const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
16
+ const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
17
17
 
18
18
  type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
19
19
  type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
@@ -38,12 +38,14 @@ export function buildArgs(
38
38
  engine,
39
39
  runFn = runEngine,
40
40
  runSafeFn = runSafeEngine,
41
- env = process.env
41
+ env = process.env,
42
+ refresh = false
42
43
  }: {
43
44
  engine?: string;
44
45
  runFn?: EngineRunFn;
45
46
  runSafeFn?: EngineRunSafeFn;
46
47
  env?: NodeJS.ProcessEnv;
48
+ refresh?: boolean;
47
49
  } = {}
48
50
  ): string[] {
49
51
  const selectedEngine = engine ?? detectEngine(config);
@@ -54,7 +56,7 @@ export function buildArgs(
54
56
  env
55
57
  });
56
58
 
57
- return [
59
+ const args = [
58
60
  'build',
59
61
  '-t',
60
62
  config.imageName,
@@ -72,6 +74,12 @@ export function buildArgs(
72
74
  toEnginePath(selectedEngine, dockerfilePath),
73
75
  toEnginePath(selectedEngine, config.repoRoot)
74
76
  ];
77
+
78
+ if (refresh) {
79
+ args.splice(1, 0, '--no-cache', '--pull');
80
+ }
81
+
82
+ return args;
75
83
  }
76
84
 
77
85
  function removeImageIfPresent(imageName: string, engine: string): void {
@@ -86,6 +94,7 @@ export async function rebuild(args: string[]): Promise<void> {
86
94
  allowPositionals: true,
87
95
  strict: true,
88
96
  options: {
97
+ refresh: { type: 'boolean' },
89
98
  quiet: { type: 'boolean', short: 'q' },
90
99
  help: { type: 'boolean', short: 'h' }
91
100
  }
@@ -101,6 +110,7 @@ export async function rebuild(args: string[]): Promise<void> {
101
110
  const preparedDockerfile = prepareDockerfile(config);
102
111
  const imageSignature = buildSignature(preparedDockerfile, tools);
103
112
  const quiet = values.quiet ?? false;
113
+ const refresh = values.refresh ?? false;
104
114
  const engine = detectEngine(config);
105
115
 
106
116
  await ensureDocker(config, undefined);
@@ -113,7 +123,7 @@ export async function rebuild(args: string[]): Promise<void> {
113
123
  removeImageIfPresent(config.imageName, engine);
114
124
  spinner.stop('Old image removed');
115
125
  spinner.start('Building image...');
116
- runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), {
126
+ runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
117
127
  cwd: config.repoRoot
118
128
  });
119
129
  spinner.stop(pc.green('Sandbox image rebuilt'));
@@ -124,7 +134,7 @@ export async function rebuild(args: string[]): Promise<void> {
124
134
  runVerboseEngine(
125
135
  engine,
126
136
  'docker',
127
- buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }),
137
+ buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }),
128
138
  { cwd: config.repoRoot }
129
139
  );
130
140
  p.log.success(pc.green('Sandbox image rebuilt'));
@@ -3,12 +3,13 @@ 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
7
6
  ls List sandboxes for the current project
8
- rm <branch> [--all] Remove a sandbox or all sandboxes
9
7
  prune [--dry-run] Remove orphaned per-branch state dirs
8
+ rebuild [--quiet] [--refresh]
9
+ Rebuild the sandbox image (--refresh pulls base + tools)
10
+ refresh Sync host Claude Code credentials to all sandbox copies
11
+ rm <branch> [--all] Remove a sandbox or all sandboxes
10
12
  vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
11
- rebuild [--quiet] Rebuild the sandbox image
12
13
 
13
14
  Run 'ai sandbox <command> --help' for details.`;
14
15