limbo-ai 1.4.0 → 1.6.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 +664 -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 = require('./package.json').version;
|
|
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,506 @@ 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
|
+
let lastRenderLineCount = 0;
|
|
352
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
353
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
354
|
+
const previousRawMode = process.stdin.isRaw;
|
|
355
|
+
process.stdin.setRawMode(true);
|
|
356
|
+
|
|
357
|
+
function cleanup() {
|
|
358
|
+
process.stdin.off('keypress', onKeypress);
|
|
359
|
+
process.stdin.setRawMode(Boolean(previousRawMode));
|
|
360
|
+
rl.close();
|
|
361
|
+
process.stdout.write('\n');
|
|
109
362
|
}
|
|
110
|
-
}
|
|
111
363
|
|
|
112
|
-
|
|
364
|
+
function draw() {
|
|
365
|
+
const output = renderMenu(question, options, selectedIndex, lang);
|
|
366
|
+
if (lastRenderLineCount > 0) {
|
|
367
|
+
readline.moveCursor(process.stdout, 0, -lastRenderLineCount);
|
|
368
|
+
}
|
|
369
|
+
for (let i = 0; i < lastRenderLineCount; i++) {
|
|
370
|
+
readline.clearLine(process.stdout, 0);
|
|
371
|
+
if (i < lastRenderLineCount - 1) readline.moveCursor(process.stdout, 0, 1);
|
|
372
|
+
}
|
|
373
|
+
if (lastRenderLineCount > 0) {
|
|
374
|
+
readline.moveCursor(process.stdout, 0, -Math.max(lastRenderLineCount - 1, 0));
|
|
375
|
+
}
|
|
376
|
+
readline.cursorTo(process.stdout, 0);
|
|
377
|
+
process.stdout.write(output);
|
|
378
|
+
lastRenderLineCount = output.split('\n').length;
|
|
379
|
+
}
|
|
113
380
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
381
|
+
function onKeypress(_, key = {}) {
|
|
382
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
383
|
+
selectedIndex = selectedIndex === 0 ? options.length - 1 : selectedIndex - 1;
|
|
384
|
+
draw();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
389
|
+
selectedIndex = selectedIndex === options.length - 1 ? 0 : selectedIndex + 1;
|
|
390
|
+
draw();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (key.name === 'return') {
|
|
395
|
+
const value = options[selectedIndex];
|
|
396
|
+
cleanup();
|
|
397
|
+
resolve(value);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (key.ctrl && key.name === 'c') {
|
|
402
|
+
cleanup();
|
|
403
|
+
process.exit(130);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
process.stdin.on('keypress', onKeypress);
|
|
408
|
+
draw();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function parseEnvFile() {
|
|
413
|
+
if (!fs.existsSync(ENV_FILE)) return {};
|
|
414
|
+
return fs.readFileSync(ENV_FILE, 'utf8')
|
|
415
|
+
.split('\n')
|
|
416
|
+
.filter((line) => line && !line.startsWith('#') && line.includes('='))
|
|
417
|
+
.reduce((acc, line) => {
|
|
418
|
+
const idx = line.indexOf('=');
|
|
419
|
+
acc[line.slice(0, idx)] = line.slice(idx + 1);
|
|
420
|
+
return acc;
|
|
421
|
+
}, {});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function generateGatewayToken() {
|
|
425
|
+
return crypto.randomBytes(24).toString('base64url');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function normalizeConfig(cfg, existingEnv = {}) {
|
|
429
|
+
const gatewayToken = cfg.gatewayToken || existingEnv.OPENCLAW_GATEWAY_TOKEN || generateGatewayToken();
|
|
430
|
+
const base = {
|
|
431
|
+
CLI_LANGUAGE: cfg.language || existingEnv.CLI_LANGUAGE || 'en',
|
|
432
|
+
AUTH_MODE: cfg.authMode || existingEnv.AUTH_MODE || 'api-key',
|
|
433
|
+
MODEL_PROVIDER: cfg.provider || existingEnv.MODEL_PROVIDER || 'anthropic',
|
|
434
|
+
MODEL_NAME: cfg.modelName || existingEnv.MODEL_NAME || 'claude-opus-4-6',
|
|
435
|
+
OPENAI_API_KEY: cfg.provider === 'openai' && cfg.apiKey ? cfg.apiKey : (cfg.keepExisting ? existingEnv.OPENAI_API_KEY || '' : ''),
|
|
436
|
+
ANTHROPIC_API_KEY: cfg.provider === 'anthropic' && cfg.apiKey ? cfg.apiKey : (cfg.keepExisting ? existingEnv.ANTHROPIC_API_KEY || '' : ''),
|
|
437
|
+
LLM_API_KEY: cfg.apiKey || (cfg.keepExisting ? existingEnv.LLM_API_KEY || '' : ''),
|
|
438
|
+
TELEGRAM_ENABLED: cfg.telegramEnabled || existingEnv.TELEGRAM_ENABLED || 'false',
|
|
439
|
+
TELEGRAM_BOT_TOKEN: cfg.telegramToken || (cfg.keepExisting ? existingEnv.TELEGRAM_BOT_TOKEN || '' : ''),
|
|
440
|
+
TELEGRAM_AUTO_PAIR_FIRST_DM: existingEnv.TELEGRAM_AUTO_PAIR_FIRST_DM || 'true',
|
|
441
|
+
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
return base;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function writeEnv(cfg, existingEnv = {}) {
|
|
448
|
+
const content = Object.entries(normalizeConfig(cfg, existingEnv))
|
|
449
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
450
|
+
.join('\n') + '\n';
|
|
127
451
|
fs.writeFileSync(ENV_FILE, content, { mode: 0o600 });
|
|
128
452
|
}
|
|
129
453
|
|
|
130
|
-
function waitForHealthy(maxAttempts = 12) {
|
|
454
|
+
function waitForHealthy(lang, maxAttempts = 12) {
|
|
131
455
|
for (let i = 1; i <= maxAttempts; i++) {
|
|
132
456
|
try {
|
|
133
457
|
const raw = runQuiet('docker compose ps --format json');
|
|
134
458
|
if (raw.includes('"healthy"')) return true;
|
|
135
459
|
} catch {}
|
|
136
|
-
log(
|
|
137
|
-
|
|
138
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000);
|
|
460
|
+
log(t(lang, 'waitingHealth', i, maxAttempts));
|
|
461
|
+
sleep(5000);
|
|
139
462
|
}
|
|
140
463
|
return false;
|
|
141
464
|
}
|
|
142
465
|
|
|
143
|
-
|
|
466
|
+
function getModelCatalog(providerFamily, authMode) {
|
|
467
|
+
return MODEL_CATALOG[`${providerFamily}:${authMode}`];
|
|
468
|
+
}
|
|
144
469
|
|
|
145
|
-
async function
|
|
146
|
-
|
|
470
|
+
async function chooseModel(lang, providerFamily, authMode) {
|
|
471
|
+
const catalog = getModelCatalog(providerFamily, authMode);
|
|
472
|
+
const options = catalog.menuModels.map((model) => ({ label: model, value: model }));
|
|
473
|
+
options.push({ label: t(lang, 'customModel'), value: '__custom__' });
|
|
474
|
+
|
|
475
|
+
const selection = await selectMenu(t(lang, 'modelQuestion'), options, lang);
|
|
476
|
+
if (selection.value !== '__custom__') return selection.value;
|
|
147
477
|
|
|
148
|
-
|
|
149
|
-
|
|
478
|
+
const rl = createPromptInterface();
|
|
479
|
+
while (true) {
|
|
480
|
+
const modelName = (await prompt(rl, t(lang, 'customModelPrompt'))).trim();
|
|
481
|
+
if (!modelName) {
|
|
482
|
+
warn(t(lang, 'requiredField'));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (catalog.supportedModels.includes(modelName)) {
|
|
486
|
+
rl.close();
|
|
487
|
+
return modelName;
|
|
488
|
+
}
|
|
489
|
+
warn(t(lang, 'invalidModel'));
|
|
490
|
+
console.log(` ${t(lang, 'supportedModels')} ${catalog.supportedModels.join(', ')}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function collectConfig(existingEnv = {}) {
|
|
495
|
+
console.log(`${c.cyan}${ASCII_ART}${c.reset}`);
|
|
496
|
+
|
|
497
|
+
const language = (await selectMenu(t('en', 'chooseLanguage'), [
|
|
498
|
+
{ label: TEXT.en.languageName, value: 'en' },
|
|
499
|
+
{ label: TEXT.es.languageName, value: 'es' },
|
|
500
|
+
], 'en')).value;
|
|
501
|
+
|
|
502
|
+
const providerFamily = (await selectMenu(t(language, 'providerQuestion'), [
|
|
503
|
+
{ label: t(language, 'providerOpenAI'), value: 'openai' },
|
|
504
|
+
{ label: t(language, 'providerAnthropic'), value: 'anthropic' },
|
|
505
|
+
], language)).value;
|
|
506
|
+
|
|
507
|
+
const accessMethod = (await selectMenu(t(language, 'accessMethodQuestion'), [
|
|
508
|
+
{
|
|
509
|
+
label: providerFamily === 'openai'
|
|
510
|
+
? t(language, 'accessSubscriptionOpenAI')
|
|
511
|
+
: t(language, 'accessSubscriptionAnthropic'),
|
|
512
|
+
value: 'subscription',
|
|
513
|
+
},
|
|
514
|
+
{ label: t(language, 'accessApiKey'), value: 'api-key' },
|
|
515
|
+
], language)).value;
|
|
516
|
+
|
|
517
|
+
const modelName = await chooseModel(language, providerFamily, accessMethod);
|
|
518
|
+
const provider = getModelCatalog(providerFamily, accessMethod).provider;
|
|
519
|
+
|
|
520
|
+
const rl = createPromptInterface();
|
|
521
|
+
let apiKey = '';
|
|
522
|
+
|
|
523
|
+
if (accessMethod === 'api-key') {
|
|
524
|
+
if (providerFamily === 'openai') {
|
|
525
|
+
apiKey = await promptValidated(
|
|
526
|
+
rl,
|
|
527
|
+
t(language, 'openAiApiKeyPrompt'),
|
|
528
|
+
(value) => {
|
|
529
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
530
|
+
if (!value.startsWith('sk-')) return { ok: false, message: t(language, 'invalidOpenAIKey') };
|
|
531
|
+
return { ok: true, value };
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
} else {
|
|
535
|
+
apiKey = await promptValidated(
|
|
536
|
+
rl,
|
|
537
|
+
t(language, 'anthropicApiKeyPrompt'),
|
|
538
|
+
(value) => {
|
|
539
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
540
|
+
if (!value.startsWith('sk-ant-')) return { ok: false, message: t(language, 'invalidAnthropicKey') };
|
|
541
|
+
return { ok: true, value };
|
|
542
|
+
},
|
|
543
|
+
);
|
|
544
|
+
}
|
|
150
545
|
}
|
|
151
546
|
|
|
547
|
+
const telegramChoice = await selectMenu(t(language, 'telegramQuestion'), [
|
|
548
|
+
{ label: t(language, 'yes'), value: 'true' },
|
|
549
|
+
{ label: t(language, 'no'), value: 'false' },
|
|
550
|
+
], language);
|
|
551
|
+
|
|
552
|
+
let telegramToken = '';
|
|
553
|
+
if (telegramChoice.value === 'true') {
|
|
554
|
+
telegramToken = await promptValidated(
|
|
555
|
+
rl,
|
|
556
|
+
t(language, 'telegramTokenPrompt'),
|
|
557
|
+
(value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
rl.close();
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
language,
|
|
565
|
+
authMode: accessMethod,
|
|
566
|
+
provider,
|
|
567
|
+
providerFamily,
|
|
568
|
+
modelName,
|
|
569
|
+
apiKey,
|
|
570
|
+
telegramEnabled: telegramChoice.value,
|
|
571
|
+
telegramToken,
|
|
572
|
+
gatewayToken: existingEnv.OPENCLAW_GATEWAY_TOKEN || generateGatewayToken(),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function ensureComposeFile() {
|
|
152
577
|
fs.mkdirSync(LIMBO_DIR, { recursive: true });
|
|
153
578
|
fs.writeFileSync(COMPOSE_FILE, COMPOSE_CONTENT);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function ensureGatewayToken(existingEnv) {
|
|
582
|
+
if (existingEnv.OPENCLAW_GATEWAY_TOKEN) return existingEnv.OPENCLAW_GATEWAY_TOKEN;
|
|
583
|
+
writeEnv({ keepExisting: true }, existingEnv);
|
|
584
|
+
return parseEnvFile().OPENCLAW_GATEWAY_TOKEN;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function pullOrBuildImage(lang) {
|
|
588
|
+
header(t(lang, 'pullingImage'));
|
|
589
|
+
try {
|
|
590
|
+
run('docker compose pull -q');
|
|
591
|
+
ok(t(lang, 'imagePulled'));
|
|
592
|
+
} catch {
|
|
593
|
+
warn(t(lang, 'pullFailed'));
|
|
594
|
+
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
595
|
+
if (!fs.existsSync(repoDockerfile)) {
|
|
596
|
+
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
597
|
+
}
|
|
598
|
+
log(t(lang, 'buildingFallback'));
|
|
599
|
+
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
600
|
+
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function runOpenClaw(args, opts = {}) {
|
|
605
|
+
return runDockerCompose(['run', '--rm', '--entrypoint', 'openclaw', 'limbo', ...args], opts);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function applyOpenClawConfig(cfg) {
|
|
609
|
+
header(t(cfg.language, 'configFlowStart'));
|
|
610
|
+
|
|
611
|
+
const setCommands = [
|
|
612
|
+
['config', 'set', 'gateway.mode', 'local'],
|
|
613
|
+
['config', 'set', 'gateway.port', String(PORT), '--strict-json'],
|
|
614
|
+
['config', 'set', 'gateway.bind', 'loopback'],
|
|
615
|
+
['config', 'set', 'gateway.auth.mode', 'token'],
|
|
616
|
+
['config', 'set', 'agents.defaults.workspace', '/data/workspace'],
|
|
617
|
+
['config', 'set', 'agents.defaults.model.primary', `${cfg.provider}/${cfg.modelName}`],
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
if (cfg.telegramEnabled === 'true') {
|
|
621
|
+
setCommands.push(
|
|
622
|
+
['config', 'set', 'channels.telegram.enabled', 'true', '--strict-json'],
|
|
623
|
+
['config', 'set', 'channels.telegram.botToken', cfg.telegramToken],
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const command of setCommands) {
|
|
628
|
+
const result = runOpenClaw(command, { stdio: 'pipe' });
|
|
629
|
+
if (result.status !== 0) {
|
|
630
|
+
process.stdout.write(result.stdout || '');
|
|
631
|
+
process.stderr.write(result.stderr || '');
|
|
632
|
+
die(t(cfg.language, 'configFlowFailed'));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (cfg.telegramEnabled !== 'true') {
|
|
637
|
+
runOpenClaw(['config', 'unset', 'channels.telegram'], { stdio: 'pipe' });
|
|
638
|
+
}
|
|
154
639
|
|
|
640
|
+
const validateResult = runOpenClaw(['config', 'validate'], { stdio: 'pipe' });
|
|
641
|
+
if (validateResult.status !== 0) {
|
|
642
|
+
process.stdout.write(validateResult.stdout || '');
|
|
643
|
+
process.stderr.write(validateResult.stderr || '');
|
|
644
|
+
die(t(cfg.language, 'configFlowFailed'));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
ok(t(cfg.language, 'configFlowDone'));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function runSubscriptionAuthFlow(cfg) {
|
|
651
|
+
header(t(cfg.language, 'subscriptionSetup'));
|
|
652
|
+
if (cfg.providerFamily === 'openai') {
|
|
653
|
+
log(t(cfg.language, 'openaiSubscriptionIntro'));
|
|
654
|
+
} else {
|
|
655
|
+
log(t(cfg.language, 'anthropicSubscriptionIntro'));
|
|
656
|
+
}
|
|
657
|
+
log(t(cfg.language, 'authFlowStart'));
|
|
658
|
+
|
|
659
|
+
const authArgs = cfg.providerFamily === 'openai'
|
|
660
|
+
? ['models', 'auth', 'login', '--provider', 'openai-codex']
|
|
661
|
+
: ['models', 'auth', 'paste-token', '--provider', 'anthropic'];
|
|
662
|
+
|
|
663
|
+
const authResult = runOpenClaw(authArgs);
|
|
664
|
+
if (authResult.status !== 0) die(t(cfg.language, 'authFlowFailed'));
|
|
665
|
+
|
|
666
|
+
const statusResult = runOpenClaw(
|
|
667
|
+
['models', 'status', '--check', '--probe-provider', cfg.provider],
|
|
668
|
+
{ stdio: 'pipe' },
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
if (statusResult.status !== 0) {
|
|
672
|
+
process.stdout.write(statusResult.stdout || '');
|
|
673
|
+
process.stderr.write(statusResult.stderr || '');
|
|
674
|
+
die(t(cfg.language, 'authStatusFailed'));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
ok(t(cfg.language, 'authFlowDone'));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function printSuccess(cfg, gatewayToken) {
|
|
681
|
+
console.log(`
|
|
682
|
+
${c.green}${c.bold}╔════════════════════════════════════════════╗${c.reset}
|
|
683
|
+
${c.green}${c.bold}║ ${t(cfg.language, 'success').padEnd(34, ' ')}║${c.reset}
|
|
684
|
+
${c.green}${c.bold}╚════════════════════════════════════════════╝${c.reset}
|
|
685
|
+
|
|
686
|
+
${c.bold}${t(cfg.language, 'gateway')}:${c.reset} ws://127.0.0.1:${PORT}
|
|
687
|
+
${c.bold}${t(cfg.language, 'gatewayToken')}:${c.reset} ${gatewayToken}
|
|
688
|
+
${c.bold}${t(cfg.language, 'data')}:${c.reset} ${LIMBO_DIR}
|
|
689
|
+
${c.bold}${t(cfg.language, 'logs')}:${c.reset} limbo logs
|
|
690
|
+
${c.bold}${t(cfg.language, 'stop')}:${c.reset} limbo stop
|
|
691
|
+
${c.bold}${t(cfg.language, 'update')}:${c.reset} limbo update
|
|
692
|
+
`);
|
|
693
|
+
|
|
694
|
+
if (cfg.telegramEnabled === 'true') {
|
|
695
|
+
console.log(` ${t(cfg.language, 'telegramEnabledHint')}`);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
console.log(` ${t(cfg.language, 'nonTelegramHintTitle')}`);
|
|
700
|
+
console.log(` ${t(cfg.language, 'nonTelegramPromptIntro')}`);
|
|
701
|
+
console.log(` "${t(cfg.language, 'nonTelegramPrompt', gatewayToken)}"`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
async function cmdStart() {
|
|
707
|
+
if (!hasDocker()) die(t('en', 'dockerMissing'));
|
|
708
|
+
|
|
709
|
+
ensureComposeFile();
|
|
710
|
+
|
|
711
|
+
const existingEnv = parseEnvFile();
|
|
155
712
|
const alreadyHasEnv = fs.existsSync(ENV_FILE);
|
|
156
713
|
let cfg;
|
|
714
|
+
let lang = existingEnv.CLI_LANGUAGE || 'en';
|
|
157
715
|
|
|
158
716
|
if (alreadyHasEnv) {
|
|
159
|
-
log(`Found existing config at ${ENV_FILE}`);
|
|
717
|
+
log(existingEnv.MODEL_PROVIDER ? t(lang, 'foundExistingConfig') : `Found existing config at ${ENV_FILE}`);
|
|
160
718
|
const reconfig = process.argv.includes('--reconfigure');
|
|
161
719
|
if (!reconfig) {
|
|
162
|
-
|
|
163
|
-
|
|
720
|
+
lang = existingEnv.CLI_LANGUAGE || 'en';
|
|
721
|
+
log(t(lang, 'reconfigureHint'));
|
|
722
|
+
ensureGatewayToken(existingEnv);
|
|
723
|
+
cfg = {
|
|
724
|
+
language: lang,
|
|
725
|
+
provider: existingEnv.MODEL_PROVIDER || 'anthropic',
|
|
726
|
+
providerFamily: (existingEnv.MODEL_PROVIDER || 'anthropic').startsWith('openai') ? 'openai' : 'anthropic',
|
|
727
|
+
authMode: existingEnv.AUTH_MODE || 'api-key',
|
|
728
|
+
modelName: existingEnv.MODEL_NAME || 'claude-opus-4-6',
|
|
729
|
+
telegramEnabled: existingEnv.TELEGRAM_ENABLED || 'false',
|
|
730
|
+
};
|
|
164
731
|
} else {
|
|
165
|
-
header('
|
|
166
|
-
cfg = await collectConfig();
|
|
732
|
+
header(t(lang, 'reconfiguration'));
|
|
733
|
+
cfg = await collectConfig(existingEnv);
|
|
734
|
+
writeEnv({ ...cfg, CLI_LANGUAGE: cfg.language }, existingEnv);
|
|
735
|
+
ok(t(cfg.language, 'envWritten'));
|
|
167
736
|
}
|
|
168
737
|
} else {
|
|
169
|
-
header('
|
|
170
|
-
cfg = await collectConfig();
|
|
738
|
+
header(t('en', 'configuration'));
|
|
739
|
+
cfg = await collectConfig(existingEnv);
|
|
740
|
+
writeEnv({ ...cfg, CLI_LANGUAGE: cfg.language }, existingEnv);
|
|
741
|
+
ok(t(cfg.language, 'envWritten'));
|
|
171
742
|
}
|
|
172
743
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
744
|
+
const mergedEnv = parseEnvFile();
|
|
745
|
+
if (!cfg.language) cfg.language = mergedEnv.CLI_LANGUAGE || 'en';
|
|
746
|
+
if (!mergedEnv.CLI_LANGUAGE) {
|
|
747
|
+
writeEnv({ ...cfg, keepExisting: true, CLI_LANGUAGE: cfg.language }, mergedEnv);
|
|
176
748
|
}
|
|
177
749
|
|
|
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
|
-
}
|
|
750
|
+
pullOrBuildImage(cfg.language);
|
|
751
|
+
|
|
752
|
+
if (cfg.authMode === 'subscription' && (process.argv.includes('--reconfigure') || !alreadyHasEnv)) {
|
|
753
|
+
runSubscriptionAuthFlow(cfg);
|
|
194
754
|
}
|
|
195
755
|
|
|
196
|
-
|
|
756
|
+
applyOpenClawConfig({
|
|
757
|
+
...cfg,
|
|
758
|
+
telegramToken: mergedEnv.TELEGRAM_BOT_TOKEN || cfg.telegramToken || '',
|
|
759
|
+
telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
header(t(cfg.language, 'starting'));
|
|
197
763
|
run('docker compose up -d --remove-orphans');
|
|
198
764
|
|
|
199
|
-
header(
|
|
200
|
-
const healthy = waitForHealthy();
|
|
765
|
+
header(t(cfg.language, 'verifying'));
|
|
766
|
+
const healthy = waitForHealthy(cfg.language);
|
|
201
767
|
if (!healthy) {
|
|
202
|
-
warn(
|
|
203
|
-
warn(
|
|
768
|
+
warn(t(cfg.language, 'healthTimeout'));
|
|
769
|
+
warn(t(cfg.language, 'logsHint'));
|
|
204
770
|
} else {
|
|
205
|
-
ok('
|
|
771
|
+
ok(t(cfg.language, 'healthy'));
|
|
206
772
|
}
|
|
207
773
|
|
|
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
|
-
`);
|
|
774
|
+
printSuccess({
|
|
775
|
+
language: cfg.language,
|
|
776
|
+
telegramEnabled: mergedEnv.TELEGRAM_ENABLED || cfg.telegramEnabled || 'false',
|
|
777
|
+
}, parseEnvFile().OPENCLAW_GATEWAY_TOKEN);
|
|
219
778
|
}
|
|
220
779
|
|
|
221
780
|
function cmdStop() {
|
|
222
|
-
if (!fs.existsSync(COMPOSE_FILE)) die('
|
|
781
|
+
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
223
782
|
log('Stopping Limbo...');
|
|
224
783
|
run('docker compose down');
|
|
225
784
|
ok('Stopped.');
|
|
226
785
|
}
|
|
227
786
|
|
|
228
787
|
function cmdLogs() {
|
|
229
|
-
if (!fs.existsSync(COMPOSE_FILE)) die('
|
|
788
|
+
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
230
789
|
run('docker compose logs -f');
|
|
231
790
|
}
|
|
232
791
|
|
|
233
792
|
function cmdUpdate() {
|
|
234
|
-
if (!fs.existsSync(COMPOSE_FILE)) die('
|
|
793
|
+
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
235
794
|
log('Pulling latest image...');
|
|
236
795
|
run('docker compose pull -q');
|
|
237
796
|
log('Restarting...');
|
|
@@ -249,7 +808,7 @@ function cmdStatus() {
|
|
|
249
808
|
|
|
250
809
|
function cmdHelp() {
|
|
251
810
|
console.log(`
|
|
252
|
-
${c.bold}limbo${c.reset}
|
|
811
|
+
${c.bold}limbo${c.reset} - personal AI memory agent
|
|
253
812
|
|
|
254
813
|
${c.bold}Usage:${c.reset}
|
|
255
814
|
npx limbo [command]
|
|
@@ -263,7 +822,7 @@ ${c.bold}Commands:${c.reset}
|
|
|
263
822
|
help Show this help
|
|
264
823
|
|
|
265
824
|
${c.bold}Flags:${c.reset}
|
|
266
|
-
--reconfigure Reconfigure
|
|
825
|
+
--reconfigure Reconfigure auth and onboarding settings (use with start)
|
|
267
826
|
|
|
268
827
|
${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
269
828
|
`);
|
|
@@ -277,15 +836,15 @@ const [,, cmd = 'start'] = process.argv;
|
|
|
277
836
|
switch (cmd) {
|
|
278
837
|
case 'start':
|
|
279
838
|
case 'install': await cmdStart(); break;
|
|
280
|
-
case 'stop': cmdStop();
|
|
281
|
-
case 'logs': cmdLogs();
|
|
839
|
+
case 'stop': cmdStop(); break;
|
|
840
|
+
case 'logs': cmdLogs(); break;
|
|
282
841
|
case 'update': cmdUpdate(); break;
|
|
283
842
|
case 'status': cmdStatus(); break;
|
|
284
843
|
case 'help':
|
|
285
844
|
case '--help':
|
|
286
845
|
case '-h': cmdHelp(); break;
|
|
287
846
|
default:
|
|
288
|
-
warn(
|
|
847
|
+
warn(t('en', 'unknownCommand', cmd));
|
|
289
848
|
cmdHelp();
|
|
290
849
|
process.exit(1);
|
|
291
850
|
}
|