limbo-ai 1.22.0 → 1.23.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 +31 -0
- package/cli.js +122 -10
- package/config.toml.template +1 -1
- package/package.json +2 -2
- package/test/cli-wizard-parity.test.js +224 -0
- package/test/zeroclaw-migration.test.js +3 -2
package/README.md
CHANGED
|
@@ -58,10 +58,39 @@ npx limbo-ai@latest update # Pull latest image and restart
|
|
|
58
58
|
npx limbo-ai@latest status # Show container status
|
|
59
59
|
npx limbo-ai@latest logs # Tail container logs
|
|
60
60
|
npx limbo-ai@latest start --reconfigure # Change API keys or settings
|
|
61
|
+
npx limbo-ai@latest config # Configure optional features (voice, web-search)
|
|
61
62
|
```
|
|
62
63
|
|
|
63
64
|
---
|
|
64
65
|
|
|
66
|
+
## Optional Features
|
|
67
|
+
|
|
68
|
+
Limbo supports optional features that can be enabled during the setup wizard (step 7) or anytime via the CLI.
|
|
69
|
+
|
|
70
|
+
### Voice Messages
|
|
71
|
+
|
|
72
|
+
Transcribe Telegram voice notes using [Groq](https://groq.com) Whisper. Requires a Groq API key (`gsk_...`).
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx limbo-ai@latest config voice --enable --api-key gsk_xxx
|
|
76
|
+
npx limbo-ai@latest config voice --status
|
|
77
|
+
npx limbo-ai@latest config voice --disable
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Web Search
|
|
81
|
+
|
|
82
|
+
Give Limbo real-time web search via the [Brave Search API](https://brave.com/search/api/). Requires a Brave API key (`BSA...`).
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
npx limbo-ai@latest config web-search --enable --api-key BSAxxx
|
|
86
|
+
npx limbo-ai@latest config web-search --status
|
|
87
|
+
npx limbo-ai@latest config web-search --disable
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Both features store API keys as Docker secrets and toggle config sections in the container on restart.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
65
94
|
## Updating
|
|
66
95
|
|
|
67
96
|
```sh
|
|
@@ -124,6 +153,8 @@ Managed automatically by `npx limbo-ai start`, stored in `~/.limbo/.env`.
|
|
|
124
153
|
| `MODEL_NAME` | no | `claude-opus-4-6` | Model name (e.g. `claude-opus-4-6`, `claude-sonnet-4-6`, `gpt-5.4`) |
|
|
125
154
|
| `TELEGRAM_ENABLED` | no | `false` | Enable Telegram bot integration |
|
|
126
155
|
| `TELEGRAM_BOT_TOKEN` | no | — | Telegram bot token (required if `TELEGRAM_ENABLED=true`) |
|
|
156
|
+
| `VOICE_ENABLED` | no | `false` | Enable voice transcription (requires Groq API key as Docker secret) |
|
|
157
|
+
| `WEB_SEARCH_ENABLED` | no | `false` | Enable web search (requires Brave API key as Docker secret) |
|
|
127
158
|
|
|
128
159
|
> \* API keys are required only for `AUTH_MODE=api-key`. Subscription auth uses ZeroClaw auth profiles instead.
|
|
129
160
|
|
package/cli.js
CHANGED
|
@@ -318,6 +318,18 @@ const TEXT = {
|
|
|
318
318
|
openRouterKeyHint: 'Get your key at: https://openrouter.ai/keys',
|
|
319
319
|
openRouterModelPrompt: ' Model name (blank = auto-routing): ',
|
|
320
320
|
openRouterModelHint: 'Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o, google/gemini-2.5-pro',
|
|
321
|
+
optionalFeatures: 'Optional features',
|
|
322
|
+
voiceQuestion: 'Enable voice transcription? (Requires Groq API key)',
|
|
323
|
+
groqApiKeyPrompt: ' Groq API key (gsk_...): ',
|
|
324
|
+
groqApiKeyHint: 'Get your free key at: https://console.groq.com/keys',
|
|
325
|
+
invalidGroqKey: 'Groq API keys usually start with "gsk_". Proceeding anyway.',
|
|
326
|
+
webSearchQuestion: 'Enable web search? (Requires Brave Search API key)',
|
|
327
|
+
braveApiKeyPrompt: ' Brave API key (BSA...): ',
|
|
328
|
+
braveApiKeyHint: 'Get your key at: https://brave.com/search/api/',
|
|
329
|
+
invalidBraveKey: 'Brave API keys usually start with "BSA". Proceeding anyway.',
|
|
330
|
+
reviewHeader: 'Review your configuration',
|
|
331
|
+
reviewConfirm: 'Proceed with this configuration?',
|
|
332
|
+
reviewStartOver: 'Start over',
|
|
321
333
|
telegramQuestion: 'Want to speak to Limbo through Telegram?',
|
|
322
334
|
telegramBotFatherSteps: [
|
|
323
335
|
'To create a Telegram bot:',
|
|
@@ -434,6 +446,18 @@ const TEXT = {
|
|
|
434
446
|
openRouterKeyHint: 'Consegui tu key en: https://openrouter.ai/keys',
|
|
435
447
|
openRouterModelPrompt: ' Nombre del modelo (vacio = auto-routing): ',
|
|
436
448
|
openRouterModelHint: 'Ejemplos: anthropic/claude-sonnet-4-6, openai/gpt-4o, google/gemini-2.5-pro',
|
|
449
|
+
optionalFeatures: 'Funciones opcionales',
|
|
450
|
+
voiceQuestion: 'Habilitar transcripcion de voz? (Requiere API key de Groq)',
|
|
451
|
+
groqApiKeyPrompt: ' Groq API key (gsk_...): ',
|
|
452
|
+
groqApiKeyHint: 'Consegui tu key gratis en: https://console.groq.com/keys',
|
|
453
|
+
invalidGroqKey: 'Las API keys de Groq normalmente empiezan con "gsk_". Continuando igual.',
|
|
454
|
+
webSearchQuestion: 'Habilitar busqueda web? (Requiere API key de Brave Search)',
|
|
455
|
+
braveApiKeyPrompt: ' Brave API key (BSA...): ',
|
|
456
|
+
braveApiKeyHint: 'Consegui tu key en: https://brave.com/search/api/',
|
|
457
|
+
invalidBraveKey: 'Las API keys de Brave normalmente empiezan con "BSA". Continuando igual.',
|
|
458
|
+
reviewHeader: 'Revisa tu configuracion',
|
|
459
|
+
reviewConfirm: 'Continuar con esta configuracion?',
|
|
460
|
+
reviewStartOver: 'Empezar de nuevo',
|
|
437
461
|
telegramQuestion: 'Quieres hablar con Limbo por Telegram?',
|
|
438
462
|
telegramBotFatherSteps: [
|
|
439
463
|
'Para crear un bot de Telegram:',
|
|
@@ -902,7 +926,6 @@ async function collectConfig(existingEnv = {}) {
|
|
|
902
926
|
], language);
|
|
903
927
|
|
|
904
928
|
let telegramToken = '';
|
|
905
|
-
let telegramAutoPair = 'false';
|
|
906
929
|
if (telegramChoice.value === 'true') {
|
|
907
930
|
console.log('');
|
|
908
931
|
TEXT[language].telegramBotFatherSteps.forEach((line) => console.log(` ${c.dim}${line}${c.reset}`));
|
|
@@ -912,11 +935,73 @@ async function collectConfig(existingEnv = {}) {
|
|
|
912
935
|
t(language, 'telegramTokenPrompt'),
|
|
913
936
|
(value) => value ? { ok: true, value } : { ok: false, message: t(language, 'requiredField') },
|
|
914
937
|
);
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── Optional features ────────────────────────────────────────────────────
|
|
941
|
+
header(t(language, 'optionalFeatures'));
|
|
942
|
+
|
|
943
|
+
let voiceEnabled = 'false';
|
|
944
|
+
let groqApiKey = '';
|
|
945
|
+
const voiceChoice = await selectMenu(t(language, 'voiceQuestion'), [
|
|
946
|
+
{ label: t(language, 'no'), value: 'false' },
|
|
947
|
+
{ label: t(language, 'yes'), value: 'true' },
|
|
948
|
+
], language);
|
|
949
|
+
if (voiceChoice.value === 'true') {
|
|
950
|
+
console.log(` ${c.dim}${t(language, 'groqApiKeyHint')}${c.reset}`);
|
|
951
|
+
groqApiKey = await promptValidated(
|
|
952
|
+
t(language, 'groqApiKeyPrompt'),
|
|
953
|
+
(value) => {
|
|
954
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
955
|
+
if (!value.startsWith('gsk_')) warn(t(language, 'invalidGroqKey'));
|
|
956
|
+
return { ok: true, value };
|
|
957
|
+
},
|
|
958
|
+
);
|
|
959
|
+
voiceEnabled = 'true';
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
let webSearchEnabled = 'false';
|
|
963
|
+
let braveApiKey = '';
|
|
964
|
+
const webSearchChoice = await selectMenu(t(language, 'webSearchQuestion'), [
|
|
965
|
+
{ label: t(language, 'no'), value: 'false' },
|
|
966
|
+
{ label: t(language, 'yes'), value: 'true' },
|
|
967
|
+
], language);
|
|
968
|
+
if (webSearchChoice.value === 'true') {
|
|
969
|
+
console.log(` ${c.dim}${t(language, 'braveApiKeyHint')}${c.reset}`);
|
|
970
|
+
braveApiKey = await promptValidated(
|
|
971
|
+
t(language, 'braveApiKeyPrompt'),
|
|
972
|
+
(value) => {
|
|
973
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
974
|
+
if (!value.startsWith('BSA')) warn(t(language, 'invalidBraveKey'));
|
|
975
|
+
return { ok: true, value };
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
webSearchEnabled = 'true';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ── Review step ──────────────────────────────────────────────────────────
|
|
982
|
+
const providerLabel = providerFamily === 'openai' ? 'OpenAI'
|
|
983
|
+
: providerFamily === 'anthropic' ? 'Anthropic' : 'OpenRouter';
|
|
984
|
+
const authLabel = accessMethod === 'subscription' ? 'Subscription' : 'API key';
|
|
985
|
+
const enabledLabel = language === 'es' ? 'habilitado' : 'enabled';
|
|
986
|
+
const disabledLabel = language === 'es' ? 'deshabilitado' : 'disabled';
|
|
987
|
+
|
|
988
|
+
header(t(language, 'reviewHeader'));
|
|
989
|
+
console.log(`
|
|
990
|
+
${c.bold}Provider:${c.reset} ${providerLabel}
|
|
991
|
+
${c.bold}Model:${c.reset} ${modelName}
|
|
992
|
+
${c.bold}Auth:${c.reset} ${authLabel}
|
|
993
|
+
${c.bold}Telegram:${c.reset} ${telegramChoice.value === 'true' ? `${c.green}${enabledLabel}${c.reset}` : `${c.dim}${disabledLabel}${c.reset}`}
|
|
994
|
+
${c.bold}Voice:${c.reset} ${voiceEnabled === 'true' ? `${c.green}${enabledLabel}${c.reset}` : `${c.dim}${disabledLabel}${c.reset}`}
|
|
995
|
+
${c.bold}Web search:${c.reset} ${webSearchEnabled === 'true' ? `${c.green}${enabledLabel}${c.reset}` : `${c.dim}${disabledLabel}${c.reset}`}
|
|
996
|
+
`);
|
|
997
|
+
|
|
998
|
+
const confirmChoice = await selectMenu(t(language, 'reviewConfirm'), [
|
|
999
|
+
{ label: t(language, 'yes'), value: 'confirm' },
|
|
1000
|
+
{ label: t(language, 'reviewStartOver'), value: 'restart' },
|
|
1001
|
+
], language);
|
|
1002
|
+
|
|
1003
|
+
if (confirmChoice.value === 'restart') {
|
|
1004
|
+
return collectConfig(existingEnv);
|
|
920
1005
|
}
|
|
921
1006
|
|
|
922
1007
|
return {
|
|
@@ -928,7 +1013,11 @@ async function collectConfig(existingEnv = {}) {
|
|
|
928
1013
|
apiKey,
|
|
929
1014
|
telegramEnabled: telegramChoice.value,
|
|
930
1015
|
telegramToken,
|
|
931
|
-
telegramAutoPair,
|
|
1016
|
+
telegramAutoPair: 'true',
|
|
1017
|
+
voiceEnabled,
|
|
1018
|
+
groqApiKey,
|
|
1019
|
+
webSearchEnabled,
|
|
1020
|
+
braveApiKey,
|
|
932
1021
|
gatewayToken: existingEnv.GATEWAY_TOKEN || generateGatewayToken(),
|
|
933
1022
|
};
|
|
934
1023
|
}
|
|
@@ -1067,7 +1156,7 @@ function teardownSetupTunnel(tunnel) {
|
|
|
1067
1156
|
function installGlobalAlias() {
|
|
1068
1157
|
// Create a `limbo` shell wrapper so users don't have to type `npx limbo-ai` every time.
|
|
1069
1158
|
// Tries /usr/local/bin first (macOS, Linux with sudo), falls back to ~/.local/bin (no sudo).
|
|
1070
|
-
const wrapper = '#!/bin/sh\nexec npx limbo-ai "$@"\n';
|
|
1159
|
+
const wrapper = '#!/bin/sh\nexec npx limbo-ai@latest "$@"\n';
|
|
1071
1160
|
const candidates = [
|
|
1072
1161
|
path.join(os.homedir(), '.local', 'bin', 'limbo'),
|
|
1073
1162
|
'/usr/local/bin/limbo',
|
|
@@ -1075,10 +1164,10 @@ function installGlobalAlias() {
|
|
|
1075
1164
|
|
|
1076
1165
|
for (const target of candidates) {
|
|
1077
1166
|
try {
|
|
1078
|
-
// Skip if already installed and current
|
|
1167
|
+
// Skip if already installed and current (must include @latest)
|
|
1079
1168
|
if (fs.existsSync(target)) {
|
|
1080
1169
|
const existing = fs.readFileSync(target, 'utf8');
|
|
1081
|
-
if (existing.includes('limbo-ai')) return;
|
|
1170
|
+
if (existing.includes('limbo-ai@latest')) return;
|
|
1082
1171
|
}
|
|
1083
1172
|
const dir = path.dirname(target);
|
|
1084
1173
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
@@ -1670,9 +1759,32 @@ function cmdLogs() {
|
|
|
1670
1759
|
run('docker compose logs -f');
|
|
1671
1760
|
}
|
|
1672
1761
|
|
|
1762
|
+
function selfUpdateCli() {
|
|
1763
|
+
const pkg = require('./package.json');
|
|
1764
|
+
try {
|
|
1765
|
+
const latest = execSync('npm view limbo-ai version', { encoding: 'utf8', timeout: 10000 }).trim();
|
|
1766
|
+
if (!latest || latest === pkg.version) return;
|
|
1767
|
+
const cur = pkg.version.split('.').map(Number);
|
|
1768
|
+
const lat = latest.split('.').map(Number);
|
|
1769
|
+
const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) ||
|
|
1770
|
+
(lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
|
|
1771
|
+
if (!isNewer) return;
|
|
1772
|
+
|
|
1773
|
+
log(`Updating CLI: ${pkg.version} → ${latest}...`);
|
|
1774
|
+
execSync('npm install -g limbo-ai@latest', { stdio: 'inherit', timeout: 60000 });
|
|
1775
|
+
ok(`CLI updated to ${latest}.`);
|
|
1776
|
+
} catch {
|
|
1777
|
+
warn('Could not self-update CLI. Run: npm install -g limbo-ai@latest');
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1673
1781
|
function cmdUpdate() {
|
|
1674
1782
|
if (!fs.existsSync(COMPOSE_FILE)) die(t('en', 'installMissing'));
|
|
1675
1783
|
|
|
1784
|
+
// Self-update the CLI if installed globally
|
|
1785
|
+
const isGlobal = !process.argv[1].includes('npx') && !process.argv[1].includes('node_modules/.cache');
|
|
1786
|
+
if (isGlobal) selfUpdateCli();
|
|
1787
|
+
|
|
1676
1788
|
// Patch image tag to :latest in existing compose files (handles upgrades from pinned tags)
|
|
1677
1789
|
let compose = fs.readFileSync(COMPOSE_FILE, 'utf8');
|
|
1678
1790
|
const patched = compose.replace(
|
package/config.toml.template
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limbo-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "Your personal AI memory agent — install and manage Limbo via npx",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
16
|
"start": "node cli.js start",
|
|
17
|
-
"test": "node --test test/cli-filter.test.js test/cli-auth.test.js test/zeroclaw-migration.test.js test/setup-server.test.js"
|
|
17
|
+
"test": "node --test test/cli-filter.test.js test/cli-auth.test.js test/zeroclaw-migration.test.js test/setup-server.test.js test/cli-wizard-parity.test.js"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
20
|
"limbo",
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// test/cli-wizard-parity.test.js — Ensures CLI and setup wizard stay in sync.
|
|
2
|
+
// If this test fails, someone added a feature to one path without the other.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { describe, it } = require('node:test');
|
|
6
|
+
const assert = require('node:assert/strict');
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const cli = require('../cli.js');
|
|
11
|
+
const wizard = require('../setup-server/server.js');
|
|
12
|
+
|
|
13
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
// Extract the env var keys that normalizeConfig produces (CLI path).
|
|
16
|
+
function cliEnvKeys() {
|
|
17
|
+
const cfg = cli.normalizeConfig({
|
|
18
|
+
language: 'en',
|
|
19
|
+
authMode: 'api-key',
|
|
20
|
+
provider: 'anthropic',
|
|
21
|
+
modelName: 'claude-opus-4-6',
|
|
22
|
+
apiKey: 'sk-ant-test',
|
|
23
|
+
telegramEnabled: 'false',
|
|
24
|
+
telegramToken: '',
|
|
25
|
+
telegramAutoPair: 'true',
|
|
26
|
+
voiceEnabled: 'false',
|
|
27
|
+
webSearchEnabled: 'false',
|
|
28
|
+
gatewayToken: 'test-token',
|
|
29
|
+
});
|
|
30
|
+
return new Set(Object.keys(cfg));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Extract the env var keys the wizard writes in handleConfigure.
|
|
34
|
+
// We read the source and parse the envVars object keys.
|
|
35
|
+
function wizardEnvKeys() {
|
|
36
|
+
const src = fs.readFileSync(
|
|
37
|
+
path.join(__dirname, '..', 'setup-server', 'server.js'),
|
|
38
|
+
'utf8',
|
|
39
|
+
);
|
|
40
|
+
// Match the envVars = { ... } block inside handleConfigure
|
|
41
|
+
const match = src.match(/const envVars\s*=\s*\{([^}]+)\}/);
|
|
42
|
+
assert.ok(match, 'could not find envVars block in setup-server/server.js');
|
|
43
|
+
const keys = [];
|
|
44
|
+
for (const line of match[1].split('\n')) {
|
|
45
|
+
const m = line.match(/^\s*([A-Z_]+)\s*:/);
|
|
46
|
+
if (m) keys.push(m[1]);
|
|
47
|
+
}
|
|
48
|
+
return new Set(keys);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Extract secret file names written by each path.
|
|
52
|
+
function cliSecretNames() {
|
|
53
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'cli.js'), 'utf8');
|
|
54
|
+
// writeSecrets function writes these secret files
|
|
55
|
+
const match = src.match(/function writeSecrets[\s\S]*?^}/m);
|
|
56
|
+
assert.ok(match, 'could not find writeSecrets in cli.js');
|
|
57
|
+
const names = [];
|
|
58
|
+
for (const m of match[0].matchAll(/writeSecretFile\(['"]([^'"]+)['"]/g)) {
|
|
59
|
+
names.push(m[1]);
|
|
60
|
+
}
|
|
61
|
+
return new Set(names);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wizardSecretNames() {
|
|
65
|
+
const src = fs.readFileSync(
|
|
66
|
+
path.join(__dirname, '..', 'setup-server', 'server.js'),
|
|
67
|
+
'utf8',
|
|
68
|
+
);
|
|
69
|
+
// handleConfigure writes secrets via writeSecretFile
|
|
70
|
+
const match = src.match(/async function handleConfigure[\s\S]*?^}/m);
|
|
71
|
+
assert.ok(match, 'could not find handleConfigure in setup-server/server.js');
|
|
72
|
+
const names = [];
|
|
73
|
+
for (const m of match[0].matchAll(/writeSecretFile\(['"]([^'"]+)['"]/g)) {
|
|
74
|
+
names.push(m[1]);
|
|
75
|
+
}
|
|
76
|
+
// gateway_token is written via ensureGatewayToken, not directly
|
|
77
|
+
names.push('gateway_token');
|
|
78
|
+
return new Set(names);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('CLI ↔ Wizard parity', () => {
|
|
84
|
+
|
|
85
|
+
// --- Env vars ---
|
|
86
|
+
|
|
87
|
+
it('wizard writes every env var that CLI normalizeConfig produces (minus secrets)', () => {
|
|
88
|
+
const cliKeys = cliEnvKeys();
|
|
89
|
+
const wizKeys = wizardEnvKeys();
|
|
90
|
+
|
|
91
|
+
// CLI normalizeConfig includes secret-adjacent keys (LLM_API_KEY, OPENAI_API_KEY, etc.)
|
|
92
|
+
// that the wizard writes to secret files instead of .env. Filter those out.
|
|
93
|
+
const secretAdjacentKeys = new Set([
|
|
94
|
+
'LLM_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
|
|
95
|
+
'TELEGRAM_BOT_TOKEN', 'GATEWAY_TOKEN',
|
|
96
|
+
// TELEGRAM_AUTO_PAIR_FIRST_DM is set by CLI but wizard handles auto-pair implicitly
|
|
97
|
+
'TELEGRAM_AUTO_PAIR_FIRST_DM',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
const cliNonSecret = new Set([...cliKeys].filter(k => !secretAdjacentKeys.has(k)));
|
|
101
|
+
|
|
102
|
+
for (const key of cliNonSecret) {
|
|
103
|
+
assert.ok(
|
|
104
|
+
wizKeys.has(key),
|
|
105
|
+
`CLI writes env var "${key}" but wizard does not. Add it to handleConfigure's envVars.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('CLI writes every env var that wizard produces', () => {
|
|
111
|
+
const cliKeys = cliEnvKeys();
|
|
112
|
+
const wizKeys = wizardEnvKeys();
|
|
113
|
+
|
|
114
|
+
for (const key of wizKeys) {
|
|
115
|
+
assert.ok(
|
|
116
|
+
cliKeys.has(key),
|
|
117
|
+
`Wizard writes env var "${key}" but CLI normalizeConfig does not. Add it to normalizeConfig.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// --- Secrets ---
|
|
123
|
+
|
|
124
|
+
it('both paths write the same secret files', () => {
|
|
125
|
+
const cliSec = cliSecretNames();
|
|
126
|
+
const wizSec = wizardSecretNames();
|
|
127
|
+
|
|
128
|
+
for (const name of cliSec) {
|
|
129
|
+
assert.ok(
|
|
130
|
+
wizSec.has(name),
|
|
131
|
+
`CLI writes secret "${name}" but wizard does not. Add writeSecretFile('${name}', ...) to handleConfigure.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
for (const name of wizSec) {
|
|
135
|
+
assert.ok(
|
|
136
|
+
cliSec.has(name),
|
|
137
|
+
`Wizard writes secret "${name}" but CLI does not. Add writeSecretFile('${name}', ...) to writeSecrets.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// --- Providers ---
|
|
143
|
+
|
|
144
|
+
it('both paths support the same set of providers', () => {
|
|
145
|
+
const cliProviders = new Set(
|
|
146
|
+
Object.keys(cli.MODEL_CATALOG).map(k => k.split(':')[0]),
|
|
147
|
+
);
|
|
148
|
+
const wizProviders = new Set(Object.keys(wizard.MODEL_CATALOG));
|
|
149
|
+
|
|
150
|
+
for (const p of cliProviders) {
|
|
151
|
+
assert.ok(
|
|
152
|
+
wizProviders.has(p),
|
|
153
|
+
`CLI supports provider "${p}" but wizard MODEL_CATALOG does not.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
for (const p of wizProviders) {
|
|
157
|
+
assert.ok(
|
|
158
|
+
cliProviders.has(p),
|
|
159
|
+
`Wizard supports provider "${p}" but CLI MODEL_CATALOG does not.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// --- Auth modes ---
|
|
165
|
+
|
|
166
|
+
it('CLI MODEL_CATALOG covers both api-key and subscription for non-openrouter providers', () => {
|
|
167
|
+
const cliKeys = Object.keys(cli.MODEL_CATALOG);
|
|
168
|
+
for (const provider of ['openai', 'anthropic']) {
|
|
169
|
+
assert.ok(
|
|
170
|
+
cliKeys.includes(`${provider}:api-key`),
|
|
171
|
+
`CLI missing "${provider}:api-key" catalog entry`,
|
|
172
|
+
);
|
|
173
|
+
assert.ok(
|
|
174
|
+
cliKeys.includes(`${provider}:subscription`),
|
|
175
|
+
`CLI missing "${provider}:subscription" catalog entry`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// --- i18n ---
|
|
181
|
+
|
|
182
|
+
it('CLI TEXT has the same keys in English and Spanish', () => {
|
|
183
|
+
// We need to read the source to extract TEXT keys since TEXT is not exported
|
|
184
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'cli.js'), 'utf8');
|
|
185
|
+
|
|
186
|
+
function extractTextKeys(lang) {
|
|
187
|
+
// Find the start of the language block
|
|
188
|
+
const blockStart = src.indexOf(` ${lang}: {`);
|
|
189
|
+
assert.ok(blockStart !== -1, `could not find TEXT.${lang} block`);
|
|
190
|
+
// Find matching closing brace by counting braces
|
|
191
|
+
let depth = 0;
|
|
192
|
+
let start = -1;
|
|
193
|
+
for (let i = blockStart; i < src.length; i++) {
|
|
194
|
+
if (src[i] === '{') {
|
|
195
|
+
if (start === -1) start = i;
|
|
196
|
+
depth++;
|
|
197
|
+
}
|
|
198
|
+
if (src[i] === '}') {
|
|
199
|
+
depth--;
|
|
200
|
+
if (depth === 0) {
|
|
201
|
+
const block = src.slice(start, i + 1);
|
|
202
|
+
// Extract top-level keys (simple property names before :)
|
|
203
|
+
const keys = [];
|
|
204
|
+
for (const m of block.matchAll(/^\s{4}(\w+)\s*:/gm)) {
|
|
205
|
+
keys.push(m[1]);
|
|
206
|
+
}
|
|
207
|
+
return new Set(keys);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
assert.fail(`could not parse TEXT.${lang} block`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const enKeys = extractTextKeys('en');
|
|
215
|
+
const esKeys = extractTextKeys('es');
|
|
216
|
+
|
|
217
|
+
for (const key of enKeys) {
|
|
218
|
+
assert.ok(esKeys.has(key), `TEXT.en has key "${key}" but TEXT.es does not.`);
|
|
219
|
+
}
|
|
220
|
+
for (const key of esKeys) {
|
|
221
|
+
assert.ok(enKeys.has(key), `TEXT.es has key "${key}" but TEXT.en does not.`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -74,7 +74,7 @@ test('config.toml.template does NOT contain unsupported sections', () => {
|
|
|
74
74
|
|
|
75
75
|
test('config.toml.template uses envsubst variables', () => {
|
|
76
76
|
const toml = read('config.toml.template');
|
|
77
|
-
const vars = ['${MODEL_PROVIDER}', '${
|
|
77
|
+
const vars = ['${MODEL_PROVIDER}', '${ZEROCLAW_MODEL}', '${LIMBO_PORT}'];
|
|
78
78
|
for (const v of vars) {
|
|
79
79
|
assert.ok(toml.includes(v), `Missing envsubst variable: ${v}`);
|
|
80
80
|
}
|
|
@@ -108,7 +108,8 @@ test('entrypoint.sh appends channels_config.telegram conditionally', () => {
|
|
|
108
108
|
|
|
109
109
|
test('Dockerfile pulls ZeroClaw binary from official image', () => {
|
|
110
110
|
const df = read('Dockerfile');
|
|
111
|
-
assert.ok(df.
|
|
111
|
+
assert.ok(df.match(/FROM ghcr\.io\/zeroclaw-labs\/zeroclaw:\S+ AS zeroclaw/),
|
|
112
|
+
'Dockerfile must pull ZeroClaw from ghcr.io/zeroclaw-labs/zeroclaw');
|
|
112
113
|
assert.ok(df.includes('COPY --from=zeroclaw /usr/local/bin/zeroclaw /usr/local/bin/zeroclaw'));
|
|
113
114
|
});
|
|
114
115
|
|