@fitlab-ai/agent-infra 0.4.5 → 0.5.1
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 +18 -2
- package/README.zh-CN.md +18 -2
- package/bin/cli.js +19 -0
- package/lib/defaults.json +17 -0
- package/lib/init.js +1 -0
- package/lib/log.js +5 -10
- package/lib/merge.js +885 -0
- package/lib/sandbox/commands/create.js +1170 -0
- package/lib/sandbox/commands/enter.js +64 -0
- package/lib/sandbox/commands/ls.js +71 -0
- package/lib/sandbox/commands/rebuild.js +102 -0
- package/lib/sandbox/commands/rm.js +211 -0
- package/lib/sandbox/commands/vm.js +101 -0
- package/lib/sandbox/config.js +79 -0
- package/lib/sandbox/constants.js +113 -0
- package/lib/sandbox/dockerfile.js +95 -0
- package/lib/sandbox/engine.js +93 -0
- package/lib/sandbox/index.js +64 -0
- package/lib/sandbox/runtimes/ai-tools.dockerfile +26 -0
- package/lib/sandbox/runtimes/base.dockerfile +30 -0
- package/lib/sandbox/runtimes/java17.dockerfile +3 -0
- package/lib/sandbox/runtimes/java21.dockerfile +3 -0
- package/lib/sandbox/runtimes/node20.dockerfile +3 -0
- package/lib/sandbox/runtimes/node22.dockerfile +3 -0
- package/lib/sandbox/runtimes/python3.dockerfile +3 -0
- package/lib/sandbox/shell.js +48 -0
- package/lib/sandbox/task-resolver.js +35 -0
- package/lib/sandbox/tools.js +135 -0
- package/lib/update.js +16 -2
- package/package.json +5 -1
- package/templates/.agents/rules/pr-sync.md +110 -0
- package/templates/.agents/rules/pr-sync.zh-CN.md +110 -0
- package/templates/.agents/scripts/validate-artifact.js +117 -1
- package/templates/.agents/skills/archive-tasks/SKILL.md +6 -3
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +6 -3
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +91 -8
- package/templates/.agents/skills/commit/SKILL.md +9 -1
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +9 -1
- package/templates/.agents/skills/commit/config/verify.json +5 -1
- package/templates/.agents/skills/commit/reference/pr-summary-sync.md +21 -0
- package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +21 -0
- package/templates/.agents/skills/commit/reference/task-status-update.md +2 -0
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -0
- package/templates/.agents/skills/create-pr/SKILL.md +2 -1
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -1
- package/templates/.agents/skills/create-pr/reference/comment-publish.md +7 -74
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +6 -73
- package/templates/.agents/skills/create-task/SKILL.md +6 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +6 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- package/templates/.agents/skills/import-issue/SKILL.md +2 -0
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/import-issue/config/verify.json +1 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -1
- package/templates/.agents/templates/task.md +5 -4
- package/templates/.agents/templates/task.zh-CN.md +5 -4
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { assertValidBranchName, containerNameCandidates } from '../constants.js';
|
|
3
|
+
import { runInteractive, runSafe } from '../shell.js';
|
|
4
|
+
import { resolveTaskBranch } from '../task-resolver.js';
|
|
5
|
+
|
|
6
|
+
const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
|
|
7
|
+
export const TMUX_INTERACTIVE_TIP =
|
|
8
|
+
"tip: for long-running TUI sessions, run 'tmux new -s work' to survive disconnects (reattach with 'tmux a -t work')\n";
|
|
9
|
+
|
|
10
|
+
export function printInteractiveEntryTip(stream = process.stderr) {
|
|
11
|
+
stream.write(TMUX_INTERACTIVE_TIP);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Terminal-detection variables that interactive TUIs (e.g. claude-code)
|
|
15
|
+
// inspect to enable progressive enhancements such as the kitty keyboard
|
|
16
|
+
// protocol, which is what makes Shift+Enter distinguishable from Enter.
|
|
17
|
+
// `docker exec` does not forward these by default, so we must pass them
|
|
18
|
+
// through explicitly.
|
|
19
|
+
const FORWARDED_TERMINAL_ENV = [
|
|
20
|
+
'TERM_PROGRAM',
|
|
21
|
+
'TERM_PROGRAM_VERSION',
|
|
22
|
+
'LC_TERMINAL',
|
|
23
|
+
'LC_TERMINAL_VERSION'
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function terminalEnvFlags(env = process.env) {
|
|
27
|
+
const flags = [];
|
|
28
|
+
for (const name of FORWARDED_TERMINAL_ENV) {
|
|
29
|
+
const value = env[name];
|
|
30
|
+
if (value) {
|
|
31
|
+
flags.push('-e', `${name}=${value}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return flags;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function enter(args) {
|
|
38
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
39
|
+
process.stdout.write(`${USAGE}\n`);
|
|
40
|
+
if (args.length === 0) {
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const config = loadConfig();
|
|
47
|
+
const [branchOrTaskId, ...cmd] = args;
|
|
48
|
+
const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
|
|
49
|
+
assertValidBranchName(branch);
|
|
50
|
+
const running = runSafe('docker', ['ps', '--format', '{{.Names}}']).split('\n');
|
|
51
|
+
const container = containerNameCandidates(config, branch).find((name) => running.includes(name));
|
|
52
|
+
|
|
53
|
+
if (!container) {
|
|
54
|
+
throw new Error(`No running sandbox found for branch '${branch}'`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const envFlags = terminalEnvFlags();
|
|
58
|
+
if (cmd.length === 0) {
|
|
59
|
+
printInteractiveEntryTip();
|
|
60
|
+
return runInteractive('docker', ['exec', '-it', ...envFlags, container, 'bash']);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return runInteractive('docker', ['exec', '-it', ...envFlags, container, ...cmd]);
|
|
64
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { sandboxLabel } from '../constants.js';
|
|
7
|
+
import { runSafe } from '../shell.js';
|
|
8
|
+
import { resolveTools, toolProjectDirCandidates } from '../tools.js';
|
|
9
|
+
|
|
10
|
+
const USAGE = 'Usage: ai sandbox ls';
|
|
11
|
+
|
|
12
|
+
function listChildren(dir) {
|
|
13
|
+
if (!fs.existsSync(dir)) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return fs.readdirSync(dir).sort().map((entry) => path.join(dir, entry));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ls(args = []) {
|
|
21
|
+
if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
|
|
22
|
+
process.stdout.write(`${USAGE}\n`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const tools = resolveTools(config);
|
|
28
|
+
const label = sandboxLabel(config);
|
|
29
|
+
const containers = runSafe('docker', [
|
|
30
|
+
'ps',
|
|
31
|
+
'-a',
|
|
32
|
+
'--filter',
|
|
33
|
+
`label=${label}`,
|
|
34
|
+
'--format',
|
|
35
|
+
'table {{.Names}}\t{{.Status}}\t{{.Label "' + `${label}.branch` + '"}}'
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
p.intro(pc.cyan(`Sandbox status for ${config.project}`));
|
|
39
|
+
|
|
40
|
+
p.log.step('Containers');
|
|
41
|
+
if (!containers || containers.split('\n').length <= 1) {
|
|
42
|
+
p.log.warn(' No sandbox containers');
|
|
43
|
+
} else {
|
|
44
|
+
for (const line of containers.split('\n')) {
|
|
45
|
+
process.stdout.write(` ${line}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
p.log.step('Worktrees');
|
|
50
|
+
const worktrees = listChildren(config.worktreeBase);
|
|
51
|
+
if (worktrees.length === 0) {
|
|
52
|
+
p.log.warn(' No sandbox worktrees');
|
|
53
|
+
} else {
|
|
54
|
+
for (const worktree of worktrees) {
|
|
55
|
+
process.stdout.write(` ${worktree}\n`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const tool of tools) {
|
|
60
|
+
p.log.step(`${tool.name} state`);
|
|
61
|
+
const entries = toolProjectDirCandidates(tool, config.project)
|
|
62
|
+
.flatMap((dir) => listChildren(dir));
|
|
63
|
+
if (entries.length === 0) {
|
|
64
|
+
p.log.warn(` No ${tool.name} sandbox state`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
process.stdout.write(` ${entry}\n`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { prepareDockerfile } from '../dockerfile.js';
|
|
7
|
+
import { sandboxImageConfigLabel, sandboxLabel } from '../constants.js';
|
|
8
|
+
import { ensureDocker } from '../engine.js';
|
|
9
|
+
import { run, runOk, runVerbose } from '../shell.js';
|
|
10
|
+
import { resolveTools, toolNpmPackagesArg } from '../tools.js';
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
|
|
13
|
+
|
|
14
|
+
function buildSignature(preparedDockerfile, tools) {
|
|
15
|
+
return createHash('sha256')
|
|
16
|
+
.update(JSON.stringify({
|
|
17
|
+
dockerfile: preparedDockerfile.signature,
|
|
18
|
+
tools: tools.map((tool) => tool.npmPackage)
|
|
19
|
+
}))
|
|
20
|
+
.digest('hex')
|
|
21
|
+
.slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildArgs(config, tools, dockerfilePath, imageSignature) {
|
|
25
|
+
const hostUid = run('id', ['-u']);
|
|
26
|
+
const hostGid = run('id', ['-g']);
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
'build',
|
|
30
|
+
'-t',
|
|
31
|
+
config.imageName,
|
|
32
|
+
'--build-arg',
|
|
33
|
+
`HOST_UID=${hostUid}`,
|
|
34
|
+
'--build-arg',
|
|
35
|
+
`HOST_GID=${hostGid}`,
|
|
36
|
+
'--build-arg',
|
|
37
|
+
`AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
|
|
38
|
+
'--label',
|
|
39
|
+
sandboxLabel(config),
|
|
40
|
+
'--label',
|
|
41
|
+
`${sandboxImageConfigLabel(config)}=${imageSignature}`,
|
|
42
|
+
'-f',
|
|
43
|
+
dockerfilePath,
|
|
44
|
+
config.repoRoot
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function removeImageIfPresent(imageName) {
|
|
49
|
+
if (runOk('docker', ['image', 'inspect', imageName])) {
|
|
50
|
+
run('docker', ['rmi', imageName]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function rebuild(args) {
|
|
55
|
+
const { values } = parseArgs({
|
|
56
|
+
args,
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
strict: true,
|
|
59
|
+
options: {
|
|
60
|
+
quiet: { type: 'boolean', short: 'q' },
|
|
61
|
+
help: { type: 'boolean', short: 'h' }
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (values.help) {
|
|
66
|
+
process.stdout.write(`${USAGE}\n`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
const tools = resolveTools(config);
|
|
72
|
+
const preparedDockerfile = prepareDockerfile(config);
|
|
73
|
+
const imageSignature = buildSignature(preparedDockerfile, tools);
|
|
74
|
+
const quiet = values.quiet ?? false;
|
|
75
|
+
|
|
76
|
+
await ensureDocker(config);
|
|
77
|
+
p.intro(pc.cyan('Rebuilding sandbox image'));
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (quiet) {
|
|
81
|
+
const spinner = p.spinner();
|
|
82
|
+
spinner.start(`Removing old image ${config.imageName}...`);
|
|
83
|
+
removeImageIfPresent(config.imageName);
|
|
84
|
+
spinner.stop('Old image removed');
|
|
85
|
+
spinner.start('Building image...');
|
|
86
|
+
run('docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature), { cwd: config.repoRoot });
|
|
87
|
+
spinner.stop(pc.green('Sandbox image rebuilt'));
|
|
88
|
+
} else {
|
|
89
|
+
p.log.step(`Removing old image ${config.imageName}`);
|
|
90
|
+
removeImageIfPresent(config.imageName);
|
|
91
|
+
p.log.step('Building image');
|
|
92
|
+
runVerbose(
|
|
93
|
+
'docker',
|
|
94
|
+
buildArgs(config, tools, preparedDockerfile.path, imageSignature),
|
|
95
|
+
{ cwd: config.repoRoot }
|
|
96
|
+
);
|
|
97
|
+
p.log.success(pc.green('Sandbox image rebuilt'));
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
preparedDockerfile.cleanup();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { loadConfig } from '../config.js';
|
|
7
|
+
import {
|
|
8
|
+
assertValidBranchName,
|
|
9
|
+
containerNameCandidates,
|
|
10
|
+
sandboxBranchLabel,
|
|
11
|
+
sandboxLabel,
|
|
12
|
+
worktreeDirCandidates
|
|
13
|
+
} from '../constants.js';
|
|
14
|
+
import { isVmManaged } from '../engine.js';
|
|
15
|
+
import { run, runOk, runSafe } from '../shell.js';
|
|
16
|
+
import { resolveTaskBranch } from '../task-resolver.js';
|
|
17
|
+
import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.js';
|
|
18
|
+
|
|
19
|
+
const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
|
|
20
|
+
|
|
21
|
+
function projectToolDirs(config, tools) {
|
|
22
|
+
return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function rmOne(config, tools, branch) {
|
|
26
|
+
assertValidBranchName(branch);
|
|
27
|
+
let effectiveBranch = branch;
|
|
28
|
+
let worktreeCandidates = worktreeDirCandidates(config, branch);
|
|
29
|
+
let toolCandidates = tools.map((tool) => ({
|
|
30
|
+
tool,
|
|
31
|
+
candidates: toolConfigDirCandidates(tool, config.project, branch)
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
p.intro(pc.cyan(`Removing sandbox for ${branch}`));
|
|
35
|
+
|
|
36
|
+
const existing = runSafe('docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
|
|
37
|
+
const matchedContainers = containerNameCandidates(config, branch)
|
|
38
|
+
.filter((name) => existing.includes(name));
|
|
39
|
+
|
|
40
|
+
if (matchedContainers.length > 0) {
|
|
41
|
+
const resolvedBranch = runSafe('docker', [
|
|
42
|
+
'inspect',
|
|
43
|
+
'-f',
|
|
44
|
+
`{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
|
|
45
|
+
matchedContainers[0]
|
|
46
|
+
]);
|
|
47
|
+
if (resolvedBranch) {
|
|
48
|
+
effectiveBranch = resolvedBranch;
|
|
49
|
+
worktreeCandidates = worktreeDirCandidates(config, effectiveBranch);
|
|
50
|
+
toolCandidates = tools.map((tool) => ({
|
|
51
|
+
tool,
|
|
52
|
+
candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const spinner = p.spinner();
|
|
57
|
+
spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
|
|
58
|
+
for (const name of matchedContainers) {
|
|
59
|
+
runSafe('docker', ['stop', name]);
|
|
60
|
+
runSafe('docker', ['rm', name]);
|
|
61
|
+
}
|
|
62
|
+
spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
|
|
63
|
+
} else {
|
|
64
|
+
p.log.warn(`No sandbox container found for '${branch}'`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const existingWorktrees = worktreeCandidates.filter((candidate) => fs.existsSync(candidate));
|
|
68
|
+
if (existingWorktrees.length > 0) {
|
|
69
|
+
const shouldRemoveWorktree = await p.confirm({
|
|
70
|
+
message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
|
|
71
|
+
initialValue: true
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (p.isCancel(shouldRemoveWorktree)) {
|
|
75
|
+
p.outro('Cancelled');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (shouldRemoveWorktree) {
|
|
80
|
+
for (const worktree of existingWorktrees) {
|
|
81
|
+
try {
|
|
82
|
+
run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
|
|
83
|
+
} catch {
|
|
84
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const shouldDeleteBranch = await p.confirm({
|
|
89
|
+
message: `Also delete local branch '${effectiveBranch}'?`,
|
|
90
|
+
initialValue: false
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!p.isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
|
|
94
|
+
if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
|
|
95
|
+
p.log.warn(`Local branch '${effectiveBranch}' was not deleted`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const { tool, candidates } of toolCandidates) {
|
|
102
|
+
for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
|
|
103
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
p.log.success(`${tool.name} state removed: ${dir}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
p.outro(pc.green('Sandbox removed'));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function rmAll(config, tools) {
|
|
112
|
+
p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
|
|
113
|
+
|
|
114
|
+
const containers = runSafe('docker', [
|
|
115
|
+
'ps',
|
|
116
|
+
'-a',
|
|
117
|
+
'--filter',
|
|
118
|
+
`label=${sandboxLabel(config)}`,
|
|
119
|
+
'--format',
|
|
120
|
+
'{{.Names}}'
|
|
121
|
+
]);
|
|
122
|
+
if (containers) {
|
|
123
|
+
const spinner = p.spinner();
|
|
124
|
+
spinner.start('Stopping project sandbox containers...');
|
|
125
|
+
for (const name of containers.split('\n').filter(Boolean)) {
|
|
126
|
+
runSafe('docker', ['stop', name]);
|
|
127
|
+
runSafe('docker', ['rm', name]);
|
|
128
|
+
}
|
|
129
|
+
spinner.stop(pc.green('Project sandbox containers removed'));
|
|
130
|
+
} else {
|
|
131
|
+
p.log.warn('No project sandbox containers found');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (fs.existsSync(config.worktreeBase) && fs.readdirSync(config.worktreeBase).length > 0) {
|
|
135
|
+
const shouldRemoveWorktrees = await p.confirm({
|
|
136
|
+
message: `Remove all worktrees in ${config.worktreeBase}?`,
|
|
137
|
+
initialValue: true
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
|
|
141
|
+
for (const entry of fs.readdirSync(config.worktreeBase)) {
|
|
142
|
+
const dir = path.join(config.worktreeBase, entry);
|
|
143
|
+
try {
|
|
144
|
+
run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
145
|
+
} catch {
|
|
146
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const dir of projectToolDirs(config, tools)) {
|
|
154
|
+
if (fs.existsSync(dir)) {
|
|
155
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
p.log.success(`Removed tool state: ${dir}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const shouldRemoveImage = await p.confirm({
|
|
161
|
+
message: `Remove image ${config.imageName}?`,
|
|
162
|
+
initialValue: false
|
|
163
|
+
});
|
|
164
|
+
if (!p.isCancel(shouldRemoveImage) && shouldRemoveImage) {
|
|
165
|
+
runSafe('docker', ['rmi', config.imageName]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isVmManaged()) {
|
|
169
|
+
const shouldStopVm = await p.confirm({
|
|
170
|
+
message: 'Stop Colima VM?',
|
|
171
|
+
initialValue: false
|
|
172
|
+
});
|
|
173
|
+
if (!p.isCancel(shouldStopVm) && shouldStopVm) {
|
|
174
|
+
runSafe('colima', ['stop']);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
p.outro(pc.green('All project sandboxes removed'));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function rm(args) {
|
|
182
|
+
const { values, positionals } = parseArgs({
|
|
183
|
+
args,
|
|
184
|
+
allowPositionals: true,
|
|
185
|
+
strict: true,
|
|
186
|
+
options: {
|
|
187
|
+
all: { type: 'boolean' },
|
|
188
|
+
help: { type: 'boolean', short: 'h' }
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (values.help) {
|
|
193
|
+
process.stdout.write(`${USAGE}\n`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!values.all && positionals.length !== 1) {
|
|
198
|
+
throw new Error(USAGE);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const config = loadConfig();
|
|
202
|
+
const tools = resolveTools(config);
|
|
203
|
+
|
|
204
|
+
if (values.all) {
|
|
205
|
+
await rmAll(config, tools);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const branch = resolveTaskBranch(positionals[0], config.repoRoot);
|
|
210
|
+
await rmOne(config, tools, branch);
|
|
211
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { loadConfig } from '../config.js';
|
|
5
|
+
import { parsePositiveIntegerOption } from '../constants.js';
|
|
6
|
+
import { detectEngine, ensureDocker, isVmManaged } from '../engine.js';
|
|
7
|
+
import { run, runOk, runSafe } from '../shell.js';
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: ai sandbox vm <status|start|stop> [--cpu <n>] [--memory <n>]`;
|
|
10
|
+
|
|
11
|
+
function ensureManagedVm() {
|
|
12
|
+
if (!isVmManaged()) {
|
|
13
|
+
throw new Error(`VM management is unavailable on ${detectEngine()}.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function status() {
|
|
18
|
+
ensureManagedVm();
|
|
19
|
+
p.intro(pc.cyan('Sandbox VM status'));
|
|
20
|
+
|
|
21
|
+
if (runOk('colima', ['status'])) {
|
|
22
|
+
process.stdout.write(`${runSafe('colima', ['status'])}\n`);
|
|
23
|
+
} else {
|
|
24
|
+
p.log.warn('Colima VM is not running');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function start(args) {
|
|
29
|
+
ensureManagedVm();
|
|
30
|
+
|
|
31
|
+
const { values } = parseArgs({
|
|
32
|
+
args,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
strict: true,
|
|
35
|
+
options: {
|
|
36
|
+
cpu: { type: 'string' },
|
|
37
|
+
memory: { type: 'string' },
|
|
38
|
+
help: { type: 'boolean', short: 'h' }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (values.help) {
|
|
43
|
+
process.stdout.write(`${USAGE}\n`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const config = loadConfig();
|
|
48
|
+
const effectiveConfig = {
|
|
49
|
+
...config,
|
|
50
|
+
vm: {
|
|
51
|
+
...config.vm,
|
|
52
|
+
cpu: parsePositiveIntegerOption(values.cpu, '--cpu') ?? config.vm.cpu,
|
|
53
|
+
memory: parsePositiveIntegerOption(values.memory, '--memory') ?? config.vm.memory
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
p.intro(pc.cyan('Starting sandbox VM'));
|
|
58
|
+
await ensureDocker(effectiveConfig, (detail) => {
|
|
59
|
+
p.log.info(detail);
|
|
60
|
+
});
|
|
61
|
+
p.outro(pc.green('VM ready'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stop() {
|
|
65
|
+
ensureManagedVm();
|
|
66
|
+
p.intro(pc.cyan('Stopping sandbox VM'));
|
|
67
|
+
|
|
68
|
+
if (!runOk('colima', ['status'])) {
|
|
69
|
+
p.log.warn('Colima VM is not running');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
run('colima', ['stop']);
|
|
74
|
+
p.outro(pc.green('VM stopped'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function vm(args) {
|
|
78
|
+
const [subcommand, ...rest] = args;
|
|
79
|
+
|
|
80
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
81
|
+
process.stdout.write(`${USAGE}\n`);
|
|
82
|
+
if (!subcommand) {
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (subcommand) {
|
|
89
|
+
case 'status':
|
|
90
|
+
status();
|
|
91
|
+
break;
|
|
92
|
+
case 'start':
|
|
93
|
+
await start(rest);
|
|
94
|
+
break;
|
|
95
|
+
case 'stop':
|
|
96
|
+
stop();
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(USAGE);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = Object.freeze({
|
|
6
|
+
runtimes: ['node20'],
|
|
7
|
+
tools: ['claude-code', 'codex', 'opencode', 'gemini-cli'],
|
|
8
|
+
dockerfile: null,
|
|
9
|
+
vm: {
|
|
10
|
+
cpu: null,
|
|
11
|
+
memory: null,
|
|
12
|
+
disk: null
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function detectRepoRoot() {
|
|
17
|
+
try {
|
|
18
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error('sandbox: current directory is not inside a git repository');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneDefaults() {
|
|
28
|
+
return {
|
|
29
|
+
runtimes: [...DEFAULTS.runtimes],
|
|
30
|
+
tools: [...DEFAULTS.tools],
|
|
31
|
+
dockerfile: DEFAULTS.dockerfile,
|
|
32
|
+
vm: { ...DEFAULTS.vm }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadConfig() {
|
|
37
|
+
const repoRoot = detectRepoRoot();
|
|
38
|
+
const home = process.env.HOME;
|
|
39
|
+
|
|
40
|
+
if (!home) {
|
|
41
|
+
throw new Error('sandbox: HOME environment variable is required');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const configPath = path.join(repoRoot, '.agents', '.airc.json');
|
|
45
|
+
if (!fs.existsSync(configPath)) {
|
|
46
|
+
throw new Error('No .agents/.airc.json found. Run "ai init" first.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const airc = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
50
|
+
const defaults = cloneDefaults();
|
|
51
|
+
const sandbox = airc.sandbox ?? {};
|
|
52
|
+
const project = airc.project;
|
|
53
|
+
|
|
54
|
+
if (!project || typeof project !== 'string') {
|
|
55
|
+
throw new Error('sandbox: .agents/.airc.json is missing a valid "project" field');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
repoRoot,
|
|
60
|
+
configPath,
|
|
61
|
+
project,
|
|
62
|
+
org: airc.org ?? '',
|
|
63
|
+
home,
|
|
64
|
+
containerPrefix: `${project}-dev`,
|
|
65
|
+
imageName: `${project}-sandbox:latest`,
|
|
66
|
+
worktreeBase: path.join(home, '.agent-infra', 'worktrees', project),
|
|
67
|
+
runtimes: Array.isArray(sandbox.runtimes) && sandbox.runtimes.length > 0
|
|
68
|
+
? [...sandbox.runtimes]
|
|
69
|
+
: defaults.runtimes,
|
|
70
|
+
tools: Array.isArray(sandbox.tools) && sandbox.tools.length > 0
|
|
71
|
+
? [...sandbox.tools]
|
|
72
|
+
: defaults.tools,
|
|
73
|
+
dockerfile: sandbox.dockerfile ?? defaults.dockerfile,
|
|
74
|
+
vm: {
|
|
75
|
+
...defaults.vm,
|
|
76
|
+
...(sandbox.vm ?? {})
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|