limbo-ai 1.4.0 → 1.5.0
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 +8 -5
- package/cli.js +652 -105
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,15 +92,18 @@ Managed automatically by `npx limbo-ai start`, stored in `~/.limbo/.env`.
|
|
|
92
92
|
|
|
93
93
|
| Variable | Required | Default | Description |
|
|
94
94
|
|----------|----------|---------|-------------|
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
95
|
+
| `AUTH_MODE` | no | `api-key` | `api-key` or `subscription` |
|
|
96
|
+
| `OPENAI_API_KEY` | no* | — | OpenAI API key for `MODEL_PROVIDER=openai` |
|
|
97
|
+
| `ANTHROPIC_API_KEY` | no* | — | Anthropic API key for `MODEL_PROVIDER=anthropic` |
|
|
98
|
+
| `LLM_API_KEY` | no | — | Legacy generic key path for older installs |
|
|
99
|
+
| `MODEL_PROVIDER` | no | `anthropic` | Model provider: `anthropic`, `openai`, or `openai-codex` |
|
|
100
|
+
| `MODEL_NAME` | no | `claude-opus-4-6` | Model name (e.g. `claude-opus-4-6`, `claude-sonnet-4-6`, `gpt-5.4`) |
|
|
99
101
|
| `TELEGRAM_ENABLED` | no | `false` | Enable Telegram bot integration |
|
|
100
102
|
| `TELEGRAM_BOT_TOKEN` | no | — | Telegram bot token (required if `TELEGRAM_ENABLED=true`) |
|
|
101
103
|
| `TELEGRAM_AUTO_PAIR_FIRST_DM` | no | `true` | Auto-approves the first Telegram DM sender and persists access (MVP-friendly onboarding) |
|
|
104
|
+
| `OPENCLAW_GATEWAY_TOKEN` | no | generated | Stable gateway token for OpenClaw-compatible clients |
|
|
102
105
|
|
|
103
|
-
> \*
|
|
106
|
+
> \* API keys are required only for `AUTH_MODE=api-key`. Subscription auth uses OpenClaw auth profiles instead.
|
|
104
107
|
|
|
105
108
|
---
|
|
106
109
|
|
package/cli.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
7
|
const { execSync, spawnSync } = require('child_process');
|
|
8
|
+
const crypto = require('crypto');
|
|
8
9
|
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
9
11
|
const path = require('path');
|
|
10
12
|
const readline = require('readline');
|
|
11
|
-
const os = require('os');
|
|
12
13
|
|
|
13
14
|
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -16,20 +17,63 @@ const LIMBO_DIR = path.join(os.homedir(), '.limbo');
|
|
|
16
17
|
const ENV_FILE = path.join(LIMBO_DIR, '.env');
|
|
17
18
|
const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
|
|
18
19
|
const GHCR_IMAGE = 'ghcr.io/tomasward1/limbo';
|
|
19
|
-
const DEFAULT_TAG = '
|
|
20
|
+
const DEFAULT_TAG = 'latest';
|
|
20
21
|
const PORT = 18789;
|
|
21
22
|
|
|
23
|
+
// OpenClaw compatibility snapshots from official docs:
|
|
24
|
+
// - https://docs.openclaw.ai/providers/openai
|
|
25
|
+
// - https://docs.openclaw.ai/providers/anthropic
|
|
26
|
+
// - https://docs.openclaw.ai/start/wizard-cli-reference
|
|
27
|
+
const MODEL_CATALOG = {
|
|
28
|
+
'openai:subscription': {
|
|
29
|
+
provider: 'openai-codex',
|
|
30
|
+
defaultModel: 'gpt-5.4',
|
|
31
|
+
menuModels: ['gpt-5.4'],
|
|
32
|
+
supportedModels: ['gpt-5.4', 'gpt-5.3-codex'],
|
|
33
|
+
},
|
|
34
|
+
'openai:api-key': {
|
|
35
|
+
provider: 'openai',
|
|
36
|
+
defaultModel: 'gpt-5.4',
|
|
37
|
+
menuModels: ['gpt-5.4', 'gpt-5.4-pro'],
|
|
38
|
+
supportedModels: ['gpt-5.4', 'gpt-5.4-pro', 'gpt-5.1-codex'],
|
|
39
|
+
},
|
|
40
|
+
'anthropic:subscription': {
|
|
41
|
+
provider: 'anthropic',
|
|
42
|
+
defaultModel: 'claude-opus-4-6',
|
|
43
|
+
menuModels: ['claude-opus-4-6', 'claude-sonnet-4-6'],
|
|
44
|
+
supportedModels: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-1', 'claude-sonnet-4'],
|
|
45
|
+
},
|
|
46
|
+
'anthropic:api-key': {
|
|
47
|
+
provider: 'anthropic',
|
|
48
|
+
defaultModel: 'claude-opus-4-6',
|
|
49
|
+
menuModels: ['claude-opus-4-6', 'claude-sonnet-4-6'],
|
|
50
|
+
supportedModels: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-1', 'claude-sonnet-4'],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ASCII_ART = String.raw`
|
|
55
|
+
_ ___ __ __ ____ ___
|
|
56
|
+
| | |_ _| \/ | __ ) / _ \
|
|
57
|
+
| | | || |\/| | _ \| | | |
|
|
58
|
+
| |___ | || | | | |_) | |_| |
|
|
59
|
+
|_____|___|_| |_|____/ \___/
|
|
60
|
+
`;
|
|
61
|
+
|
|
22
62
|
// docker-compose.yml written to ~/.limbo on install
|
|
23
63
|
const COMPOSE_CONTENT = `services:
|
|
24
64
|
limbo:
|
|
25
|
-
image: ${GHCR_IMAGE}
|
|
65
|
+
image: ${GHCR_IMAGE}:${DEFAULT_TAG}
|
|
26
66
|
restart: unless-stopped
|
|
27
67
|
ports:
|
|
28
68
|
- "127.0.0.1:${PORT}:${PORT}"
|
|
29
69
|
volumes:
|
|
30
70
|
- limbo-data:/data
|
|
71
|
+
- limbo-openclaw-state:/home/limbo/.openclaw
|
|
31
72
|
env_file:
|
|
32
73
|
- .env
|
|
74
|
+
environment:
|
|
75
|
+
OPENCLAW_CONFIG_PATH: /home/limbo/.openclaw/openclaw.json
|
|
76
|
+
OPENCLAW_STATE_DIR: /home/limbo/.openclaw
|
|
33
77
|
healthcheck:
|
|
34
78
|
test:
|
|
35
79
|
- CMD-SHELL
|
|
@@ -43,27 +87,197 @@ const COMPOSE_CONTENT = `services:
|
|
|
43
87
|
|
|
44
88
|
volumes:
|
|
45
89
|
limbo-data:
|
|
90
|
+
limbo-openclaw-state:
|
|
46
91
|
`;
|
|
47
92
|
|
|
93
|
+
const TEXT = {
|
|
94
|
+
en: {
|
|
95
|
+
languageName: 'English',
|
|
96
|
+
chooseLanguage: 'Choose your language',
|
|
97
|
+
menuHelp: 'Use arrow keys and press Enter.',
|
|
98
|
+
providerQuestion: 'AI Provider',
|
|
99
|
+
providerOpenAI: 'Codex (OpenAI)',
|
|
100
|
+
providerAnthropic: 'Claude (Anthropic)',
|
|
101
|
+
accessMethodQuestion: 'Access method',
|
|
102
|
+
accessSubscriptionOpenAI: 'ChatGPT / Codex subscription',
|
|
103
|
+
accessSubscriptionAnthropic: 'Claude Code subscription',
|
|
104
|
+
accessApiKey: 'API token',
|
|
105
|
+
modelQuestion: 'Model',
|
|
106
|
+
customModel: 'Add another supported model name',
|
|
107
|
+
customModelPrompt: ' Model name: ',
|
|
108
|
+
invalidModel: 'That model is not in the current OpenClaw docs allowlist for this provider/auth path.',
|
|
109
|
+
supportedModels: 'Supported models:',
|
|
110
|
+
openAiApiKeyPrompt: ' OpenAI API key (sk-...): ',
|
|
111
|
+
anthropicApiKeyPrompt: ' Anthropic API key (sk-ant-...): ',
|
|
112
|
+
requiredField: 'This field is required.',
|
|
113
|
+
invalidOpenAIKey: 'OpenAI API keys usually start with "sk-".',
|
|
114
|
+
invalidAnthropicKey: 'Anthropic API keys usually start with "sk-ant-".',
|
|
115
|
+
telegramQuestion: 'Want to speak to Limbo through Telegram?',
|
|
116
|
+
telegramTokenPrompt: ' Telegram bot token: ',
|
|
117
|
+
yes: 'Yes',
|
|
118
|
+
no: 'No',
|
|
119
|
+
configuration: 'Configuration',
|
|
120
|
+
reconfiguration: 'Reconfiguration',
|
|
121
|
+
foundExistingConfig: `Found existing config at ${ENV_FILE}`,
|
|
122
|
+
reconfigureHint: 'Starting with existing config. Use --reconfigure to change settings.',
|
|
123
|
+
envWritten: '.env written.',
|
|
124
|
+
pullingImage: 'Pulling image...',
|
|
125
|
+
imagePulled: 'Image pulled.',
|
|
126
|
+
pullFailed: 'Could not pull from GHCR. Trying local build fallback...',
|
|
127
|
+
buildingFallback: 'Building from local Dockerfile...',
|
|
128
|
+
buildOk: (tag) => `Built: ${GHCR_IMAGE}:${tag}`,
|
|
129
|
+
starting: 'Starting Limbo...',
|
|
130
|
+
verifying: 'Verifying health...',
|
|
131
|
+
waitingHealth: (i, max) => `Waiting for container to be healthy... (${i}/${max})`,
|
|
132
|
+
healthTimeout: 'Container did not report healthy within timeout.',
|
|
133
|
+
logsHint: 'Check logs with: limbo logs',
|
|
134
|
+
healthy: 'Container is healthy.',
|
|
135
|
+
subscriptionSetup: 'Provider authentication',
|
|
136
|
+
openaiSubscriptionIntro: 'Limbo will open OpenClaw auth inside the container so you can complete Codex login.',
|
|
137
|
+
anthropicSubscriptionIntro: 'Generate a Claude setup-token on any machine with `claude setup-token`, then paste it into the next step.',
|
|
138
|
+
authFlowStart: 'Starting provider auth flow...',
|
|
139
|
+
authFlowDone: 'Provider auth completed.',
|
|
140
|
+
authFlowFailed: 'Provider auth did not complete successfully.',
|
|
141
|
+
authStatusFailed: 'OpenClaw still reports missing or invalid auth for the selected provider.',
|
|
142
|
+
configFlowStart: 'Applying OpenClaw config...',
|
|
143
|
+
configFlowDone: 'OpenClaw config updated.',
|
|
144
|
+
configFlowFailed: 'Could not update the OpenClaw config for Limbo.',
|
|
145
|
+
success: 'Limbo is running!',
|
|
146
|
+
gateway: 'Gateway',
|
|
147
|
+
gatewayToken: 'Gateway token',
|
|
148
|
+
data: 'Data',
|
|
149
|
+
logs: 'Logs',
|
|
150
|
+
stop: 'Stop',
|
|
151
|
+
update: 'Update',
|
|
152
|
+
telegramEnabledHint: 'Telegram is enabled. Message your bot to start talking to Limbo.',
|
|
153
|
+
nonTelegramHintTitle: 'No Telegram? You can still talk to Limbo through another agent.',
|
|
154
|
+
nonTelegramPromptIntro: 'Suggested prompt:',
|
|
155
|
+
nonTelegramPrompt: (token) => `Connect to my Limbo gateway at ws://127.0.0.1:${PORT} using token ${token}. Use Limbo as my memory layer: save notes, recall context, and update maps of content when I ask.`,
|
|
156
|
+
dockerMissing: 'Docker is not installed or `docker compose` is unavailable.\nInstall Docker Desktop: https://docs.docker.com/get-docker/',
|
|
157
|
+
installMissing: 'Limbo is not installed. Run: npx limbo start',
|
|
158
|
+
helpTitle: 'limbo - personal AI memory agent',
|
|
159
|
+
usage: 'Usage',
|
|
160
|
+
commands: 'Commands',
|
|
161
|
+
flags: 'Flags',
|
|
162
|
+
dataDirectory: 'Data directory',
|
|
163
|
+
helpStart: 'Install and start Limbo (default if no command given)',
|
|
164
|
+
helpStop: 'Stop the running container',
|
|
165
|
+
helpLogs: 'Tail container logs',
|
|
166
|
+
helpUpdate: 'Pull latest image and restart',
|
|
167
|
+
helpStatus: 'Show container status',
|
|
168
|
+
helpHelp: 'Show this help',
|
|
169
|
+
helpReconfigure: 'Reconfigure auth and onboarding settings (use with start)',
|
|
170
|
+
unknownCommand: (cmd) => `Unknown command: ${cmd}`,
|
|
171
|
+
},
|
|
172
|
+
es: {
|
|
173
|
+
languageName: 'Espanol',
|
|
174
|
+
chooseLanguage: 'Elige tu idioma',
|
|
175
|
+
menuHelp: 'Usa las flechas y Enter.',
|
|
176
|
+
providerQuestion: 'AI Provider',
|
|
177
|
+
providerOpenAI: 'Codex (OpenAI)',
|
|
178
|
+
providerAnthropic: 'Claude (Anthropic)',
|
|
179
|
+
accessMethodQuestion: 'Metodo de acceso',
|
|
180
|
+
accessSubscriptionOpenAI: 'Suscripcion ChatGPT / Codex',
|
|
181
|
+
accessSubscriptionAnthropic: 'Suscripcion Claude Code',
|
|
182
|
+
accessApiKey: 'API token',
|
|
183
|
+
modelQuestion: 'Modelo',
|
|
184
|
+
customModel: 'Agregar otro nombre de modelo soportado',
|
|
185
|
+
customModelPrompt: ' Nombre del modelo: ',
|
|
186
|
+
invalidModel: 'Ese modelo no esta en la allowlist actual de OpenClaw para este provider y metodo.',
|
|
187
|
+
supportedModels: 'Modelos soportados:',
|
|
188
|
+
openAiApiKeyPrompt: ' OpenAI API key (sk-...): ',
|
|
189
|
+
anthropicApiKeyPrompt: ' Anthropic API key (sk-ant-...): ',
|
|
190
|
+
requiredField: 'Este campo es obligatorio.',
|
|
191
|
+
invalidOpenAIKey: 'Las API keys de OpenAI normalmente empiezan con "sk-".',
|
|
192
|
+
invalidAnthropicKey: 'Las API keys de Anthropic normalmente empiezan con "sk-ant-".',
|
|
193
|
+
telegramQuestion: 'Quieres hablar con Limbo por Telegram?',
|
|
194
|
+
telegramTokenPrompt: ' Telegram bot token: ',
|
|
195
|
+
yes: 'Si',
|
|
196
|
+
no: 'No',
|
|
197
|
+
configuration: 'Configuracion',
|
|
198
|
+
reconfiguration: 'Reconfiguracion',
|
|
199
|
+
foundExistingConfig: `Se encontro una configuracion existente en ${ENV_FILE}`,
|
|
200
|
+
reconfigureHint: 'Se va a usar la configuracion actual. Usa --reconfigure para cambiarla.',
|
|
201
|
+
envWritten: '.env escrito.',
|
|
202
|
+
pullingImage: 'Bajando imagen...',
|
|
203
|
+
imagePulled: 'Imagen descargada.',
|
|
204
|
+
pullFailed: 'No se pudo bajar la imagen desde GHCR. Probando build local...',
|
|
205
|
+
buildingFallback: 'Construyendo desde el Dockerfile local...',
|
|
206
|
+
buildOk: (tag) => `Imagen construida: ${GHCR_IMAGE}:${tag}`,
|
|
207
|
+
starting: 'Arrancando Limbo...',
|
|
208
|
+
verifying: 'Verificando health...',
|
|
209
|
+
waitingHealth: (i, max) => `Esperando a que el container quede healthy... (${i}/${max})`,
|
|
210
|
+
healthTimeout: 'El container no reporto healthy dentro del timeout.',
|
|
211
|
+
logsHint: 'Mira los logs con: limbo logs',
|
|
212
|
+
healthy: 'El container esta healthy.',
|
|
213
|
+
subscriptionSetup: 'Autenticacion del provider',
|
|
214
|
+
openaiSubscriptionIntro: 'Limbo va a abrir la autenticacion de OpenClaw dentro del container para que completes el login de Codex.',
|
|
215
|
+
anthropicSubscriptionIntro: 'Genera un Claude setup-token en cualquier maquina con `claude setup-token` y pegalo en el siguiente paso.',
|
|
216
|
+
authFlowStart: 'Iniciando autenticacion del provider...',
|
|
217
|
+
authFlowDone: 'Autenticacion del provider completada.',
|
|
218
|
+
authFlowFailed: 'La autenticacion del provider no termino correctamente.',
|
|
219
|
+
authStatusFailed: 'OpenClaw sigue reportando auth faltante o invalida para el provider elegido.',
|
|
220
|
+
configFlowStart: 'Aplicando configuracion de OpenClaw...',
|
|
221
|
+
configFlowDone: 'Configuracion de OpenClaw actualizada.',
|
|
222
|
+
configFlowFailed: 'No se pudo actualizar la configuracion de OpenClaw para Limbo.',
|
|
223
|
+
success: 'Limbo esta corriendo!',
|
|
224
|
+
gateway: 'Gateway',
|
|
225
|
+
gatewayToken: 'Token del gateway',
|
|
226
|
+
data: 'Data',
|
|
227
|
+
logs: 'Logs',
|
|
228
|
+
stop: 'Stop',
|
|
229
|
+
update: 'Update',
|
|
230
|
+
telegramEnabledHint: 'Telegram esta habilitado. Escribile a tu bot para empezar a hablar con Limbo.',
|
|
231
|
+
nonTelegramHintTitle: 'Sin Telegram? Igual puedes hablar con Limbo desde otro agente.',
|
|
232
|
+
nonTelegramPromptIntro: 'Prompt sugerido:',
|
|
233
|
+
nonTelegramPrompt: (token) => `Conectate a mi gateway de Limbo en ws://127.0.0.1:${PORT} usando el token ${token}. Usa Limbo como mi capa de memoria: guarda notas, recupera contexto y actualiza maps of content cuando yo lo pida.`,
|
|
234
|
+
dockerMissing: 'Docker no esta instalado o `docker compose` no esta disponible.\nInstala Docker Desktop: https://docs.docker.com/get-docker/',
|
|
235
|
+
installMissing: 'Limbo no esta instalado. Corre: npx limbo start',
|
|
236
|
+
helpTitle: 'limbo - agente personal de memoria con AI',
|
|
237
|
+
usage: 'Uso',
|
|
238
|
+
commands: 'Comandos',
|
|
239
|
+
flags: 'Flags',
|
|
240
|
+
dataDirectory: 'Directorio de data',
|
|
241
|
+
helpStart: 'Instala y arranca Limbo (default si no pasas comando)',
|
|
242
|
+
helpStop: 'Frena el container',
|
|
243
|
+
helpLogs: 'Sigue los logs del container',
|
|
244
|
+
helpUpdate: 'Baja la ultima imagen y reinicia',
|
|
245
|
+
helpStatus: 'Muestra el estado del container',
|
|
246
|
+
helpHelp: 'Muestra esta ayuda',
|
|
247
|
+
helpReconfigure: 'Reconfigura auth y onboarding (usar con start)',
|
|
248
|
+
unknownCommand: (cmd) => `Comando desconocido: ${cmd}`,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
48
252
|
// ─── Colors ──────────────────────────────────────────────────────────────────
|
|
49
253
|
|
|
50
254
|
const c = {
|
|
51
255
|
reset: '\x1b[0m',
|
|
52
|
-
bold:
|
|
53
|
-
cyan:
|
|
256
|
+
bold: '\x1b[1m',
|
|
257
|
+
cyan: '\x1b[36m',
|
|
54
258
|
green: '\x1b[32m',
|
|
55
259
|
yellow: '\x1b[33m',
|
|
56
|
-
red:
|
|
260
|
+
red: '\x1b[31m',
|
|
261
|
+
dim: '\x1b[2m',
|
|
57
262
|
};
|
|
58
263
|
|
|
59
|
-
const log
|
|
60
|
-
const ok
|
|
61
|
-
const warn
|
|
62
|
-
const die
|
|
264
|
+
const log = (msg) => console.log(`${c.cyan}[limbo]${c.reset} ${msg}`);
|
|
265
|
+
const ok = (msg) => console.log(`${c.green}[limbo]${c.reset} ${msg}`);
|
|
266
|
+
const warn = (msg) => console.log(`${c.yellow}[limbo]${c.reset} ${msg}`);
|
|
267
|
+
const die = (msg) => { console.error(`${c.red}[limbo] ERROR:${c.reset} ${msg}`); process.exit(1); };
|
|
63
268
|
const header = (msg) => console.log(`\n${c.bold}${msg}${c.reset}`);
|
|
64
269
|
|
|
65
270
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
66
271
|
|
|
272
|
+
function t(lang, key, ...args) {
|
|
273
|
+
const value = TEXT[lang][key];
|
|
274
|
+
return typeof value === 'function' ? value(...args) : value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sleep(ms) {
|
|
278
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
279
|
+
}
|
|
280
|
+
|
|
67
281
|
function hasDocker() {
|
|
68
282
|
const result = spawnSync('docker', ['compose', 'version'], { stdio: 'pipe' });
|
|
69
283
|
return result.status === 0;
|
|
@@ -77,161 +291,494 @@ function runQuiet(cmd) {
|
|
|
77
291
|
return execSync(cmd, { stdio: 'pipe', cwd: LIMBO_DIR }).toString().trim();
|
|
78
292
|
}
|
|
79
293
|
|
|
294
|
+
function runDockerCompose(args, opts = {}) {
|
|
295
|
+
const result = spawnSync('docker', ['compose', ...args], {
|
|
296
|
+
cwd: LIMBO_DIR,
|
|
297
|
+
stdio: opts.stdio || 'inherit',
|
|
298
|
+
input: opts.input,
|
|
299
|
+
encoding: opts.encoding || 'utf8',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (result.error) throw result.error;
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
80
306
|
function prompt(rl, question) {
|
|
81
307
|
return new Promise((resolve) => rl.question(question, resolve));
|
|
82
308
|
}
|
|
83
309
|
|
|
84
|
-
|
|
85
|
-
|
|
310
|
+
function createPromptInterface() {
|
|
311
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
312
|
+
}
|
|
86
313
|
|
|
87
|
-
|
|
88
|
-
|
|
314
|
+
async function promptValidated(rl, question, validate, errorMessage) {
|
|
315
|
+
while (true) {
|
|
316
|
+
const value = (await prompt(rl, question)).trim();
|
|
317
|
+
const validation = validate(value);
|
|
318
|
+
if (validation.ok) return validation.value;
|
|
319
|
+
warn(validation.message || errorMessage);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
89
322
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
323
|
+
function renderMenu(question, options, selectedIndex, lang) {
|
|
324
|
+
const lines = [`${c.bold}${question}${c.reset}`, `${c.dim}${t(lang, 'menuHelp')}${c.reset}`, ''];
|
|
325
|
+
options.forEach((option, index) => {
|
|
326
|
+
const prefix = index === selectedIndex ? `${c.green}>${c.reset}` : ' ';
|
|
327
|
+
lines.push(`${prefix} ${option.label}`);
|
|
328
|
+
if (option.description) lines.push(` ${c.dim}${option.description}${c.reset}`);
|
|
329
|
+
});
|
|
330
|
+
return lines.join('\n');
|
|
331
|
+
}
|
|
94
332
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
333
|
+
async function selectMenu(question, options, lang) {
|
|
334
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
335
|
+
const rl = createPromptInterface();
|
|
336
|
+
while (true) {
|
|
337
|
+
console.log(`\n${question}`);
|
|
338
|
+
options.forEach((option, index) => console.log(` ${index + 1}. ${option.label}`));
|
|
339
|
+
const raw = (await prompt(rl, ' > ')).trim();
|
|
340
|
+
const selected = Number(raw);
|
|
341
|
+
if (Number.isInteger(selected) && selected >= 1 && selected <= options.length) {
|
|
342
|
+
rl.close();
|
|
343
|
+
return options[selected - 1];
|
|
344
|
+
}
|
|
345
|
+
warn('Pick one of the listed options.');
|
|
346
|
+
}
|
|
99
347
|
}
|
|
100
348
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
let selectedIndex = 0;
|
|
351
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
352
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
353
|
+
const previousRawMode = process.stdin.isRaw;
|
|
354
|
+
process.stdin.setRawMode(true);
|
|
355
|
+
|
|
356
|
+
function cleanup() {
|
|
357
|
+
process.stdin.off('keypress', onKeypress);
|
|
358
|
+
process.stdin.setRawMode(Boolean(previousRawMode));
|
|
359
|
+
rl.close();
|
|
360
|
+
process.stdout.write('\n');
|
|
109
361
|
}
|
|
110
|
-
}
|
|
111
362
|
|
|
112
|
-
|
|
363
|
+
function draw() {
|
|
364
|
+
readline.cursorTo(process.stdout, 0);
|
|
365
|
+
readline.clearScreenDown(process.stdout);
|
|
366
|
+
process.stdout.write(`${renderMenu(question, options, selectedIndex, lang)}\n`);
|
|
367
|
+
}
|
|
113
368
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
369
|
+
function onKeypress(_, key = {}) {
|
|
370
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
371
|
+
selectedIndex = selectedIndex === 0 ? options.length - 1 : selectedIndex - 1;
|
|
372
|
+
draw();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
377
|
+
selectedIndex = selectedIndex === options.length - 1 ? 0 : selectedIndex + 1;
|
|
378
|
+
draw();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (key.name === 'return') {
|
|
383
|
+
const value = options[selectedIndex];
|
|
384
|
+
cleanup();
|
|
385
|
+
resolve(value);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (key.ctrl && key.name === 'c') {
|
|
390
|
+
cleanup();
|
|
391
|
+
process.exit(130);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
process.stdin.on('keypress', onKeypress);
|
|
396
|
+
draw();
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function parseEnvFile() {
|
|
401
|
+
if (!fs.existsSync(ENV_FILE)) return {};
|
|
402
|
+
return fs.readFileSync(ENV_FILE, 'utf8')
|
|
403
|
+
.split('\n')
|
|
404
|
+
.filter((line) => line && !line.startsWith('#') && line.includes('='))
|
|
405
|
+
.reduce((acc, line) => {
|
|
406
|
+
const idx = line.indexOf('=');
|
|
407
|
+
acc[line.slice(0, idx)] = line.slice(idx + 1);
|
|
408
|
+
return acc;
|
|
409
|
+
}, {});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function generateGatewayToken() {
|
|
413
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function normalizeConfig(cfg, existingEnv = {}) {
|
|
417
|
+
const gatewayToken = cfg.gatewayToken || existingEnv.OPENCLAW_GATEWAY_TOKEN || generateGatewayToken();
|
|
418
|
+
const base = {
|
|
419
|
+
CLI_LANGUAGE: cfg.language || existingEnv.CLI_LANGUAGE || 'en',
|
|
420
|
+
AUTH_MODE: cfg.authMode || existingEnv.AUTH_MODE || 'api-key',
|
|
421
|
+
MODEL_PROVIDER: cfg.provider || existingEnv.MODEL_PROVIDER || 'anthropic',
|
|
422
|
+
MODEL_NAME: cfg.modelName || existingEnv.MODEL_NAME || 'claude-opus-4-6',
|
|
423
|
+
OPENAI_API_KEY: cfg.provider === 'openai' && cfg.apiKey ? cfg.apiKey : (cfg.keepExisting ? existingEnv.OPENAI_API_KEY || '' : ''),
|
|
424
|
+
ANTHROPIC_API_KEY: cfg.provider === 'anthropic' && cfg.apiKey ? cfg.apiKey : (cfg.keepExisting ? existingEnv.ANTHROPIC_API_KEY || '' : ''),
|
|
425
|
+
LLM_API_KEY: cfg.apiKey || (cfg.keepExisting ? existingEnv.LLM_API_KEY || '' : ''),
|
|
426
|
+
TELEGRAM_ENABLED: cfg.telegramEnabled || existingEnv.TELEGRAM_ENABLED || 'false',
|
|
427
|
+
TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
|
|
428
|
+
TELEGRAM_AUTO_PAIR_FIRST_DM: existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'true',
|
|
429
|
+
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
return base;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function writeEnv(cfg, existingEnv = {}) {
|
|
436
|
+
const content = Object.entries(normalizeConfig(cfg, existingEnv))
|
|
437
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
438
|
+
.join('\n') + '\n';
|
|
127
439
|
fs.writeFileSync(ENV_FILE, content, { mode: 0o600 });
|
|
128
440
|
}
|
|
129
441
|
|
|
130
|
-
function waitForHealthy(maxAttempts = 12) {
|
|
442
|
+
function waitForHealthy(lang, maxAttempts = 12) {
|
|
131
443
|
for (let i = 1; i <= maxAttempts; i++) {
|
|
132
444
|
try {
|
|
133
445
|
const raw = runQuiet('docker compose ps --format json');
|
|
134
446
|
if (raw.includes('"healthy"')) return true;
|
|
135
447
|
} catch {}
|
|
136
|
-
log(
|
|
137
|
-
|
|
138
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
|
|
448
|
+
log(t(lang, 'waitingHealth', i, maxAttempts));
|
|
449
|
+
sleep(5000);
|
|
139
450
|
}
|
|
140
451
|
return false;
|
|
141
452
|
}
|
|
142
453
|
|
|
143
|
-
|
|
454
|
+
function getModelCatalog(providerFamily, authMode) {
|
|
455
|
+
return MODEL_CATALOG[`${providerFamily}:${authMode}`];
|
|
456
|
+
}
|
|
144
457
|
|
|
145
|
-
async function
|
|
146
|
-
|
|
458
|
+
async function chooseModel(lang, providerFamily, authMode) {
|
|
459
|
+
const catalog = getModelCatalog(providerFamily, authMode);
|
|
460
|
+
const options = catalog.menuModels.map((model) => ({ label: model, value: model }));
|
|
461
|
+
options.push({ label: t(lang, 'customModel'), value: '__custom__' });
|
|
462
|
+
|
|
463
|
+
const selection = await selectMenu(t(lang, 'modelQuestion'), options, lang);
|
|
464
|
+
if (selection.value !== '__custom__') return selection.value;
|
|
147
465
|
|
|
148
|
-
|
|
149
|
-
|
|
466
|
+
const rl = createPromptInterface();
|
|
467
|
+
while (true) {
|
|
468
|
+
const modelName = (await prompt(rl, t(lang, 'customModelPrompt'))).trim();
|
|
469
|
+
if (!modelName) {
|
|
470
|
+
warn(t(lang, 'requiredField'));
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (catalog.supportedModels.includes(modelName)) {
|
|
474
|
+
rl.close();
|
|
475
|
+
return modelName;
|
|
476
|
+
}
|
|
477
|
+
warn(t(lang, 'invalidModel'));
|
|
478
|
+
console.log(` ${t(lang, 'supportedModels')} ${catalog.supportedModels.join(', ')}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function collectConfig(existingEnv = {}) {
|
|
483
|
+
console.log(`${c.cyan}${ASCII_ART}${c.reset}`);
|
|
484
|
+
|
|
485
|
+
const language = (await selectMenu(t('en', 'chooseLanguage'), [
|
|
486
|
+
{ label: TEXT.en.languageName, value: 'en' },
|
|
487
|
+
{ label: TEXT.es.languageName, value: 'es' },
|
|
488
|
+
], 'en')).value;
|
|
489
|
+
|
|
490
|
+
const providerFamily = (await selectMenu(t(language, 'providerQuestion'), [
|
|
491
|
+
{ label: t(language, 'providerOpenAI'), value: 'openai' },
|
|
492
|
+
{ label: t(language, 'providerAnthropic'), value: 'anthropic' },
|
|
493
|
+
], language)).value;
|
|
494
|
+
|
|
495
|
+
const accessMethod = (await selectMenu(t(language, 'accessMethodQuestion'), [
|
|
496
|
+
{
|
|
497
|
+
label: providerFamily === 'openai'
|
|
498
|
+
? t(language, 'accessSubscriptionOpenAI')
|
|
499
|
+
: t(language, 'accessSubscriptionAnthropic'),
|
|
500
|
+
value: 'subscription',
|
|
501
|
+
},
|
|
502
|
+
{ label: t(language, 'accessApiKey'), value: 'api-key' },
|
|
503
|
+
], language)).value;
|
|
504
|
+
|
|
505
|
+
const modelName = await chooseModel(language, providerFamily, accessMethod);
|
|
506
|
+
const provider = getModelCatalog(providerFamily, accessMethod).provider;
|
|
507
|
+
|
|
508
|
+
const rl = createPromptInterface();
|
|
509
|
+
let apiKey = '';
|
|
510
|
+
|
|
511
|
+
if (accessMethod === 'api-key') {
|
|
512
|
+
if (providerFamily === 'openai') {
|
|
513
|
+
apiKey = await promptValidated(
|
|
514
|
+
rl,
|
|
515
|
+
t(language, 'openAiApiKeyPrompt'),
|
|
516
|
+
(value) => {
|
|
517
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
518
|
+
if (!value.startsWith('sk-')) return { ok: false, message: t(language, 'invalidOpenAIKey') };
|
|
519
|
+
return { ok: true, value };
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
} else {
|
|
523
|
+
apiKey = await promptValidated(
|
|
524
|
+
rl,
|
|
525
|
+
t(language, 'anthropicApiKeyPrompt'),
|
|
526
|
+
(value) => {
|
|
527
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
528
|
+
if (!value.startsWith('sk-ant-')) return { ok: false, message: t(language, 'invalidAnthropicKey') };
|
|
529
|
+
return { ok: true, value };
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
}
|
|
150
533
|
}
|
|
151
534
|
|
|
535
|
+
const telegramChoice = await selectMenu(t(language, 'telegramQuestion'), [
|
|
536
|
+
{ label: t(language, 'yes'), value: 'true' },
|
|
537
|
+
{ label: t(language, 'no'), value: 'false' },
|
|
538
|
+
], language);
|
|
539
|
+
|
|
540
|
+
let telegramToken = '';
|
|
541
|
+
if (telegramChoice.value === 'true') {
|
|
542
|
+
telegramToken = await promptValidated(
|
|
543
|
+
rl,
|
|
544
|
+
t(language, 'telegramTokenPrompt'),
|
|
545
|
+
(value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
rl.close();
|
|
550
|
+
|
|
551
|
+
return {
|
|
552
|
+
language,
|
|
553
|
+
authMode: accessMethod,
|
|
554
|
+
provider,
|
|
555
|
+
providerFamily,
|
|
556
|
+
modelName,
|
|
557
|
+
apiKey,
|
|
558
|
+
telegramEnabled: telegramChoice.value,
|
|
559
|
+
telegramToken,
|
|
560
|
+
gatewayToken: existingEnv.OPENCLAW_GATEWAY_TOKEN || generateGatewayToken(),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function ensureComposeFile() {
|
|
152
565
|
fs.mkdirSync(LIMBO_DIR, { recursive: true });
|
|
153
566
|
fs.writeFileSync(COMPOSE_FILE, COMPOSE_CONTENT);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function ensureGatewayToken(existingEnv) {
|
|
570
|
+
if (existingEnv.OPENCLAW_GATEWAY_TOKEN) return existingEnv.OPENCLAW_GATEWAY_TOKEN;
|
|
571
|
+
writeEnv({ keepExisting: true }, existingEnv);
|
|
572
|
+
return parseEnvFile().OPENCLAW_GATEWAY_TOKEN;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function pullOrBuildImage(lang) {
|
|
576
|
+
header(t(lang, 'pullingImage'));
|
|
577
|
+
try {
|
|
578
|
+
run('docker compose pull -q');
|
|
579
|
+
ok(t(lang, 'imagePulled'));
|
|
580
|
+
} catch {
|
|
581
|
+
warn(t(lang, 'pullFailed'));
|
|
582
|
+
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
583
|
+
if (!fs.existsSync(repoDockerfile)) {
|
|
584
|
+
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
585
|
+
}
|
|
586
|
+
log(t(lang, 'buildingFallback'));
|
|
587
|
+
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
588
|
+
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function runOpenClaw(args, opts = {}) {
|
|
593
|
+
return runDockerCompose(['run', '--rm', '--entrypoint', 'openclaw', 'limbo', ...args], opts);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function applyOpenClawConfig(cfg) {
|
|
597
|
+
header(t(cfg.language, 'configFlowStart'));
|
|
598
|
+
|
|
599
|
+
const setCommands = [
|
|
600
|
+
['config', 'set', 'gateway.mode', 'local'],
|
|
601
|
+
['config', 'set', 'gateway.port', String(PORT), '--strict-json'],
|
|
602
|
+
['config', 'set', 'gateway.bind', 'loopback'],
|
|
603
|
+
['config', 'set', 'gateway.auth.mode', 'token'],
|
|
604
|
+
['config', 'set', 'agents.defaults.workspace', '/data/workspace'],
|
|
605
|
+
['config', 'set', 'agents.defaults.model.primary', `${cfg.provider}/${cfg.modelName}`],
|
|
606
|
+
];
|
|
607
|
+
|
|
608
|
+
if (cfg.telegramEnabled === 'true') {
|
|
609
|
+
setCommands.push(
|
|
610
|
+
['config', 'set', 'channels.telegram.enabled', 'true', '--strict-json'],
|
|
611
|
+
['config', 'set', 'channels.telegram.botToken', cfg.telegramToken],
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
for (const command of setCommands) {
|
|
616
|
+
const result = runOpenClaw(command, { stdio: 'pipe' });
|
|
617
|
+
if (result.status !== 0) {
|
|
618
|
+
process.stdout.write(result.stdout || '');
|
|
619
|
+
process.stderr.write(result.stderr || '');
|
|
620
|
+
die(t(cfg.language, 'configFlowFailed'));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (cfg.telegramEnabled !== 'true') {
|
|
625
|
+
runOpenClaw(['config', 'unset', 'channels.telegram'], { stdio: 'pipe' });
|
|
626
|
+
}
|
|
154
627
|
|
|
628
|
+
const validateResult = runOpenClaw(['config', 'validate'], { stdio: 'pipe' });
|
|
629
|
+
if (validateResult.status !== 0) {
|
|
630
|
+
process.stdout.write(validateResult.stdout || '');
|
|
631
|
+
process.stderr.write(validateResult.stderr || '');
|
|
632
|
+
die(t(cfg.language, 'configFlowFailed'));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
ok(t(cfg.language, 'configFlowDone'));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function runSubscriptionAuthFlow(cfg) {
|
|
639
|
+
header(t(cfg.language, 'subscriptionSetup'));
|
|
640
|
+
if (cfg.providerFamily === 'openai') {
|
|
641
|
+
log(t(cfg.language, 'openaiSubscriptionIntro'));
|
|
642
|
+
} else {
|
|
643
|
+
log(t(cfg.language, 'anthropicSubscriptionIntro'));
|
|
644
|
+
}
|
|
645
|
+
log(t(cfg.language, 'authFlowStart'));
|
|
646
|
+
|
|
647
|
+
const authArgs = cfg.providerFamily === 'openai'
|
|
648
|
+
? ['models', 'auth', 'login', '--provider', 'openai-codex']
|
|
649
|
+
: ['models', 'auth', 'paste-token', '--provider', 'anthropic'];
|
|
650
|
+
|
|
651
|
+
const authResult = runOpenClaw(authArgs);
|
|
652
|
+
if (authResult.status !== 0) die(t(cfg.language, 'authFlowFailed'));
|
|
653
|
+
|
|
654
|
+
const statusResult = runOpenClaw(
|
|
655
|
+
['models', 'status', '--check', '--probe-provider', cfg.provider],
|
|
656
|
+
{ stdio: 'pipe' },
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
if (statusResult.status !== 0) {
|
|
660
|
+
process.stdout.write(statusResult.stdout || '');
|
|
661
|
+
process.stderr.write(statusResult.stderr || '');
|
|
662
|
+
die(t(cfg.language, 'authStatusFailed'));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
ok(t(cfg.language, 'authFlowDone'));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function printSuccess(cfg, gatewayToken) {
|
|
669
|
+
console.log(`
|
|
670
|
+
${c.green}${c.bold}╔════════════════════════════════════════════╗${c.reset}
|
|
671
|
+
${c.green}${c.bold}║ ${t(cfg.language, 'success').padEnd(34, ' ')}║${c.reset}
|
|
672
|
+
${c.green}${c.bold}╚════════════════════════════════════════════╝${c.reset}
|
|
673
|
+
|
|
674
|
+
${c.bold}${t(cfg.language, 'gateway')}:${c.reset} ws://127.0.0.1:${PORT}
|
|
675
|
+
${c.bold}${t(cfg.language, 'gatewayToken')}:${c.reset} ${gatewayToken}
|
|
676
|
+
${c.bold}${t(cfg.language, 'data')}:${c.reset} ${LIMBO_DIR}
|
|
677
|
+
${c.bold}${t(cfg.language, 'logs')}:${c.reset} limbo logs
|
|
678
|
+
${c.bold}${t(cfg.language, 'stop')}:${c.reset} limbo stop
|
|
679
|
+
${c.bold}${t(cfg.language, 'update')}:${c.reset} limbo update
|
|
680
|
+
`);
|
|
681
|
+
|
|
682
|
+
if (cfg.telegramEnabled === 'true') {
|
|
683
|
+
console.log(` ${t(cfg.language, 'telegramEnabledHint')}`);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
console.log(` ${t(cfg.language, 'nonTelegramHintTitle')}`);
|
|
688
|
+
console.log(` ${t(cfg.language, 'nonTelegramPromptIntro')}`);
|
|
689
|
+
console.log(` "${t(cfg.language, 'nonTelegramPrompt', gatewayToken)}"`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
async function cmdStart() {
|
|
695
|
+
if (!hasDocker()) die(t('en', 'dockerMissing'));
|
|
696
|
+
|
|
697
|
+
ensureComposeFile();
|
|
698
|
+
|
|
699
|
+
const existingEnv = parseEnvFile();
|
|
155
700
|
const alreadyHasEnv = fs.existsSync(ENV_FILE);
|
|
156
701
|
let cfg;
|
|
702
|
+
let lang = existingEnv.CLI_LANGUAGE || 'en';
|
|
157
703
|
|
|
158
704
|
if (alreadyHasEnv) {
|
|
159
|
-
log(`Found existing config at ${ENV_FILE}`);
|
|
705
|
+
log(existingEnv.MODEL_PROVIDER ? t(lang, 'foundExistingConfig') : `Found existing config at ${ENV_FILE}`);
|
|
160
706
|
const reconfig = process.argv.includes('--reconfigure');
|
|
161
707
|
if (!reconfig) {
|
|
162
|
-
|
|
163
|
-
|
|
708
|
+
lang = existingEnv.CLI_LANGUAGE || 'en';
|
|
709
|
+
log(t(lang, 'reconfigureHint'));
|
|
710
|
+
ensureGatewayToken(existingEnv);
|
|
711
|
+
cfg = {
|
|
712
|
+
language: lang,
|
|
713
|
+
provider: existingEnv.MODEL_PROVIDER || 'anthropic',
|
|
714
|
+
providerFamily: (existingEnv.MODEL_PROVIDER || 'anthropic').startsWith('openai') ? 'openai' : 'anthropic',
|
|
715
|
+
authMode: existingEnv.AUTH_MODE || 'api-key',
|
|
716
|
+
modelName: existingEnv.MODEL_NAME || 'claude-opus-4-6',
|
|
717
|
+
telegramEnabled: existingEnv.TELEGRAM_ENABLED || 'false',
|
|
718
|
+
};
|
|
164
719
|
} else {
|
|
165
|
-
header('
|
|
166
|
-
cfg = await collectConfig();
|
|
720
|
+
header(t(lang, 'reconfiguration'));
|
|
721
|
+
cfg = await collectConfig(existingEnv);
|
|
722
|
+
writeEnv({ ...cfg, CLI_LANGUAGE: cfg.language }, existingEnv);
|
|
723
|
+
ok(t(cfg.language, 'envWritten'));
|
|
167
724
|
}
|
|
168
725
|
} else {
|
|
169
|
-
header('
|
|
170
|
-
cfg = await collectConfig();
|
|
726
|
+
header(t('en', 'configuration'));
|
|
727
|
+
cfg = await collectConfig(existingEnv);
|
|
728
|
+
writeEnv({ ...cfg, CLI_LANGUAGE: cfg.language }, existingEnv);
|
|
729
|
+
ok(t(cfg.language, 'envWritten'));
|
|
171
730
|
}
|
|
172
731
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
732
|
+
const mergedEnv = parseEnvFile();
|
|
733
|
+
if (!cfg.language) cfg.language = mergedEnv.CLI_LANGUAGE || 'en';
|
|
734
|
+
if (!mergedEnv.CLI_LANGUAGE) {
|
|
735
|
+
writeEnv({ ...cfg, keepExisting: true, CLI_LANGUAGE: cfg.language }, mergedEnv);
|
|
176
736
|
}
|
|
177
737
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
} catch {
|
|
183
|
-
warn('Could not pull from GHCR. Is the image public? Trying local build fallback...');
|
|
184
|
-
// Fallback: build from current directory if we're inside the repo
|
|
185
|
-
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
186
|
-
if (fs.existsSync(repoDockerfile)) {
|
|
187
|
-
log('Building from local Dockerfile...');
|
|
188
|
-
const tag = cfg?.tag || DEFAULT_TAG;
|
|
189
|
-
execSync(`docker build -t ${GHCR_IMAGE}:${tag} .`, { stdio: 'inherit', cwd: __dirname });
|
|
190
|
-
ok(`Built: ${GHCR_IMAGE}:${tag}`);
|
|
191
|
-
} else {
|
|
192
|
-
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
193
|
-
}
|
|
738
|
+
pullOrBuildImage(cfg.language);
|
|
739
|
+
|
|
740
|
+
if (cfg.authMode === 'subscription' && (process.argv.includes('--reconfigure') || !alreadyHasEnv)) {
|
|
741
|
+
runSubscriptionAuthFlow(cfg);
|
|
194
742
|
}
|
|
195
743
|
|
|
196
|
-
|
|
744
|
+
applyOpenClawConfig({
|
|
745
|
+
...cfg,
|
|
746
|
+
telegramToken: mergedEnv.TELEGRAM_BOT_TOKEN || cfg.telegramToken || '',
|
|
747
|
+
telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
header(t(cfg.language, 'starting'));
|
|
197
751
|
run('docker compose up -d --remove-orphans');
|
|
198
752
|
|
|
199
|
-
header(
|
|
200
|
-
const healthy = waitForHealthy();
|
|
753
|
+
header(t(cfg.language, 'verifying'));
|
|
754
|
+
const healthy = waitForHealthy(cfg.language);
|
|
201
755
|
if (!healthy) {
|
|
202
|
-
warn(
|
|
203
|
-
warn(
|
|
756
|
+
warn(t(cfg.language, 'healthTimeout'));
|
|
757
|
+
warn(t(cfg.language, 'logsHint'));
|
|
204
758
|
} else {
|
|
205
|
-
ok('
|
|
759
|
+
ok(t(cfg.language, 'healthy'));
|
|
206
760
|
}
|
|
207
761
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
${c.bold}Gateway:${c.reset} ws://127.0.0.1:${PORT}
|
|
214
|
-
${c.bold}Data:${c.reset} ${LIMBO_DIR}
|
|
215
|
-
${c.bold}Logs:${c.reset} limbo logs
|
|
216
|
-
${c.bold}Stop:${c.reset} limbo stop
|
|
217
|
-
${c.bold}Update:${c.reset} limbo update
|
|
218
|
-
`);
|
|
762
|
+
printSuccess({
|
|
763
|
+
language: cfg.language,
|
|
764
|
+
telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
|
|
765
|
+
}, parseEnvFile().OPENCLAW_GATEWAY_TOKEN);
|
|
219
766
|
}
|
|
220
767
|
|
|
221
768
|
function cmdStop() {
|
|
222
|
-
if (!fs.existsSync(COMPOSE_FILE)) die('
|
|
769
|
+
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
223
770
|
log('Stopping Limbo...');
|
|
224
771
|
run('docker compose down');
|
|
225
772
|
ok('Stopped.');
|
|
226
773
|
}
|
|
227
774
|
|
|
228
775
|
function cmdLogs() {
|
|
229
|
-
if (!fs.existsSync(COMPOSE_FILE)) die('
|
|
776
|
+
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
230
777
|
run('docker compose logs -f');
|
|
231
778
|
}
|
|
232
779
|
|
|
233
780
|
function cmdUpdate() {
|
|
234
|
-
if (!fs.existsSync(COMPOSE_FILE)) die('
|
|
781
|
+
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
235
782
|
log('Pulling latest image...');
|
|
236
783
|
run('docker compose pull -q');
|
|
237
784
|
log('Restarting...');
|
|
@@ -249,7 +796,7 @@ function cmdStatus() {
|
|
|
249
796
|
|
|
250
797
|
function cmdHelp() {
|
|
251
798
|
console.log(`
|
|
252
|
-
${c.bold}limbo${c.reset}
|
|
799
|
+
${c.bold}limbo${c.reset} - personal AI memory agent
|
|
253
800
|
|
|
254
801
|
${c.bold}Usage:${c.reset}
|
|
255
802
|
npx limbo [command]
|
|
@@ -263,7 +810,7 @@ ${c.bold}Commands:${c.reset}
|
|
|
263
810
|
help Show this help
|
|
264
811
|
|
|
265
812
|
${c.bold}Flags:${c.reset}
|
|
266
|
-
--reconfigure Reconfigure
|
|
813
|
+
--reconfigure Reconfigure auth and onboarding settings (use with start)
|
|
267
814
|
|
|
268
815
|
${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
269
816
|
`);
|
|
@@ -277,15 +824,15 @@ const [,, cmd = 'start'] = process.argv;
|
|
|
277
824
|
switch (cmd) {
|
|
278
825
|
case 'start':
|
|
279
826
|
case 'install': await cmdStart(); break;
|
|
280
|
-
case 'stop': cmdStop();
|
|
281
|
-
case 'logs': cmdLogs();
|
|
827
|
+
case 'stop': cmdStop(); break;
|
|
828
|
+
case 'logs': cmdLogs(); break;
|
|
282
829
|
case 'update': cmdUpdate(); break;
|
|
283
830
|
case 'status': cmdStatus(); break;
|
|
284
831
|
case 'help':
|
|
285
832
|
case '--help':
|
|
286
833
|
case '-h': cmdHelp(); break;
|
|
287
834
|
default:
|
|
288
|
-
warn(
|
|
835
|
+
warn(t('en', 'unknownCommand', cmd));
|
|
289
836
|
cmdHelp();
|
|
290
837
|
process.exit(1);
|
|
291
838
|
}
|