@teamclaws/teamclaw 2026.3.21 → 2026.3.25
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 +19 -1
- package/api.ts +2 -2
- package/cli.mjs +1185 -0
- package/index.ts +24 -7
- package/openclaw.plugin.json +326 -2
- package/package.json +6 -9
- package/src/config.ts +29 -1
- package/src/controller/controller-service.ts +1 -0
- package/src/controller/controller-tools.ts +12 -1
- package/src/controller/http-server.ts +355 -10
- package/src/controller/local-worker-manager.ts +5 -3
- package/src/controller/prompt-injector.ts +6 -1
- package/src/controller/websocket.ts +1 -0
- package/src/controller/worker-provisioning.ts +93 -4
- package/src/install-defaults.ts +1 -0
- package/src/openclaw-workspace.ts +57 -1
- package/src/roles.ts +42 -7
- package/src/state.ts +6 -0
- package/src/task-executor.ts +1 -0
- package/src/types.ts +53 -1
- package/src/ui/app.js +138 -2
- package/src/ui/index.html +10 -0
- package/src/ui/style.css +148 -0
- package/src/worker/http-handler.ts +4 -0
- package/src/worker/prompt-injector.ts +1 -0
- package/src/worker/skill-installer.ts +302 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { createInterface } from "node:readline/promises";
|
|
9
|
+
import JSON5 from "json5";
|
|
10
|
+
|
|
11
|
+
const PACKAGE_NAME = "@teamclaws/teamclaw";
|
|
12
|
+
const PLUGIN_ID = "teamclaw";
|
|
13
|
+
const DEFAULT_TEAMCLAW_IMAGE = "ghcr.io/topcheer/teamclaw-openclaw:latest";
|
|
14
|
+
const DEFAULT_CONTROLLER_PORT = 9527;
|
|
15
|
+
const DEFAULT_WORKER_PORT = 9528;
|
|
16
|
+
const DEFAULT_GATEWAY_PORT = 18789;
|
|
17
|
+
const DEFAULT_TEAM_NAME = "default";
|
|
18
|
+
const DEFAULT_TASK_TIMEOUT_MS = 1_800_000;
|
|
19
|
+
const DEFAULT_AGENT_TIMEOUT_SECONDS = 2_400;
|
|
20
|
+
const DEFAULT_LOCAL_ROLES = ["architect", "developer", "qa"];
|
|
21
|
+
const DEFAULT_PROVISIONING_ROLES = ["architect", "developer", "qa"];
|
|
22
|
+
|
|
23
|
+
const ROLE_OPTIONS = [
|
|
24
|
+
{ value: "pm", label: "Product Manager" },
|
|
25
|
+
{ value: "architect", label: "Software Architect" },
|
|
26
|
+
{ value: "developer", label: "Developer" },
|
|
27
|
+
{ value: "qa", label: "QA Engineer" },
|
|
28
|
+
{ value: "release-engineer", label: "Release Engineer" },
|
|
29
|
+
{ value: "infra-engineer", label: "Infrastructure Engineer" },
|
|
30
|
+
{ value: "devops", label: "DevOps Engineer" },
|
|
31
|
+
{ value: "security-engineer", label: "Security Engineer" },
|
|
32
|
+
{ value: "designer", label: "UI/UX Designer" },
|
|
33
|
+
{ value: "marketing", label: "Marketing Specialist" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const INSTALL_MODE_OPTIONS = [
|
|
37
|
+
{
|
|
38
|
+
value: "single-local",
|
|
39
|
+
label: "Single machine controller + localRoles",
|
|
40
|
+
hint: "Recommended for first-time setup.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
value: "controller-manual",
|
|
44
|
+
label: "Controller only (manual distributed workers)",
|
|
45
|
+
hint: "Use separate OpenClaw installs for workers.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
value: "controller-process",
|
|
49
|
+
label: "Controller + on-demand process workers",
|
|
50
|
+
hint: "Launch workers as child processes on the same host.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
value: "controller-docker",
|
|
54
|
+
label: "Controller + on-demand Docker workers",
|
|
55
|
+
hint: "Launch workers in Docker containers.",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
value: "controller-kubernetes",
|
|
59
|
+
label: "Controller + on-demand Kubernetes workers",
|
|
60
|
+
hint: "Launch workers as Kubernetes pods.",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
value: "worker",
|
|
64
|
+
label: "Dedicated worker node",
|
|
65
|
+
hint: "Join an existing TeamClaw controller.",
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function printHelp() {
|
|
70
|
+
console.log(`
|
|
71
|
+
TeamClaw installer
|
|
72
|
+
|
|
73
|
+
Usage:
|
|
74
|
+
npx -y @teamclaws/teamclaw install
|
|
75
|
+
npm exec -y @teamclaws/teamclaw install
|
|
76
|
+
|
|
77
|
+
Commands:
|
|
78
|
+
install Install/configure TeamClaw for OpenClaw
|
|
79
|
+
help Show this help
|
|
80
|
+
|
|
81
|
+
Options:
|
|
82
|
+
--config <path> Override the OpenClaw config path
|
|
83
|
+
--yes Accept the recommended defaults without prompting
|
|
84
|
+
--skip-plugin-install Only update openclaw.json; skip "openclaw plugins install"
|
|
85
|
+
--dry-run Show what would happen without writing files
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseArgs(argv) {
|
|
90
|
+
const options = {
|
|
91
|
+
configPath: "",
|
|
92
|
+
yes: false,
|
|
93
|
+
skipPluginInstall: false,
|
|
94
|
+
dryRun: false,
|
|
95
|
+
};
|
|
96
|
+
let command = "";
|
|
97
|
+
|
|
98
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
99
|
+
const arg = argv[index];
|
|
100
|
+
if (!command && !arg.startsWith("--")) {
|
|
101
|
+
command = arg;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (arg === "--config") {
|
|
105
|
+
const value = argv[index + 1];
|
|
106
|
+
if (!value) {
|
|
107
|
+
throw new Error("--config requires a path");
|
|
108
|
+
}
|
|
109
|
+
options.configPath = value;
|
|
110
|
+
index += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg === "--yes") {
|
|
114
|
+
options.yes = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (arg === "--skip-plugin-install") {
|
|
118
|
+
options.skipPluginInstall = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (arg === "--dry-run") {
|
|
122
|
+
options.dryRun = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (arg === "--help" || arg === "-h") {
|
|
126
|
+
command = "help";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { command: command || "help", options };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isRecord(value) {
|
|
136
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ensureRecord(parent, key) {
|
|
140
|
+
if (!isRecord(parent[key])) {
|
|
141
|
+
parent[key] = {};
|
|
142
|
+
}
|
|
143
|
+
return parent[key];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function expandUserPath(value) {
|
|
147
|
+
const trimmed = String(value || "").trim();
|
|
148
|
+
if (!trimmed) {
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
if (trimmed === "~") {
|
|
152
|
+
return os.homedir();
|
|
153
|
+
}
|
|
154
|
+
if (trimmed.startsWith("~/")) {
|
|
155
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
156
|
+
}
|
|
157
|
+
return path.resolve(trimmed);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function resolveDefaultOpenClawHomeDir(env = process.env) {
|
|
161
|
+
const baseHome = env.OPENCLAW_HOME?.trim() || env.HOME?.trim() || os.homedir();
|
|
162
|
+
return path.resolve(baseHome);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveDefaultOpenClawStateDir(env = process.env) {
|
|
166
|
+
const override = env.OPENCLAW_STATE_DIR?.trim();
|
|
167
|
+
if (override) {
|
|
168
|
+
return path.resolve(override);
|
|
169
|
+
}
|
|
170
|
+
return path.join(resolveDefaultOpenClawHomeDir(env), ".openclaw");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveDefaultOpenClawConfigPath(env = process.env) {
|
|
174
|
+
const override = env.OPENCLAW_CONFIG_PATH?.trim();
|
|
175
|
+
if (override) {
|
|
176
|
+
return path.resolve(override);
|
|
177
|
+
}
|
|
178
|
+
return path.join(resolveDefaultOpenClawStateDir(env), "openclaw.json");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function resolveDefaultOpenClawWorkspaceDir(env = process.env) {
|
|
182
|
+
return path.join(resolveDefaultOpenClawStateDir(env), "workspace");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function pathExists(targetPath) {
|
|
186
|
+
try {
|
|
187
|
+
await fs.access(targetPath);
|
|
188
|
+
return true;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function readOpenClawConfig(configPath) {
|
|
195
|
+
if (!await pathExists(configPath)) {
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
199
|
+
if (!raw.trim()) {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON5.parse(raw);
|
|
204
|
+
if (!isRecord(parsed)) {
|
|
205
|
+
throw new Error("config root must be an object");
|
|
206
|
+
}
|
|
207
|
+
return parsed;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Failed to parse OpenClaw config at ${configPath}: ${
|
|
211
|
+
error instanceof Error ? error.message : String(error)
|
|
212
|
+
}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function ensureConfigFile(configPath, dryRun) {
|
|
218
|
+
const exists = await pathExists(configPath);
|
|
219
|
+
if (exists) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (dryRun) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
226
|
+
await fs.writeFile(configPath, "{}\n", "utf8");
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function createBackup(configPath, dryRun) {
|
|
231
|
+
if (!await pathExists(configPath)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
if (dryRun) {
|
|
235
|
+
return `${configPath}.teamclaw.bak`;
|
|
236
|
+
}
|
|
237
|
+
let backupPath = `${configPath}.teamclaw.bak`;
|
|
238
|
+
let index = 1;
|
|
239
|
+
while (await pathExists(backupPath)) {
|
|
240
|
+
backupPath = `${configPath}.teamclaw.${index}.bak`;
|
|
241
|
+
index += 1;
|
|
242
|
+
}
|
|
243
|
+
await fs.copyFile(configPath, backupPath);
|
|
244
|
+
return backupPath;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function writeConfig(configPath, config) {
|
|
248
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
249
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getExistingTeamClawConfig(config) {
|
|
253
|
+
if (!isRecord(config)) {
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
const plugins = isRecord(config.plugins) ? config.plugins : {};
|
|
257
|
+
const entries = isRecord(plugins.entries) ? plugins.entries : {};
|
|
258
|
+
const teamclaw = isRecord(entries[PLUGIN_ID]) ? entries[PLUGIN_ID] : {};
|
|
259
|
+
return isRecord(teamclaw.config) ? teamclaw.config : {};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveModelPrimaryValue(model) {
|
|
263
|
+
if (typeof model === "string") {
|
|
264
|
+
return model.trim();
|
|
265
|
+
}
|
|
266
|
+
if (!isRecord(model) || typeof model.primary !== "string") {
|
|
267
|
+
return "";
|
|
268
|
+
}
|
|
269
|
+
return model.primary.trim();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function applySelectedModel(existingModel, selectedModel) {
|
|
273
|
+
const nextPrimary = typeof selectedModel === "string" ? selectedModel.trim() : "";
|
|
274
|
+
if (!nextPrimary) {
|
|
275
|
+
return existingModel;
|
|
276
|
+
}
|
|
277
|
+
if (!isRecord(existingModel)) {
|
|
278
|
+
return nextPrimary;
|
|
279
|
+
}
|
|
280
|
+
if (resolveModelPrimaryValue(existingModel) === nextPrimary) {
|
|
281
|
+
return existingModel;
|
|
282
|
+
}
|
|
283
|
+
const nextModel = {
|
|
284
|
+
...existingModel,
|
|
285
|
+
primary: nextPrimary,
|
|
286
|
+
};
|
|
287
|
+
if (Array.isArray(existingModel.fallbacks)) {
|
|
288
|
+
nextModel.fallbacks = dedupeStrings(existingModel.fallbacks).filter((value) => value !== nextPrimary);
|
|
289
|
+
}
|
|
290
|
+
return nextModel;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getCurrentModel(config) {
|
|
294
|
+
const agents = isRecord(config.agents) ? config.agents : {};
|
|
295
|
+
const defaults = isRecord(agents.defaults) ? agents.defaults : {};
|
|
296
|
+
return resolveModelPrimaryValue(defaults.model);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function getCurrentWorkspacePath(config) {
|
|
300
|
+
const agents = isRecord(config.agents) ? config.agents : {};
|
|
301
|
+
const defaults = isRecord(agents.defaults) ? agents.defaults : {};
|
|
302
|
+
return typeof defaults.workspace === "string" ? expandUserPath(defaults.workspace) : "";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function dedupeStrings(values) {
|
|
306
|
+
return Array.from(new Set(values.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim())));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function extractModelOptions(config) {
|
|
310
|
+
const currentModel = getCurrentModel(config);
|
|
311
|
+
const models = [];
|
|
312
|
+
const rootModels = isRecord(config.models) ? config.models : {};
|
|
313
|
+
const providers = isRecord(rootModels.providers) ? rootModels.providers : {};
|
|
314
|
+
|
|
315
|
+
for (const [providerId, rawProvider] of Object.entries(providers)) {
|
|
316
|
+
if (!isRecord(rawProvider) || !Array.isArray(rawProvider.models)) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
for (const rawModel of rawProvider.models) {
|
|
320
|
+
if (!isRecord(rawModel) || typeof rawModel.id !== "string" || !rawModel.id.trim()) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const modelId = rawModel.id.trim();
|
|
324
|
+
const value = `${providerId}/${modelId}`;
|
|
325
|
+
const displayName = typeof rawModel.name === "string" && rawModel.name.trim()
|
|
326
|
+
? rawModel.name.trim()
|
|
327
|
+
: modelId;
|
|
328
|
+
models.push({
|
|
329
|
+
value,
|
|
330
|
+
label: `${displayName} (${value})`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
models.sort((left, right) => left.label.localeCompare(right.label));
|
|
336
|
+
|
|
337
|
+
const deduped = [];
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
for (const option of models) {
|
|
340
|
+
if (seen.has(option.value)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
deduped.push(option);
|
|
344
|
+
seen.add(option.value);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (currentModel && !seen.has(currentModel)) {
|
|
348
|
+
deduped.unshift({
|
|
349
|
+
value: currentModel,
|
|
350
|
+
label: `Keep current default model (${currentModel})`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
options: deduped,
|
|
356
|
+
currentModel,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class Prompter {
|
|
361
|
+
constructor({ yes }) {
|
|
362
|
+
this.yes = yes;
|
|
363
|
+
this.rl = yes ? null : createInterface({
|
|
364
|
+
input: process.stdin,
|
|
365
|
+
output: process.stdout,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
close() {
|
|
370
|
+
this.rl?.close();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
note(message = "") {
|
|
374
|
+
console.log(message);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async text({ message, defaultValue = "", allowEmpty = false, validate }) {
|
|
378
|
+
if (this.yes) {
|
|
379
|
+
const value = defaultValue ?? "";
|
|
380
|
+
if (!allowEmpty && !value) {
|
|
381
|
+
throw new Error(`Missing default value for ${message}; rerun without --yes.`);
|
|
382
|
+
}
|
|
383
|
+
console.log(`${message}: ${value || "<empty>"}`);
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
while (true) {
|
|
388
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
389
|
+
const raw = await this.rl.question(`${message}${suffix}: `);
|
|
390
|
+
const value = raw.trim() || defaultValue || "";
|
|
391
|
+
if (!allowEmpty && !value) {
|
|
392
|
+
console.log("A value is required.");
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const error = validate ? validate(value) : "";
|
|
396
|
+
if (error) {
|
|
397
|
+
console.log(error);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
return value;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async number({ message, defaultValue, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER }) {
|
|
405
|
+
const raw = await this.text({
|
|
406
|
+
message,
|
|
407
|
+
defaultValue: String(defaultValue),
|
|
408
|
+
validate: (value) => {
|
|
409
|
+
const parsed = Number(value);
|
|
410
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
411
|
+
return "Please enter an integer.";
|
|
412
|
+
}
|
|
413
|
+
if (parsed < min) {
|
|
414
|
+
return `Please enter a value >= ${min}.`;
|
|
415
|
+
}
|
|
416
|
+
if (parsed > max) {
|
|
417
|
+
return `Please enter a value <= ${max}.`;
|
|
418
|
+
}
|
|
419
|
+
return "";
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
return Number(raw);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async confirm({ message, defaultValue = true }) {
|
|
426
|
+
if (this.yes) {
|
|
427
|
+
console.log(`${message}: ${defaultValue ? "yes" : "no"}`);
|
|
428
|
+
return defaultValue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
while (true) {
|
|
432
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
433
|
+
const raw = (await this.rl.question(`${message} [${hint}]: `)).trim().toLowerCase();
|
|
434
|
+
if (!raw) {
|
|
435
|
+
return defaultValue;
|
|
436
|
+
}
|
|
437
|
+
if (raw === "y" || raw === "yes") {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
if (raw === "n" || raw === "no") {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
console.log('Please answer "y" or "n".');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async select({ message, options, defaultValue }) {
|
|
448
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
449
|
+
throw new Error(`No options available for ${message}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const defaultIndex = Math.max(0, options.findIndex((option) => option.value === defaultValue));
|
|
453
|
+
if (this.yes) {
|
|
454
|
+
const choice = options[defaultIndex] ?? options[0];
|
|
455
|
+
console.log(`${message}: ${choice.label}`);
|
|
456
|
+
return choice.value;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
while (true) {
|
|
460
|
+
console.log(`\n${message}`);
|
|
461
|
+
options.forEach((option, index) => {
|
|
462
|
+
const marker = index === defaultIndex ? " (default)" : "";
|
|
463
|
+
const hint = option.hint ? ` — ${option.hint}` : "";
|
|
464
|
+
console.log(` ${index + 1}. ${option.label}${hint}${marker}`);
|
|
465
|
+
});
|
|
466
|
+
const raw = (await this.rl.question(`Selection [${defaultIndex + 1}]: `)).trim();
|
|
467
|
+
if (!raw) {
|
|
468
|
+
return options[defaultIndex]?.value ?? options[0].value;
|
|
469
|
+
}
|
|
470
|
+
const asNumber = Number(raw);
|
|
471
|
+
if (Number.isInteger(asNumber) && asNumber >= 1 && asNumber <= options.length) {
|
|
472
|
+
return options[asNumber - 1].value;
|
|
473
|
+
}
|
|
474
|
+
const byValue = options.find((option) => option.value === raw);
|
|
475
|
+
if (byValue) {
|
|
476
|
+
return byValue.value;
|
|
477
|
+
}
|
|
478
|
+
console.log("Please choose one of the listed options.");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function parseRoleList(raw) {
|
|
484
|
+
const values = dedupeStrings(String(raw || "").split(",").map((entry) => entry.trim()));
|
|
485
|
+
const validIds = new Set(ROLE_OPTIONS.map((option) => option.value));
|
|
486
|
+
const invalid = values.filter((value) => !validIds.has(value));
|
|
487
|
+
return {
|
|
488
|
+
values,
|
|
489
|
+
invalid,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function promptRoleList(prompter, message, defaultRoles) {
|
|
494
|
+
const defaultValue = defaultRoles.join(",");
|
|
495
|
+
if (!prompter.yes) {
|
|
496
|
+
console.log(`Available roles: ${ROLE_OPTIONS.map((option) => `${option.value} (${option.label})`).join(", ")}`);
|
|
497
|
+
}
|
|
498
|
+
const raw = await prompter.text({
|
|
499
|
+
message,
|
|
500
|
+
defaultValue,
|
|
501
|
+
validate: (value) => {
|
|
502
|
+
const parsed = parseRoleList(value);
|
|
503
|
+
if (parsed.values.length === 0) {
|
|
504
|
+
return "Please choose at least one role.";
|
|
505
|
+
}
|
|
506
|
+
if (parsed.invalid.length > 0) {
|
|
507
|
+
return `Unknown role ids: ${parsed.invalid.join(", ")}`;
|
|
508
|
+
}
|
|
509
|
+
return "";
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
return parseRoleList(raw).values;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function buildStartCommand(configPath) {
|
|
516
|
+
const defaultPath = resolveDefaultOpenClawConfigPath();
|
|
517
|
+
if (path.resolve(configPath) === path.resolve(defaultPath)) {
|
|
518
|
+
return "openclaw gateway run";
|
|
519
|
+
}
|
|
520
|
+
return `OPENCLAW_CONFIG_PATH=${shellEscape(configPath)} openclaw gateway run`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function shellEscape(value) {
|
|
524
|
+
if (!value) {
|
|
525
|
+
return "''";
|
|
526
|
+
}
|
|
527
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function installPluginWithCommand(command, args, env) {
|
|
531
|
+
const result = spawnSync(command, args, {
|
|
532
|
+
stdio: "inherit",
|
|
533
|
+
env,
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
status: result.status ?? 1,
|
|
537
|
+
signal: result.signal ?? null,
|
|
538
|
+
error: result.error ?? null,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function attemptPluginInstall({ configPath }) {
|
|
543
|
+
const env = {
|
|
544
|
+
...process.env,
|
|
545
|
+
OPENCLAW_CONFIG_PATH: configPath,
|
|
546
|
+
};
|
|
547
|
+
const candidates = [
|
|
548
|
+
{
|
|
549
|
+
label: "openclaw",
|
|
550
|
+
command: "openclaw",
|
|
551
|
+
args: ["plugins", "install", PACKAGE_NAME],
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
label: "npm exec fallback",
|
|
555
|
+
command: "npm",
|
|
556
|
+
args: ["exec", "-y", "openclaw@latest", "--", "plugins", "install", PACKAGE_NAME],
|
|
557
|
+
},
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
561
|
+
const candidate = candidates[index];
|
|
562
|
+
console.log(`\nInstalling ${PACKAGE_NAME} with ${candidate.label}...`);
|
|
563
|
+
const result = installPluginWithCommand(candidate.command, candidate.args, env);
|
|
564
|
+
if (result.status === 0 && !result.error) {
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
method: candidate.label,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
|
|
571
|
+
if (errorCode === "ENOENT" && index < candidates.length - 1) {
|
|
572
|
+
console.log(`${candidate.command} was not found. Trying the npm exec fallback...`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const detail = result.error
|
|
576
|
+
? result.error.message
|
|
577
|
+
: result.signal
|
|
578
|
+
? `terminated by signal ${result.signal}`
|
|
579
|
+
: `exited with code ${result.status}`;
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
error: `${candidate.label} failed: ${detail}`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
ok: false,
|
|
588
|
+
error: "No install command was available.",
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function collectInstallChoices(config, prompter) {
|
|
593
|
+
const existingTeamClaw = getExistingTeamClawConfig(config);
|
|
594
|
+
const existingMode = typeof existingTeamClaw.mode === "string" ? existingTeamClaw.mode.trim() : "";
|
|
595
|
+
const modeDefault = existingMode === "worker" ? "worker" : "single-local";
|
|
596
|
+
|
|
597
|
+
const installMode = await prompter.select({
|
|
598
|
+
message: "Choose an installation mode",
|
|
599
|
+
options: INSTALL_MODE_OPTIONS,
|
|
600
|
+
defaultValue: modeDefault,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const modelInfo = extractModelOptions(config);
|
|
604
|
+
let selectedModel = modelInfo.currentModel;
|
|
605
|
+
if (modelInfo.options.length > 0) {
|
|
606
|
+
selectedModel = await prompter.select({
|
|
607
|
+
message: "Choose the OpenClaw default model TeamClaw should use",
|
|
608
|
+
options: modelInfo.options,
|
|
609
|
+
defaultValue: modelInfo.currentModel || modelInfo.options[0].value,
|
|
610
|
+
});
|
|
611
|
+
} else {
|
|
612
|
+
selectedModel = await prompter.text({
|
|
613
|
+
message: "Enter the OpenClaw default model (provider/model-id) or leave empty to keep it unchanged",
|
|
614
|
+
defaultValue: modelInfo.currentModel,
|
|
615
|
+
allowEmpty: true,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const teamName = await prompter.text({
|
|
620
|
+
message: "Team name",
|
|
621
|
+
defaultValue:
|
|
622
|
+
typeof existingTeamClaw.teamName === "string" && existingTeamClaw.teamName.trim()
|
|
623
|
+
? existingTeamClaw.teamName.trim()
|
|
624
|
+
: DEFAULT_TEAM_NAME,
|
|
625
|
+
});
|
|
626
|
+
const workspacePath = expandUserPath(await prompter.text({
|
|
627
|
+
message: "OpenClaw workspace directory",
|
|
628
|
+
defaultValue: getCurrentWorkspacePath(config) || resolveDefaultOpenClawWorkspaceDir(),
|
|
629
|
+
}));
|
|
630
|
+
|
|
631
|
+
if (installMode === "worker") {
|
|
632
|
+
const workerRole = await prompter.select({
|
|
633
|
+
message: "Choose the worker role for this node",
|
|
634
|
+
options: ROLE_OPTIONS,
|
|
635
|
+
defaultValue:
|
|
636
|
+
typeof existingTeamClaw.role === "string" && existingTeamClaw.role.trim()
|
|
637
|
+
? existingTeamClaw.role.trim()
|
|
638
|
+
: "developer",
|
|
639
|
+
});
|
|
640
|
+
const workerPort = await prompter.number({
|
|
641
|
+
message: "Worker API port",
|
|
642
|
+
defaultValue:
|
|
643
|
+
typeof existingTeamClaw.port === "number" && existingTeamClaw.port >= 1
|
|
644
|
+
? existingTeamClaw.port
|
|
645
|
+
: DEFAULT_WORKER_PORT,
|
|
646
|
+
min: 1,
|
|
647
|
+
max: 65535,
|
|
648
|
+
});
|
|
649
|
+
const controllerUrl = await prompter.text({
|
|
650
|
+
message: "Controller URL",
|
|
651
|
+
defaultValue:
|
|
652
|
+
typeof existingTeamClaw.controllerUrl === "string" && existingTeamClaw.controllerUrl.trim()
|
|
653
|
+
? existingTeamClaw.controllerUrl.trim()
|
|
654
|
+
: "http://127.0.0.1:9527",
|
|
655
|
+
validate: (value) => value.startsWith("http://") || value.startsWith("https://")
|
|
656
|
+
? ""
|
|
657
|
+
: 'Controller URL must start with "http://" or "https://".',
|
|
658
|
+
});
|
|
659
|
+
return {
|
|
660
|
+
installMode,
|
|
661
|
+
selectedModel,
|
|
662
|
+
teamName,
|
|
663
|
+
workspacePath,
|
|
664
|
+
workerRole,
|
|
665
|
+
workerPort,
|
|
666
|
+
controllerUrl,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const controllerPort = await prompter.number({
|
|
671
|
+
message: "Controller API port",
|
|
672
|
+
defaultValue:
|
|
673
|
+
typeof existingTeamClaw.port === "number" && existingTeamClaw.port >= 1
|
|
674
|
+
? existingTeamClaw.port
|
|
675
|
+
: DEFAULT_CONTROLLER_PORT,
|
|
676
|
+
min: 1,
|
|
677
|
+
max: 65535,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
if (installMode === "single-local") {
|
|
681
|
+
const localRoles = await promptRoleList(
|
|
682
|
+
prompter,
|
|
683
|
+
"Local roles to run in this OpenClaw instance (comma-separated)",
|
|
684
|
+
Array.isArray(existingTeamClaw.localRoles) && existingTeamClaw.localRoles.length > 0
|
|
685
|
+
? existingTeamClaw.localRoles
|
|
686
|
+
: DEFAULT_LOCAL_ROLES,
|
|
687
|
+
);
|
|
688
|
+
return {
|
|
689
|
+
installMode,
|
|
690
|
+
selectedModel,
|
|
691
|
+
teamName,
|
|
692
|
+
workspacePath,
|
|
693
|
+
controllerPort,
|
|
694
|
+
localRoles,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (installMode === "controller-manual") {
|
|
699
|
+
return {
|
|
700
|
+
installMode,
|
|
701
|
+
selectedModel,
|
|
702
|
+
teamName,
|
|
703
|
+
workspacePath,
|
|
704
|
+
controllerPort,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const provisioningRoles = await promptRoleList(
|
|
709
|
+
prompter,
|
|
710
|
+
"On-demand roles to launch (comma-separated)",
|
|
711
|
+
Array.isArray(existingTeamClaw.workerProvisioningRoles) && existingTeamClaw.workerProvisioningRoles.length > 0
|
|
712
|
+
? existingTeamClaw.workerProvisioningRoles
|
|
713
|
+
: DEFAULT_PROVISIONING_ROLES,
|
|
714
|
+
);
|
|
715
|
+
const maxPerRole = await prompter.number({
|
|
716
|
+
message: "Maximum on-demand workers per role",
|
|
717
|
+
defaultValue:
|
|
718
|
+
typeof existingTeamClaw.workerProvisioningMaxPerRole === "number" && existingTeamClaw.workerProvisioningMaxPerRole >= 1
|
|
719
|
+
? existingTeamClaw.workerProvisioningMaxPerRole
|
|
720
|
+
: 2,
|
|
721
|
+
min: 1,
|
|
722
|
+
max: 50,
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
if (installMode === "controller-process") {
|
|
726
|
+
return {
|
|
727
|
+
installMode,
|
|
728
|
+
selectedModel,
|
|
729
|
+
teamName,
|
|
730
|
+
workspacePath,
|
|
731
|
+
controllerPort,
|
|
732
|
+
provisioningRoles,
|
|
733
|
+
maxPerRole,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (installMode === "controller-docker") {
|
|
738
|
+
const controllerUrl = await prompter.text({
|
|
739
|
+
message: "Controller URL visible from Docker containers",
|
|
740
|
+
defaultValue:
|
|
741
|
+
typeof existingTeamClaw.workerProvisioningControllerUrl === "string" && existingTeamClaw.workerProvisioningControllerUrl.trim()
|
|
742
|
+
? existingTeamClaw.workerProvisioningControllerUrl.trim()
|
|
743
|
+
: "http://host.docker.internal:9527",
|
|
744
|
+
validate: (value) => value.startsWith("http://") || value.startsWith("https://")
|
|
745
|
+
? ""
|
|
746
|
+
: 'Controller URL must start with "http://" or "https://".',
|
|
747
|
+
});
|
|
748
|
+
const workerImage = await prompter.text({
|
|
749
|
+
message: "Docker/Kubernetes worker image",
|
|
750
|
+
defaultValue:
|
|
751
|
+
typeof existingTeamClaw.workerProvisioningImage === "string" && existingTeamClaw.workerProvisioningImage.trim()
|
|
752
|
+
? existingTeamClaw.workerProvisioningImage.trim()
|
|
753
|
+
: DEFAULT_TEAMCLAW_IMAGE,
|
|
754
|
+
});
|
|
755
|
+
const dockerWorkspaceVolume = await prompter.text({
|
|
756
|
+
message: "Docker workspace volume or host path (leave empty for ephemeral workspaces)",
|
|
757
|
+
defaultValue:
|
|
758
|
+
typeof existingTeamClaw.workerProvisioningDockerWorkspaceVolume === "string"
|
|
759
|
+
? existingTeamClaw.workerProvisioningDockerWorkspaceVolume.trim()
|
|
760
|
+
: "teamclaw-workspaces",
|
|
761
|
+
allowEmpty: true,
|
|
762
|
+
});
|
|
763
|
+
return {
|
|
764
|
+
installMode,
|
|
765
|
+
selectedModel,
|
|
766
|
+
teamName,
|
|
767
|
+
workspacePath,
|
|
768
|
+
controllerPort,
|
|
769
|
+
provisioningRoles,
|
|
770
|
+
maxPerRole,
|
|
771
|
+
controllerUrl,
|
|
772
|
+
workerImage,
|
|
773
|
+
dockerWorkspaceVolume,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const controllerUrl = await prompter.text({
|
|
778
|
+
message: "Controller URL visible from Kubernetes pods",
|
|
779
|
+
defaultValue:
|
|
780
|
+
typeof existingTeamClaw.workerProvisioningControllerUrl === "string" && existingTeamClaw.workerProvisioningControllerUrl.trim()
|
|
781
|
+
? existingTeamClaw.workerProvisioningControllerUrl.trim()
|
|
782
|
+
: "http://teamclaw-controller.default.svc.cluster.local:9527",
|
|
783
|
+
validate: (value) => value.startsWith("http://") || value.startsWith("https://")
|
|
784
|
+
? ""
|
|
785
|
+
: 'Controller URL must start with "http://" or "https://".',
|
|
786
|
+
});
|
|
787
|
+
const workerImage = await prompter.text({
|
|
788
|
+
message: "Docker/Kubernetes worker image",
|
|
789
|
+
defaultValue:
|
|
790
|
+
typeof existingTeamClaw.workerProvisioningImage === "string" && existingTeamClaw.workerProvisioningImage.trim()
|
|
791
|
+
? existingTeamClaw.workerProvisioningImage.trim()
|
|
792
|
+
: DEFAULT_TEAMCLAW_IMAGE,
|
|
793
|
+
});
|
|
794
|
+
const namespace = await prompter.text({
|
|
795
|
+
message: "Kubernetes namespace",
|
|
796
|
+
defaultValue:
|
|
797
|
+
typeof existingTeamClaw.workerProvisioningKubernetesNamespace === "string" &&
|
|
798
|
+
existingTeamClaw.workerProvisioningKubernetesNamespace.trim()
|
|
799
|
+
? existingTeamClaw.workerProvisioningKubernetesNamespace.trim()
|
|
800
|
+
: "default",
|
|
801
|
+
});
|
|
802
|
+
const serviceAccount = await prompter.text({
|
|
803
|
+
message: "Kubernetes service account",
|
|
804
|
+
defaultValue:
|
|
805
|
+
typeof existingTeamClaw.workerProvisioningKubernetesServiceAccount === "string" &&
|
|
806
|
+
existingTeamClaw.workerProvisioningKubernetesServiceAccount.trim()
|
|
807
|
+
? existingTeamClaw.workerProvisioningKubernetesServiceAccount.trim()
|
|
808
|
+
: "teamclaw-worker",
|
|
809
|
+
});
|
|
810
|
+
const kubernetesWorkspacePersistentVolumeClaim = await prompter.text({
|
|
811
|
+
message: "Kubernetes workspace PVC (leave empty for ephemeral workspaces)",
|
|
812
|
+
defaultValue:
|
|
813
|
+
typeof existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim === "string"
|
|
814
|
+
? existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim.trim()
|
|
815
|
+
: "",
|
|
816
|
+
allowEmpty: true,
|
|
817
|
+
});
|
|
818
|
+
return {
|
|
819
|
+
installMode,
|
|
820
|
+
selectedModel,
|
|
821
|
+
teamName,
|
|
822
|
+
workspacePath,
|
|
823
|
+
controllerPort,
|
|
824
|
+
provisioningRoles,
|
|
825
|
+
maxPerRole,
|
|
826
|
+
controllerUrl,
|
|
827
|
+
workerImage,
|
|
828
|
+
namespace,
|
|
829
|
+
serviceAccount,
|
|
830
|
+
kubernetesWorkspacePersistentVolumeClaim,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function applyInstallerChoices(config, choices) {
|
|
835
|
+
const next = isRecord(config) ? structuredClone(config) : {};
|
|
836
|
+
const gateway = ensureRecord(next, "gateway");
|
|
837
|
+
if (typeof gateway.port !== "number" || gateway.port < 1) {
|
|
838
|
+
gateway.port = DEFAULT_GATEWAY_PORT;
|
|
839
|
+
}
|
|
840
|
+
if (typeof gateway.mode !== "string" || !gateway.mode.trim()) {
|
|
841
|
+
gateway.mode = "local";
|
|
842
|
+
}
|
|
843
|
+
if (typeof gateway.bind !== "string" || !gateway.bind.trim()) {
|
|
844
|
+
gateway.bind = "lan";
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const agents = ensureRecord(next, "agents");
|
|
848
|
+
const agentDefaults = ensureRecord(agents, "defaults");
|
|
849
|
+
if (choices.selectedModel) {
|
|
850
|
+
agentDefaults.model = applySelectedModel(agentDefaults.model, choices.selectedModel);
|
|
851
|
+
}
|
|
852
|
+
if (choices.workspacePath) {
|
|
853
|
+
agentDefaults.workspace = choices.workspacePath;
|
|
854
|
+
}
|
|
855
|
+
const existingTimeout = typeof agentDefaults.timeoutSeconds === "number"
|
|
856
|
+
? agentDefaults.timeoutSeconds
|
|
857
|
+
: 0;
|
|
858
|
+
if (!Number.isFinite(existingTimeout) || existingTimeout < DEFAULT_AGENT_TIMEOUT_SECONDS) {
|
|
859
|
+
agentDefaults.timeoutSeconds = DEFAULT_AGENT_TIMEOUT_SECONDS;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const plugins = ensureRecord(next, "plugins");
|
|
863
|
+
plugins.enabled = true;
|
|
864
|
+
const entries = ensureRecord(plugins, "entries");
|
|
865
|
+
const teamclawEntry = ensureRecord(entries, PLUGIN_ID);
|
|
866
|
+
teamclawEntry.enabled = true;
|
|
867
|
+
const teamclawConfig = {
|
|
868
|
+
...(isRecord(teamclawEntry.config) ? teamclawEntry.config : {}),
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
teamclawConfig.teamName = choices.teamName;
|
|
872
|
+
teamclawConfig.heartbeatIntervalMs = typeof teamclawConfig.heartbeatIntervalMs === "number" &&
|
|
873
|
+
teamclawConfig.heartbeatIntervalMs >= 1_000
|
|
874
|
+
? teamclawConfig.heartbeatIntervalMs
|
|
875
|
+
: 10_000;
|
|
876
|
+
teamclawConfig.taskTimeoutMs = Math.max(
|
|
877
|
+
typeof teamclawConfig.taskTimeoutMs === "number" ? teamclawConfig.taskTimeoutMs : 0,
|
|
878
|
+
DEFAULT_TASK_TIMEOUT_MS,
|
|
879
|
+
);
|
|
880
|
+
teamclawConfig.gitEnabled = typeof teamclawConfig.gitEnabled === "boolean" ? teamclawConfig.gitEnabled : true;
|
|
881
|
+
teamclawConfig.gitDefaultBranch = typeof teamclawConfig.gitDefaultBranch === "string" && teamclawConfig.gitDefaultBranch.trim()
|
|
882
|
+
? teamclawConfig.gitDefaultBranch.trim()
|
|
883
|
+
: "main";
|
|
884
|
+
teamclawConfig.gitAuthorName = typeof teamclawConfig.gitAuthorName === "string" && teamclawConfig.gitAuthorName.trim()
|
|
885
|
+
? teamclawConfig.gitAuthorName.trim()
|
|
886
|
+
: "TeamClaw";
|
|
887
|
+
teamclawConfig.gitAuthorEmail = typeof teamclawConfig.gitAuthorEmail === "string" && teamclawConfig.gitAuthorEmail.trim()
|
|
888
|
+
? teamclawConfig.gitAuthorEmail.trim()
|
|
889
|
+
: "teamclaw@local";
|
|
890
|
+
|
|
891
|
+
teamclawConfig.workerProvisioningMinPerRole = 0;
|
|
892
|
+
teamclawConfig.workerProvisioningIdleTtlMs = typeof teamclawConfig.workerProvisioningIdleTtlMs === "number" &&
|
|
893
|
+
teamclawConfig.workerProvisioningIdleTtlMs >= 1_000
|
|
894
|
+
? teamclawConfig.workerProvisioningIdleTtlMs
|
|
895
|
+
: 120_000;
|
|
896
|
+
teamclawConfig.workerProvisioningStartupTimeoutMs = typeof teamclawConfig.workerProvisioningStartupTimeoutMs === "number" &&
|
|
897
|
+
teamclawConfig.workerProvisioningStartupTimeoutMs >= 1_000
|
|
898
|
+
? teamclawConfig.workerProvisioningStartupTimeoutMs
|
|
899
|
+
: 120_000;
|
|
900
|
+
teamclawConfig.workerProvisioningDockerNetwork = typeof teamclawConfig.workerProvisioningDockerNetwork === "string"
|
|
901
|
+
? teamclawConfig.workerProvisioningDockerNetwork.trim()
|
|
902
|
+
: "";
|
|
903
|
+
teamclawConfig.workerProvisioningDockerMounts = Array.isArray(teamclawConfig.workerProvisioningDockerMounts)
|
|
904
|
+
? teamclawConfig.workerProvisioningDockerMounts.filter((value) => typeof value === "string" && value.trim())
|
|
905
|
+
: [];
|
|
906
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = typeof teamclawConfig.workerProvisioningWorkspaceRoot === "string"
|
|
907
|
+
? teamclawConfig.workerProvisioningWorkspaceRoot.trim()
|
|
908
|
+
: "";
|
|
909
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume =
|
|
910
|
+
typeof teamclawConfig.workerProvisioningDockerWorkspaceVolume === "string"
|
|
911
|
+
? teamclawConfig.workerProvisioningDockerWorkspaceVolume.trim()
|
|
912
|
+
: "";
|
|
913
|
+
teamclawConfig.workerProvisioningKubernetesContext =
|
|
914
|
+
typeof teamclawConfig.workerProvisioningKubernetesContext === "string"
|
|
915
|
+
? teamclawConfig.workerProvisioningKubernetesContext.trim()
|
|
916
|
+
: "";
|
|
917
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim =
|
|
918
|
+
typeof teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim === "string"
|
|
919
|
+
? teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim.trim()
|
|
920
|
+
: "";
|
|
921
|
+
teamclawConfig.workerProvisioningKubernetesLabels = isRecord(teamclawConfig.workerProvisioningKubernetesLabels)
|
|
922
|
+
? teamclawConfig.workerProvisioningKubernetesLabels
|
|
923
|
+
: {};
|
|
924
|
+
teamclawConfig.workerProvisioningKubernetesAnnotations = isRecord(teamclawConfig.workerProvisioningKubernetesAnnotations)
|
|
925
|
+
? teamclawConfig.workerProvisioningKubernetesAnnotations
|
|
926
|
+
: {};
|
|
927
|
+
|
|
928
|
+
if (choices.installMode === "worker") {
|
|
929
|
+
teamclawConfig.mode = "worker";
|
|
930
|
+
teamclawConfig.port = choices.workerPort;
|
|
931
|
+
teamclawConfig.role = choices.workerRole;
|
|
932
|
+
teamclawConfig.controllerUrl = choices.controllerUrl;
|
|
933
|
+
teamclawConfig.localRoles = [];
|
|
934
|
+
teamclawConfig.workerProvisioningType = "none";
|
|
935
|
+
teamclawConfig.workerProvisioningControllerUrl = "";
|
|
936
|
+
teamclawConfig.workerProvisioningRoles = [];
|
|
937
|
+
teamclawConfig.workerProvisioningMaxPerRole = 1;
|
|
938
|
+
teamclawConfig.workerProvisioningImage = "";
|
|
939
|
+
teamclawConfig.workerProvisioningPassEnv = [];
|
|
940
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
941
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = "";
|
|
942
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume = "";
|
|
943
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
944
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
945
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
|
|
946
|
+
} else {
|
|
947
|
+
teamclawConfig.mode = "controller";
|
|
948
|
+
teamclawConfig.port = choices.controllerPort;
|
|
949
|
+
teamclawConfig.controllerUrl = "";
|
|
950
|
+
delete teamclawConfig.role;
|
|
951
|
+
|
|
952
|
+
if (choices.installMode === "single-local") {
|
|
953
|
+
teamclawConfig.localRoles = choices.localRoles;
|
|
954
|
+
teamclawConfig.workerProvisioningType = "none";
|
|
955
|
+
teamclawConfig.workerProvisioningControllerUrl = "";
|
|
956
|
+
teamclawConfig.workerProvisioningRoles = [];
|
|
957
|
+
teamclawConfig.workerProvisioningMaxPerRole = 1;
|
|
958
|
+
teamclawConfig.workerProvisioningImage = "";
|
|
959
|
+
teamclawConfig.workerProvisioningPassEnv = [];
|
|
960
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
961
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = "";
|
|
962
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume = "";
|
|
963
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
964
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
965
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
|
|
966
|
+
} else if (choices.installMode === "controller-manual") {
|
|
967
|
+
teamclawConfig.localRoles = [];
|
|
968
|
+
teamclawConfig.workerProvisioningType = "none";
|
|
969
|
+
teamclawConfig.workerProvisioningControllerUrl = "";
|
|
970
|
+
teamclawConfig.workerProvisioningRoles = [];
|
|
971
|
+
teamclawConfig.workerProvisioningMaxPerRole = 1;
|
|
972
|
+
teamclawConfig.workerProvisioningImage = "";
|
|
973
|
+
teamclawConfig.workerProvisioningPassEnv = [];
|
|
974
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
975
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = "";
|
|
976
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume = "";
|
|
977
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
978
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
979
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
|
|
980
|
+
} else if (choices.installMode === "controller-process") {
|
|
981
|
+
teamclawConfig.localRoles = [];
|
|
982
|
+
teamclawConfig.workerProvisioningType = "process";
|
|
983
|
+
teamclawConfig.workerProvisioningControllerUrl = "";
|
|
984
|
+
teamclawConfig.workerProvisioningRoles = choices.provisioningRoles;
|
|
985
|
+
teamclawConfig.workerProvisioningMaxPerRole = choices.maxPerRole;
|
|
986
|
+
teamclawConfig.workerProvisioningImage = "";
|
|
987
|
+
teamclawConfig.workerProvisioningPassEnv = [];
|
|
988
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
989
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = "";
|
|
990
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume = "";
|
|
991
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
992
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
993
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
|
|
994
|
+
} else if (choices.installMode === "controller-docker") {
|
|
995
|
+
teamclawConfig.localRoles = [];
|
|
996
|
+
teamclawConfig.workerProvisioningType = "docker";
|
|
997
|
+
teamclawConfig.workerProvisioningControllerUrl = choices.controllerUrl;
|
|
998
|
+
teamclawConfig.workerProvisioningRoles = choices.provisioningRoles;
|
|
999
|
+
teamclawConfig.workerProvisioningMaxPerRole = choices.maxPerRole;
|
|
1000
|
+
teamclawConfig.workerProvisioningImage = choices.workerImage;
|
|
1001
|
+
teamclawConfig.workerProvisioningPassEnv = ["DOCKER_HOST", "DOCKER_CONFIG", "KUBECONFIG", "NO_PROXY"];
|
|
1002
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
1003
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = choices.dockerWorkspaceVolume ? "/workspace-root" : "";
|
|
1004
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume = choices.dockerWorkspaceVolume;
|
|
1005
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = "default";
|
|
1006
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
|
|
1007
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
|
|
1008
|
+
} else if (choices.installMode === "controller-kubernetes") {
|
|
1009
|
+
teamclawConfig.localRoles = [];
|
|
1010
|
+
teamclawConfig.workerProvisioningType = "kubernetes";
|
|
1011
|
+
teamclawConfig.workerProvisioningControllerUrl = choices.controllerUrl;
|
|
1012
|
+
teamclawConfig.workerProvisioningRoles = choices.provisioningRoles;
|
|
1013
|
+
teamclawConfig.workerProvisioningMaxPerRole = choices.maxPerRole;
|
|
1014
|
+
teamclawConfig.workerProvisioningImage = choices.workerImage;
|
|
1015
|
+
teamclawConfig.workerProvisioningPassEnv = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"];
|
|
1016
|
+
teamclawConfig.workerProvisioningExtraEnv = {};
|
|
1017
|
+
teamclawConfig.workerProvisioningWorkspaceRoot = choices.kubernetesWorkspacePersistentVolumeClaim
|
|
1018
|
+
? "/workspace-root"
|
|
1019
|
+
: "";
|
|
1020
|
+
teamclawConfig.workerProvisioningDockerWorkspaceVolume = "";
|
|
1021
|
+
teamclawConfig.workerProvisioningKubernetesNamespace = choices.namespace;
|
|
1022
|
+
teamclawConfig.workerProvisioningKubernetesServiceAccount = choices.serviceAccount;
|
|
1023
|
+
teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim =
|
|
1024
|
+
choices.kubernetesWorkspacePersistentVolumeClaim;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
teamclawEntry.config = teamclawConfig;
|
|
1029
|
+
entries[PLUGIN_ID] = teamclawEntry;
|
|
1030
|
+
plugins.entries = entries;
|
|
1031
|
+
next.plugins = plugins;
|
|
1032
|
+
next.agents = agents;
|
|
1033
|
+
next.gateway = gateway;
|
|
1034
|
+
return next;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function buildSummaryLines(params) {
|
|
1038
|
+
const lines = [
|
|
1039
|
+
`Config path: ${params.configPath}`,
|
|
1040
|
+
`Install mode: ${params.choices.installMode}`,
|
|
1041
|
+
`Workspace: ${params.choices.workspacePath}`,
|
|
1042
|
+
];
|
|
1043
|
+
if (params.choices.selectedModel) {
|
|
1044
|
+
lines.push(`Default model: ${params.choices.selectedModel}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (params.backupPath) {
|
|
1047
|
+
lines.push(`Backup: ${params.backupPath}`);
|
|
1048
|
+
}
|
|
1049
|
+
if (params.pluginInstallStatus === "installed") {
|
|
1050
|
+
lines.push(`Plugin install: completed via ${params.pluginInstallMethod}`);
|
|
1051
|
+
} else if (params.pluginInstallStatus === "skipped") {
|
|
1052
|
+
lines.push("Plugin install: skipped");
|
|
1053
|
+
} else if (params.pluginInstallError) {
|
|
1054
|
+
lines.push(`Plugin install: ${params.pluginInstallError}`);
|
|
1055
|
+
}
|
|
1056
|
+
lines.push(`Start command: ${buildStartCommand(params.configPath)}`);
|
|
1057
|
+
|
|
1058
|
+
if (params.choices.installMode === "single-local") {
|
|
1059
|
+
lines.push(`Open UI: http://127.0.0.1:${params.choices.controllerPort}/ui`);
|
|
1060
|
+
}
|
|
1061
|
+
if (params.choices.installMode === "controller-docker" || params.choices.installMode === "controller-kubernetes") {
|
|
1062
|
+
lines.push(`Provisioning image: ${params.choices.workerImage}`);
|
|
1063
|
+
}
|
|
1064
|
+
if (params.choices.installMode === "controller-docker" && params.choices.dockerWorkspaceVolume) {
|
|
1065
|
+
lines.push(`Docker workspace volume: ${params.choices.dockerWorkspaceVolume}`);
|
|
1066
|
+
}
|
|
1067
|
+
if (
|
|
1068
|
+
params.choices.installMode === "controller-kubernetes" &&
|
|
1069
|
+
params.choices.kubernetesWorkspacePersistentVolumeClaim
|
|
1070
|
+
) {
|
|
1071
|
+
lines.push(`Kubernetes workspace PVC: ${params.choices.kubernetesWorkspacePersistentVolumeClaim}`);
|
|
1072
|
+
}
|
|
1073
|
+
if (params.choices.installMode === "worker") {
|
|
1074
|
+
lines.push(`Worker role: ${params.choices.workerRole}`);
|
|
1075
|
+
lines.push(`Controller URL: ${params.choices.controllerUrl}`);
|
|
1076
|
+
}
|
|
1077
|
+
return lines;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function runInstall(options) {
|
|
1081
|
+
if (!options.yes && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
1082
|
+
throw new Error("Interactive install requires a TTY. Re-run with --yes or in a terminal.");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const prompter = new Prompter({ yes: options.yes });
|
|
1086
|
+
try {
|
|
1087
|
+
const configPath = expandUserPath(
|
|
1088
|
+
options.configPath || await prompter.text({
|
|
1089
|
+
message: "OpenClaw config path",
|
|
1090
|
+
defaultValue: resolveDefaultOpenClawConfigPath(),
|
|
1091
|
+
}),
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
if (!configPath) {
|
|
1095
|
+
throw new Error("OpenClaw config path is required.");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
let backupPath = null;
|
|
1099
|
+
if (await pathExists(configPath)) {
|
|
1100
|
+
backupPath = await createBackup(configPath, options.dryRun);
|
|
1101
|
+
}
|
|
1102
|
+
const configWasCreated = await ensureConfigFile(configPath, options.dryRun);
|
|
1103
|
+
if (configWasCreated) {
|
|
1104
|
+
prompter.note(options.dryRun
|
|
1105
|
+
? `Would create ${configPath}`
|
|
1106
|
+
: `Created ${configPath}`);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
let pluginInstallStatus = "skipped";
|
|
1110
|
+
let pluginInstallMethod = "";
|
|
1111
|
+
let pluginInstallError = "";
|
|
1112
|
+
if (!options.skipPluginInstall && !options.dryRun) {
|
|
1113
|
+
const installResult = attemptPluginInstall({ configPath });
|
|
1114
|
+
if (installResult.ok) {
|
|
1115
|
+
pluginInstallStatus = "installed";
|
|
1116
|
+
pluginInstallMethod = installResult.method;
|
|
1117
|
+
} else {
|
|
1118
|
+
pluginInstallStatus = "failed";
|
|
1119
|
+
pluginInstallError = installResult.error;
|
|
1120
|
+
const continueWithoutPluginInstall = await prompter.confirm({
|
|
1121
|
+
message: `Plugin installation failed (${installResult.error}). Continue configuring openclaw.json anyway?`,
|
|
1122
|
+
defaultValue: true,
|
|
1123
|
+
});
|
|
1124
|
+
if (!continueWithoutPluginInstall) {
|
|
1125
|
+
process.exitCode = 1;
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const config = await readOpenClawConfig(configPath);
|
|
1132
|
+
const choices = await collectInstallChoices(config, prompter);
|
|
1133
|
+
const nextConfig = applyInstallerChoices(config, choices);
|
|
1134
|
+
|
|
1135
|
+
if (options.dryRun) {
|
|
1136
|
+
prompter.note("\nDry run only; no files were written.");
|
|
1137
|
+
} else {
|
|
1138
|
+
await writeConfig(configPath, nextConfig);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const summaryLines = buildSummaryLines({
|
|
1142
|
+
configPath,
|
|
1143
|
+
choices,
|
|
1144
|
+
backupPath,
|
|
1145
|
+
pluginInstallStatus,
|
|
1146
|
+
pluginInstallMethod,
|
|
1147
|
+
pluginInstallError,
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
prompter.note("\nTeamClaw installer summary");
|
|
1151
|
+
prompter.note("--------------------------");
|
|
1152
|
+
for (const line of summaryLines) {
|
|
1153
|
+
prompter.note(`- ${line}`);
|
|
1154
|
+
}
|
|
1155
|
+
prompter.note("");
|
|
1156
|
+
if (choices.installMode === "controller-docker") {
|
|
1157
|
+
prompter.note("Before using Docker provisioning, make sure the controller can reach the Docker daemon.");
|
|
1158
|
+
} else if (choices.installMode === "controller-kubernetes") {
|
|
1159
|
+
prompter.note("Before using Kubernetes provisioning, make sure kubectl, namespace access, and the worker image are ready.");
|
|
1160
|
+
} else if (choices.installMode === "controller-manual") {
|
|
1161
|
+
prompter.note("Next step: run this installer again on your worker nodes with the dedicated worker mode.");
|
|
1162
|
+
} else if (choices.installMode === "worker") {
|
|
1163
|
+
prompter.note("Next step: start this worker node so it can register with the controller.");
|
|
1164
|
+
}
|
|
1165
|
+
} finally {
|
|
1166
|
+
prompter.close();
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function main() {
|
|
1171
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
1172
|
+
if (command === "help") {
|
|
1173
|
+
printHelp();
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (command !== "install") {
|
|
1177
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1178
|
+
}
|
|
1179
|
+
await runInstall(options);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
main().catch((error) => {
|
|
1183
|
+
console.error(`TeamClaw installer failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
});
|