@fitlab-ai/agent-infra 0.6.3 → 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.
- package/README.md +12 -2
- package/README.zh-CN.md +12 -2
- package/bin/cli.ts +18 -6
- package/dist/bin/cli.js +20 -6
- package/dist/lib/cp.js +127 -0
- package/dist/lib/sandbox/clipboard/bridge.js +30 -9
- package/dist/lib/sandbox/clipboard/darwin.js +7 -14
- package/dist/lib/sandbox/clipboard/index.js +12 -3
- package/dist/lib/sandbox/commands/create.js +11 -0
- package/dist/lib/sandbox/commands/enter.js +20 -3
- package/dist/lib/sandbox/commands/rebuild.js +11 -5
- package/dist/lib/sandbox/host-timezone.js +33 -0
- package/dist/lib/sandbox/index.js +4 -3
- package/dist/lib/sandbox/readme-scaffold.js +148 -0
- package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +2 -0
- package/dist/lib/sandbox/runtimes/base.dockerfile +24 -19
- package/lib/cp.ts +177 -0
- package/lib/sandbox/clipboard/bridge.ts +30 -10
- package/lib/sandbox/clipboard/darwin.ts +15 -14
- package/lib/sandbox/clipboard/index.ts +12 -3
- package/lib/sandbox/commands/create.ts +12 -0
- package/lib/sandbox/commands/enter.ts +41 -3
- package/lib/sandbox/commands/rebuild.ts +15 -5
- package/lib/sandbox/host-timezone.ts +42 -0
- package/lib/sandbox/index.ts +4 -3
- package/lib/sandbox/readme-scaffold.ts +177 -0
- package/lib/sandbox/runtimes/ai-tools.dockerfile +2 -0
- package/lib/sandbox/runtimes/base.dockerfile +24 -19
- package/package.json +7 -7
- package/templates/.agents/rules/create-issue.github.en.md +19 -1
- package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
- package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
- package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
- package/templates/.agents/rules/testing-discipline.en.md +44 -0
- package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
|
@@ -12,6 +12,7 @@ import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
|
|
|
12
12
|
import { resolveTaskBranch } from '../task-resolver.ts';
|
|
13
13
|
import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
|
|
14
14
|
import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
|
|
15
|
+
import { detectHostTimezone } from '../host-timezone.ts';
|
|
15
16
|
|
|
16
17
|
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
17
18
|
const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
|
|
@@ -39,6 +40,42 @@ export function terminalEnvFlags(env: NodeJS.ProcessEnv = process.env): string[]
|
|
|
39
40
|
return flags;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
export function hostTimezoneEnvFlags(detect = detectHostTimezone): string[] {
|
|
44
|
+
const tz = detect();
|
|
45
|
+
return tz ? ['-e', `TZ=${tz}`] : [];
|
|
46
|
+
}
|
|
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
|
+
|
|
42
79
|
export function formatCredentialSyncStatus(
|
|
43
80
|
result: ReturnType<typeof reconcileClaudeCredentials>,
|
|
44
81
|
isTTY = process.stderr.isTTY
|
|
@@ -101,7 +138,7 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
101
138
|
}
|
|
102
139
|
}
|
|
103
140
|
|
|
104
|
-
const envFlags = terminalEnvFlags();
|
|
141
|
+
const envFlags = [...terminalEnvFlags(), ...hostTimezoneEnvFlags()];
|
|
105
142
|
if (cmd.length === 0) {
|
|
106
143
|
try {
|
|
107
144
|
materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
|
|
@@ -109,9 +146,10 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
109
146
|
process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
|
|
110
147
|
}
|
|
111
148
|
|
|
112
|
-
|
|
149
|
+
const dockerArgs = ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH];
|
|
150
|
+
return runSandboxInteractive({
|
|
113
151
|
engine,
|
|
114
|
-
dockerArgs
|
|
152
|
+
dockerArgs,
|
|
115
153
|
container,
|
|
116
154
|
home: config.home
|
|
117
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
|
-
|
|
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'));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
export type DetectHostTimezoneOptions = {
|
|
5
|
+
platform?: NodeJS.Platform;
|
|
6
|
+
readlink?: (targetPath: string) => string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ZONEINFO_MARK = '/zoneinfo/';
|
|
11
|
+
const IANA_ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
|
12
|
+
|
|
13
|
+
function safeTimezone(value: string | undefined): string | null {
|
|
14
|
+
if (!value || !IANA_ZONE_RE.test(value)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function detectHostTimezone(options: DetectHostTimezoneOptions = {}): string | null {
|
|
21
|
+
const platform = options.platform ?? os.platform();
|
|
22
|
+
const env = options.env ?? process.env;
|
|
23
|
+
if (env.TZ) {
|
|
24
|
+
return safeTimezone(env.TZ);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const readlink = options.readlink ?? fs.readlinkSync;
|
|
32
|
+
try {
|
|
33
|
+
const target = readlink('/etc/localtime');
|
|
34
|
+
const idx = target.indexOf(ZONEINFO_MARK);
|
|
35
|
+
if (idx < 0) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return safeTimezone(target.slice(idx + ZONEINFO_MARK.length));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/lib/sandbox/index.ts
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { shareBranchDir, shareCommonDir } from './constants.ts';
|
|
4
|
+
|
|
5
|
+
type ScaffoldResult = { created: boolean; path: string };
|
|
6
|
+
type WriteStderr = (chunk: string) => void;
|
|
7
|
+
type ScaffoldFs = Pick<typeof fs, 'mkdirSync' | 'writeFileSync'>;
|
|
8
|
+
type ScaffoldOptions = {
|
|
9
|
+
writeStderr?: WriteStderr;
|
|
10
|
+
fsModule?: ScaffoldFs;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const DOTFILES_README = `# User-level dotfiles channel
|
|
14
|
+
|
|
15
|
+
This directory is mounted **read-only** into every sandbox container at
|
|
16
|
+
\`/dotfiles\`. On entry, \`sandbox-dotfiles-link\` mirrors every file here as a
|
|
17
|
+
symlink under \`$HOME\` (e.g. \`.tmux.conf\` -> \`/home/devuser/.tmux.conf\`),
|
|
18
|
+
overriding image defaults so your editor, shell, and tool preferences follow
|
|
19
|
+
you across \`ai sandbox destroy + create\`.
|
|
20
|
+
|
|
21
|
+
See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#user-level-dotfiles-channel
|
|
22
|
+
|
|
23
|
+
Common usage - drop files or symlinks here:
|
|
24
|
+
|
|
25
|
+
\`\`\`sh
|
|
26
|
+
# Real files
|
|
27
|
+
echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
|
|
28
|
+
|
|
29
|
+
# Symlinks to live host paths
|
|
30
|
+
ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
|
|
31
|
+
ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
|
|
32
|
+
\`\`\`
|
|
33
|
+
|
|
34
|
+
> Do **not** put secrets here. Use the dedicated SSH / credential mounts.
|
|
35
|
+
|
|
36
|
+
If you delete this file, the next \`ai sandbox create\` will re-create it
|
|
37
|
+
verbatim. To stop seeing it, edit or empty the file in place - the scaffold
|
|
38
|
+
only writes \`README.md\` when it is missing, never when it already exists.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
# 用户级 dotfiles 通道
|
|
43
|
+
|
|
44
|
+
该目录被以**只读**方式挂载到每个 sandbox 容器的 \`/dotfiles\`。容器启动时,
|
|
45
|
+
\`sandbox-dotfiles-link\` 会把这里的每个文件 \`ln -sfn\` 到 \`$HOME\` 对应路径
|
|
46
|
+
(例如 \`.tmux.conf -> /home/devuser/.tmux.conf\`),覆盖镜像默认值,让你的编辑器、
|
|
47
|
+
shell、工具偏好跨 \`ai sandbox destroy + create\` 持久存在。
|
|
48
|
+
|
|
49
|
+
参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#用户级-dotfiles-通道
|
|
50
|
+
|
|
51
|
+
常见用法:把文件或符号链接放进来:
|
|
52
|
+
|
|
53
|
+
\`\`\`sh
|
|
54
|
+
# 直接放文件
|
|
55
|
+
echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
|
|
56
|
+
|
|
57
|
+
# 用符号链接指向 host 实际文件
|
|
58
|
+
ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
|
|
59
|
+
ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
> **不要**在此放任何凭证。SSH / 凭证请使用专用挂载通道。
|
|
63
|
+
|
|
64
|
+
如果你删除该文件,下一次 \`ai sandbox create\` 会原样重新生成。如果你不想再
|
|
65
|
+
看到它,**就地编辑或清空内容**即可:scaffold 仅在 \`README.md\` **缺失**时
|
|
66
|
+
写入,文件存在(哪怕被清空)就不会被重写。
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const SHARE_COMMON_README = `# /share/common - host <-> sandbox shared scratch (cross-branch)
|
|
70
|
+
|
|
71
|
+
This directory is mounted **read-write** into every sandbox container of this
|
|
72
|
+
project at \`/share/common\`, regardless of branch. Drop files here to share
|
|
73
|
+
between host and any sandbox without polluting the git worktree.
|
|
74
|
+
|
|
75
|
+
See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
|
|
76
|
+
|
|
77
|
+
This file is safe to delete; the next \`ai sandbox create\` will re-create it.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
# /share/common - 宿主 <-> sandbox 共享暂存(跨分支)
|
|
82
|
+
|
|
83
|
+
该目录被以**读写**方式挂载到本项目所有 sandbox 容器的 \`/share/common\`,
|
|
84
|
+
跨分支可见。可用来在宿主和任意 sandbox 之间传文件,无需弄脏 git worktree。
|
|
85
|
+
|
|
86
|
+
参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
|
|
87
|
+
|
|
88
|
+
该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const SHARE_BRANCH_README = `# /share/branch - host <-> sandbox shared scratch (branch-exclusive)
|
|
92
|
+
|
|
93
|
+
This directory is mounted **read-write** into the sandbox container of this
|
|
94
|
+
project's current branch at \`/share/branch\`. Files here are exclusive to this
|
|
95
|
+
branch's sandbox and do not leak across branches.
|
|
96
|
+
|
|
97
|
+
See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
|
|
98
|
+
|
|
99
|
+
This file is safe to delete; the next \`ai sandbox create\` will re-create it.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
# /share/branch - 宿主 <-> sandbox 共享暂存(分支独占)
|
|
104
|
+
|
|
105
|
+
该目录被以**读写**方式挂载到本项目当前分支 sandbox 容器的 \`/share/branch\`,
|
|
106
|
+
仅当前分支可见,不会跨分支泄漏。
|
|
107
|
+
|
|
108
|
+
参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
|
|
109
|
+
|
|
110
|
+
该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
function errorDetail(error: unknown): string {
|
|
114
|
+
return error instanceof Error ? error.message : 'unknown error';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function errorCode(error: unknown): string {
|
|
118
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
119
|
+
? String(error.code)
|
|
120
|
+
: '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function ensureFile(target: string, content: string, options: ScaffoldOptions): ScaffoldResult {
|
|
124
|
+
const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
|
|
125
|
+
const fsModule = options.fsModule ?? fs;
|
|
126
|
+
const result: ScaffoldResult = { created: false, path: target };
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
fsModule.mkdirSync(path.dirname(target), { recursive: true });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
fsModule.writeFileSync(target, content, { encoding: 'utf8', flag: 'wx' });
|
|
137
|
+
result.created = true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (errorCode(error) === 'EEXIST') {
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function ensureDotfilesReadme(dotfilesDir: string, options: ScaffoldOptions = {}): ScaffoldResult {
|
|
149
|
+
return ensureFile(path.join(dotfilesDir, 'README.md'), DOTFILES_README, options);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function ensureShareCommonReadme(
|
|
153
|
+
config: { shareBase: string },
|
|
154
|
+
options: ScaffoldOptions = {}
|
|
155
|
+
): ScaffoldResult {
|
|
156
|
+
return ensureFile(path.join(shareCommonDir(config), 'README.md'), SHARE_COMMON_README, options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function ensureShareBranchReadme(
|
|
160
|
+
config: { shareBase: string },
|
|
161
|
+
branch: string,
|
|
162
|
+
options: ScaffoldOptions = {}
|
|
163
|
+
): ScaffoldResult {
|
|
164
|
+
return ensureFile(path.join(shareBranchDir(config, branch), 'README.md'), SHARE_BRANCH_README, options);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function ensureSandboxDiscoveryReadmes(
|
|
168
|
+
config: { shareBase: string; dotfilesDir: string },
|
|
169
|
+
branch: string,
|
|
170
|
+
options: ScaffoldOptions = {}
|
|
171
|
+
): ScaffoldResult[] {
|
|
172
|
+
return [
|
|
173
|
+
ensureDotfilesReadme(config.dotfilesDir, options),
|
|
174
|
+
ensureShareCommonReadme(config, options),
|
|
175
|
+
ensureShareBranchReadme(config, branch, options)
|
|
176
|
+
];
|
|
177
|
+
}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
FROM ubuntu:
|
|
1
|
+
FROM ubuntu:24.04
|
|
2
2
|
|
|
3
3
|
LABEL description="AI coding sandbox"
|
|
4
4
|
|
|
5
5
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
6
|
-
ENV TZ=Asia/Shanghai
|
|
7
6
|
|
|
8
7
|
ARG HOST_UID=1000
|
|
9
8
|
ARG HOST_GID=1000
|
|
@@ -22,14 +21,14 @@ RUN apt-get update && apt-get install -y \
|
|
|
22
21
|
build-essential ca-certificates gnupg lsb-release \
|
|
23
22
|
libevent-core-2.1-7 libncursesw6 libtinfo6 \
|
|
24
23
|
pkg-config bison libevent-dev libncurses-dev \
|
|
25
|
-
locales \
|
|
24
|
+
locales tzdata \
|
|
26
25
|
&& locale-gen en_US.UTF-8 \
|
|
27
26
|
&& (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
28
27
|
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
|
|
29
28
|
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
|
30
29
|
> /etc/apt/sources.list.d/github-cli.list \
|
|
31
30
|
&& apt-get update && apt-get install -y gh \
|
|
32
|
-
&& TMUX_VERSION=3.
|
|
31
|
+
&& TMUX_VERSION=3.6b \
|
|
33
32
|
&& wget -qO /tmp/tmux.tar.gz \
|
|
34
33
|
"https://github.com/tmux/tmux/releases/download/${TMUX_VERSION}/tmux-${TMUX_VERSION}.tar.gz" \
|
|
35
34
|
&& tar xzf /tmp/tmux.tar.gz -C /tmp \
|
|
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
|
|
|
45
44
|
&& rm -rf /var/lib/apt/lists/*
|
|
46
45
|
|
|
47
46
|
# Enable extended keys in CSI u format so Shift+Enter and other modified
|
|
48
|
-
# keys are forwarded through tmux. Preserve terminal
|
|
47
|
+
# keys are forwarded through tmux. Preserve terminal/timezone variables
|
|
49
48
|
# injected at `docker exec` time when new tmux sessions are created.
|
|
50
49
|
RUN printf '%s\n' \
|
|
51
50
|
'set -g extended-keys always' \
|
|
52
51
|
'set -g extended-keys-format csi-u' \
|
|
53
52
|
"set -as terminal-features 'xterm*:extkeys'" \
|
|
54
|
-
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
|
|
53
|
+
"set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
|
|
55
54
|
'set -g mouse on' \
|
|
56
55
|
'set -g status-interval 1' \
|
|
57
56
|
'set -g status-right-length 80' \
|
|
@@ -127,7 +126,7 @@ find . -type f -print | while IFS= read -r rel; do
|
|
|
127
126
|
.config/opencode|.config/opencode/*|\
|
|
128
127
|
.local/share/opencode|.local/share/opencode/*|\
|
|
129
128
|
.host-shell-config|.host-shell-config/*|\
|
|
130
|
-
.gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases)
|
|
129
|
+
.gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases|README.md)
|
|
131
130
|
continue ;;
|
|
132
131
|
esac
|
|
133
132
|
|
|
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
|
|
|
146
145
|
#!/bin/sh
|
|
147
146
|
set -eu
|
|
148
147
|
|
|
149
|
-
sandbox-dotfiles-link >/dev/null || true
|
|
148
|
+
sandbox-dotfiles-link >/dev/null 2>&1 || true
|
|
150
149
|
|
|
151
150
|
SESSION=work
|
|
152
151
|
|
|
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
|
|
|
154
153
|
exec bash
|
|
155
154
|
fi
|
|
156
155
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
|
|
162
|
-
while read -r name attached; do
|
|
163
|
-
[ "$name" = "$SESSION" ] && continue
|
|
156
|
+
# Drop stale grouped sessions left by older entry-script versions (the windows
|
|
157
|
+
# live on $SESSION, so killing the group members only removes view entries).
|
|
158
|
+
tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
|
|
164
159
|
case "$name" in
|
|
165
|
-
|
|
160
|
+
"$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
|
|
166
161
|
esac
|
|
167
|
-
|
|
168
|
-
|
|
162
|
+
done
|
|
163
|
+
|
|
164
|
+
# Reuse the single $SESSION; -d detaches any pre-existing client so the new
|
|
165
|
+
# one becomes the sole owner of window-size, eliminating size races.
|
|
166
|
+
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
|
167
|
+
# Push the per-exec TZ into the running session's env so new
|
|
168
|
+
# windows/panes pick up the host timezone without a session kill.
|
|
169
|
+
if [ -n "${TZ:-}" ]; then
|
|
170
|
+
tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
|
|
171
|
+
fi
|
|
172
|
+
exec tmux attach -d -t "$SESSION"
|
|
173
|
+
fi
|
|
169
174
|
|
|
170
|
-
exec tmux new-session -
|
|
175
|
+
exec tmux new-session -s "$SESSION"
|
|
171
176
|
SCRIPT
|
|
172
177
|
|
|
173
178
|
ENV LANG=en_US.UTF-8
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fitlab-ai/agent-infra",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"installer"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@clack/prompts": "1.
|
|
46
|
+
"@clack/prompts": "1.5.1",
|
|
47
47
|
"cross-spawn": "^7.0.6",
|
|
48
48
|
"picocolors": "1.1.1",
|
|
49
49
|
"semver": "^7.8.1",
|
|
@@ -54,11 +54,11 @@
|
|
|
54
54
|
"demo:regen": "sh scripts/demo-regen.sh",
|
|
55
55
|
"prepare": "git config core.hooksPath .git-hooks || true",
|
|
56
56
|
"typecheck": "tsc -p tsconfig.test.json --noEmit && tsc -p tsconfig.jschecks.json --noEmit",
|
|
57
|
-
"test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test tests/
|
|
58
|
-
"test:core": "npm run build && node --experimental-strip-types --no-warnings --test tests/
|
|
59
|
-
"test": "npm run build && node --experimental-strip-types --no-warnings --test tests
|
|
60
|
-
"test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage --test-coverage-exclude=
|
|
61
|
-
"prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test tests
|
|
57
|
+
"test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\"",
|
|
58
|
+
"test:core": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\"",
|
|
59
|
+
"test": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\"",
|
|
60
|
+
"test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage \"--test-coverage-exclude=tests/**\" --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov \"tests/**/*.test.ts\"",
|
|
61
|
+
"prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test \"tests/**/*.test.ts\""
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -115,7 +115,25 @@ When applying the Issue Type, follow the "Set Issue Type" command in `.agents/ru
|
|
|
115
115
|
|
|
116
116
|
#### milestone
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
**Mandatory; do not skip.** This section expands `.agents/rules/milestone-inference.md` Phase 1 in place and keeps the same semantics; do not treat it as optional inference.
|
|
119
|
+
|
|
120
|
+
Select the milestone using these numbered steps, with priority strictly aligned to Phase 1:
|
|
121
|
+
|
|
122
|
+
1. If `has_triage=false`: omit `--milestone` immediately and skip this section.
|
|
123
|
+
2. List all open milestones in the repository:
|
|
124
|
+
```bash
|
|
125
|
+
gh api "repos/$upstream_repo/milestones?state=open&per_page=100" \
|
|
126
|
+
--jq '.[].title'
|
|
127
|
+
```
|
|
128
|
+
3. If task.md frontmatter explicitly provides a `milestone` field and that value appears in the step 2 list: use that value directly as `{milestone-arg}` and skip steps 4 / 5.
|
|
129
|
+
4. Filter the step 2 result with `^[0-9]+\.[0-9]+\.x$`.
|
|
130
|
+
- Non-empty: sort by major and minor numerically, then choose the smallest release line as `{milestone-arg}`.
|
|
131
|
+
5. If step 4 has no candidates: fall back to `General Backlog`.
|
|
132
|
+
- `General Backlog` exists in the step 2 result: use that milestone.
|
|
133
|
+
- `General Backlog` does not exist: omit `--milestone` only in this case.
|
|
134
|
+
6. If the step 2 `gh api` call fails (network / authentication error): handle it as "no candidates" and continue to step 5.
|
|
135
|
+
|
|
136
|
+
When a milestone is selected, pass the release line (or `General Backlog` or the explicit task.md value) as `{milestone-arg}` to the `gh issue create` command in step 5; keep the expansion rule at the end of §5 unchanged.
|
|
119
137
|
|
|
120
138
|
### 5. Call the GitHub CLI to Create the Issue
|
|
121
139
|
|
|
@@ -115,7 +115,25 @@ Issue 模板检测:按 `.agents/rules/issue-pr-commands.md` 的 "Issue 模板
|
|
|
115
115
|
|
|
116
116
|
#### milestone
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
**必须执行,不得跳过**。本节是 `.agents/rules/milestone-inference.md` 阶段 1 的就地展开,语义与之对齐;不要把它当成"可选推断"。
|
|
119
|
+
|
|
120
|
+
按以下编号步骤选取 milestone(优先级与阶段 1 严格一致):
|
|
121
|
+
|
|
122
|
+
1. 如果 `has_triage=false`:直接省略 `--milestone`,跳过本节。
|
|
123
|
+
2. 列出仓库 open 状态的全部 milestone:
|
|
124
|
+
```bash
|
|
125
|
+
gh api "repos/$upstream_repo/milestones?state=open&per_page=100" \
|
|
126
|
+
--jq '.[].title'
|
|
127
|
+
```
|
|
128
|
+
3. 如果 `task.md` frontmatter 显式给出 `milestone` 字段,且该值出现在步骤 2 列表中:直接使用该值作为 `{milestone-arg}`,跳过步骤 4 / 5。
|
|
129
|
+
4. 用正则 `^[0-9]+\.[0-9]+\.x$` 过滤步骤 2 结果。
|
|
130
|
+
- 非空:按 major、minor 数值升序排序,取最小的版本线作为 `{milestone-arg}`。
|
|
131
|
+
5. 步骤 4 候选为空:尝试回退到 `General Backlog`。
|
|
132
|
+
- 步骤 2 结果中存在 `General Backlog`:使用该 milestone。
|
|
133
|
+
- 不存在 `General Backlog`:仅在此情况下省略 `--milestone`。
|
|
134
|
+
6. 步骤 2 的 `gh api` 调用失败(网络 / 认证错误):按"无候选"处理,落到步骤 5。
|
|
135
|
+
|
|
136
|
+
设置成功时把版本线(或 `General Backlog` 或 task.md 显式值)作为 `{milestone-arg}` 带入步骤 5 的 `gh issue create` 命令;§5 末尾的展开规则保持不变。
|
|
119
137
|
|
|
120
138
|
### 5. 调用 GitHub CLI 创建 Issue
|
|
121
139
|
|
|
@@ -44,6 +44,18 @@ Only match titles in `X.Y.x` format and choose the smallest major/minor pair num
|
|
|
44
44
|
|
|
45
45
|
Direct milestone writes are triage-gated. When the caller detected `has_triage=false`, omit `--milestone` and continue.
|
|
46
46
|
|
|
47
|
+
### Backfill when called from `import-issue`
|
|
48
|
+
|
|
49
|
+
When `import-issue` imports an existing Issue whose current milestone is empty, infer a release line using the priority above, including the `General Backlog` fallback. When a non-empty release line is found, write it back to the remote Issue:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
if [ "$has_triage" = "true" ]; then
|
|
53
|
+
gh issue edit {issue-number} -R "$upstream_repo" --milestone "{version}" 2>/dev/null || true
|
|
54
|
+
fi
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If `has_triage=false`, inference returns empty, or `gh issue edit` fails, skip and continue without blocking the `import-issue` workflow.
|
|
58
|
+
|
|
47
59
|
## Phase 2: `implement-task`
|
|
48
60
|
|
|
49
61
|
Goal: narrow the Issue milestone from a release line to a concrete version when implementation starts.
|
|
@@ -44,6 +44,18 @@ gh api "repos/$upstream_repo/milestones?state=open&per_page=100" \
|
|
|
44
44
|
|
|
45
45
|
Milestone 设置属于 `has_triage` 权限范围;如果调用方检测到 `has_triage=false`,则省略 `--milestone` 并继续。
|
|
46
46
|
|
|
47
|
+
### `import-issue` 调用时的兜底
|
|
48
|
+
|
|
49
|
+
`import-issue` 导入既有 Issue 时,若 Issue 当前 milestone 为空,按上述优先级推断版本线(含 `General Backlog` 回退)。命中非空版本线后,回写到远端 Issue:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
if [ "$has_triage" = "true" ]; then
|
|
53
|
+
gh issue edit {issue-number} -R "$upstream_repo" --milestone "{version}" 2>/dev/null || true
|
|
54
|
+
fi
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
如果 `has_triage=false`、推断结果为空、或 `gh issue edit` 失败,跳过并继续,不阻断 `import-issue` 工作流。
|
|
58
|
+
|
|
47
59
|
## 阶段 2:`implement-task`
|
|
48
60
|
|
|
49
61
|
目标:开始开发时,把 Issue milestone 从版本线收窄到具体版本。
|