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 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
- const autoPairChoice = await selectMenu(t(language, 'telegramAutoPairQuestion'), [
916
- { label: t(language, 'no'), value: 'false' },
917
- { label: t(language, 'yes'), value: 'true' },
918
- ], language);
919
- telegramAutoPair = autoPairChoice.value;
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(
@@ -3,7 +3,7 @@
3
3
  # conditionally — see entrypoint.sh for details.
4
4
 
5
5
  default_provider = "${MODEL_PROVIDER}"
6
- default_model = "${MODEL_PROVIDER}/${MODEL_NAME}"
6
+ default_model = "${ZEROCLAW_MODEL}"
7
7
 
8
8
  [gateway]
9
9
  host = "127.0.0.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.22.0",
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}', '${MODEL_NAME}', '${LIMBO_PORT}'];
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.includes('FROM ghcr.io/zeroclaw-labs/zeroclaw:latest AS zeroclaw'));
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