lynkr 9.6.0 → 9.7.1

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.
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `lynkr init` — interactive setup wizard that produces a working .env.
4
+ *
5
+ * Walks the user through:
6
+ * 1. Usage mode (Claude Pro/Max subscription via wrap, or API-key direct).
7
+ * 2. Per-tier model selection across all supported providers.
8
+ * 3. Routing-intelligence knobs (visible badge, intent window, decay).
9
+ * 4. Credential collection (re-uses values across tiers, never asks twice).
10
+ *
11
+ * Usage:
12
+ * lynkr init # interactive
13
+ * lynkr init --force # overwrite existing .env
14
+ * lynkr init --output=<path> # write to <path> instead of .env
15
+ * lynkr init --dry-run # print to stdout, don't write
16
+ * lynkr init --help
17
+ *
18
+ * @module bin/lynkr-init
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const readline = require('readline');
24
+
25
+ // ──────────────────────────────────────────────────────────────────────────────
26
+ // Provider schema
27
+ // ──────────────────────────────────────────────────────────────────────────────
28
+ //
29
+ // Canonical list pulled from src/config/index.js SUPPORTED_MODEL_PROVIDERS.
30
+ // Each entry lists the credential env vars the user needs to supply and any
31
+ // model/endpoint extras with sensible defaults. Local providers (no creds) are
32
+ // flagged so the wizard skips the credential prompt.
33
+
34
+ const PROVIDERS = {
35
+ ollama: {
36
+ label: 'Ollama (local, free)',
37
+ local: true,
38
+ creds: [],
39
+ extras: [
40
+ { key: 'OLLAMA_ENDPOINT', label: 'endpoint', default: 'http://localhost:11434' },
41
+ ],
42
+ defaultModel: 'qwen2.5-coder:latest',
43
+ },
44
+ llamacpp: {
45
+ label: 'llama.cpp (local)',
46
+ local: true,
47
+ creds: [],
48
+ extras: [
49
+ { key: 'LLAMACPP_ENDPOINT', label: 'endpoint', default: 'http://localhost:8080' },
50
+ ],
51
+ defaultModel: 'qwen2.5-coder',
52
+ },
53
+ lmstudio: {
54
+ label: 'LM Studio (local)',
55
+ local: true,
56
+ creds: [],
57
+ extras: [
58
+ { key: 'LMSTUDIO_ENDPOINT', label: 'endpoint', default: 'http://localhost:1234/v1' },
59
+ ],
60
+ defaultModel: 'qwen2.5-coder',
61
+ },
62
+ 'azure-anthropic': {
63
+ label: 'Azure Anthropic (Claude via Azure)',
64
+ local: false,
65
+ creds: [
66
+ { key: 'AZURE_ANTHROPIC_ENDPOINT', label: 'Azure Anthropic endpoint URL' },
67
+ { key: 'AZURE_ANTHROPIC_API_KEY', label: 'Azure Anthropic API key', secret: true },
68
+ ],
69
+ extras: [],
70
+ defaultModel: 'claude-sonnet-4-6',
71
+ },
72
+ 'azure-openai': {
73
+ label: 'Azure OpenAI (GPT family via Azure)',
74
+ local: false,
75
+ creds: [
76
+ { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure OpenAI endpoint URL' },
77
+ { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API key', secret: true },
78
+ { key: 'AZURE_OPENAI_DEPLOYMENT', label: 'Deployment name', default: 'gpt-5.2-chat' },
79
+ ],
80
+ extras: [],
81
+ defaultModel: 'gpt-5.2-chat',
82
+ },
83
+ openai: {
84
+ label: 'OpenAI (direct)',
85
+ local: false,
86
+ creds: [
87
+ { key: 'OPENAI_API_KEY', label: 'OpenAI API key', secret: true },
88
+ ],
89
+ extras: [],
90
+ defaultModel: 'gpt-4o',
91
+ },
92
+ openrouter: {
93
+ label: 'OpenRouter (100+ models, one key)',
94
+ local: false,
95
+ creds: [
96
+ { key: 'OPENROUTER_API_KEY', label: 'OpenRouter API key', secret: true },
97
+ ],
98
+ extras: [],
99
+ defaultModel: 'anthropic/claude-sonnet-4',
100
+ },
101
+ databricks: {
102
+ label: 'Databricks Foundation Models',
103
+ local: false,
104
+ creds: [
105
+ { key: 'DATABRICKS_API_BASE', label: 'Databricks workspace URL' },
106
+ { key: 'DATABRICKS_API_KEY', label: 'Databricks API token', secret: true },
107
+ ],
108
+ extras: [],
109
+ defaultModel: 'databricks-claude-sonnet-4',
110
+ },
111
+ bedrock: {
112
+ label: 'AWS Bedrock',
113
+ local: false,
114
+ creds: [
115
+ { key: 'BEDROCK_API_KEY', label: 'AWS Bedrock API key (or use IAM)', secret: true },
116
+ ],
117
+ extras: [],
118
+ defaultModel: 'anthropic.claude-sonnet-4-v1:0',
119
+ },
120
+ vertex: {
121
+ label: 'Google Vertex AI',
122
+ local: false,
123
+ creds: [
124
+ { key: 'VERTEX_API_KEY', label: 'Vertex API key (or use ADC)', secret: true },
125
+ ],
126
+ extras: [],
127
+ defaultModel: 'gemini-2.0-flash',
128
+ },
129
+ zai: {
130
+ label: 'Z.ai (GLM family)',
131
+ local: false,
132
+ creds: [
133
+ { key: 'ZAI_API_KEY', label: 'Z.ai API key', secret: true },
134
+ ],
135
+ extras: [],
136
+ defaultModel: 'GLM-4.7',
137
+ },
138
+ moonshot: {
139
+ label: 'Moonshot (Kimi family)',
140
+ local: false,
141
+ creds: [
142
+ { key: 'MOONSHOT_API_KEY', label: 'Moonshot API key', secret: true },
143
+ ],
144
+ extras: [],
145
+ defaultModel: 'kimi-k2-turbo-preview',
146
+ },
147
+ };
148
+
149
+ const PROVIDER_ORDER = [
150
+ 'ollama', 'llamacpp', 'lmstudio',
151
+ 'azure-anthropic', 'azure-openai', 'openai', 'openrouter',
152
+ 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot',
153
+ ];
154
+ const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
155
+
156
+ // Always-emitted baseline. Mirrors the production-grade config the maintainer
157
+ // runs locally: caching/compression on, generous policy budgets, sandboxed
158
+ // agents, MCP/web defaults, etc. Users can edit any of these post-generation;
159
+ // the wizard prompts only for tier picks, credentials, and a handful of
160
+ // intelligence knobs. Everything else is opinionated default.
161
+ //
162
+ // Categorised inline so future contributors know which group a key lives in.
163
+ const BASELINE_ENV = {
164
+ // ── Databricks placeholders (satisfy startup validator) ───────────────
165
+ DATABRICKS_API_BASE: 'http://localhost:8081',
166
+ DATABRICKS_API_KEY: 'tier-routing-active',
167
+ DATABRICKS_ENDPOINT_PATH: '/unused',
168
+
169
+ // ── Server ────────────────────────────────────────────────────────────
170
+ PORT: '8081',
171
+ NODE_ENV: 'production',
172
+ REQUEST_JSON_LIMIT: '1gb',
173
+ SESSION_DB_PATH: './data/sessions.db',
174
+ ENABLE_TOOL_SEARCH: 'true',
175
+ LOG_LEVEL: 'silent',
176
+
177
+ // ── Routing intelligence (tuned defaults) ─────────────────────────────
178
+ LYNKR_PREFLIGHT_ENABLED: 'false',
179
+ LYNKR_PREFLIGHT_TIMEOUT_MS: '120000',
180
+ LYNKR_CASCADE_ENABLED: 'true',
181
+ LYNKR_KNN_MIN_INDEX_SIZE: '200',
182
+ LYNKR_KNN_CONFIDENCE_HIGH: '0.55',
183
+ LYNKR_KNN_CONFIDENCE_LOW: '0.30',
184
+
185
+ // ── Tool execution ────────────────────────────────────────────────────
186
+ TOOL_EXECUTION_MODE: 'client',
187
+ TOOL_INJECTION_ENABLED: 'false',
188
+ SMART_TOOL_SELECTION_MODE: 'disabled',
189
+ SMART_TOOL_SELECTION_TOKEN_BUDGET: '2500',
190
+ CODE_MODE_ENABLED: 'true',
191
+
192
+ // ── Caching ───────────────────────────────────────────────────────────
193
+ PROMPT_CACHE_ENABLED: 'true',
194
+ PROMPT_CACHE_MAX_ENTRIES: '1000',
195
+ PROMPT_CACHE_TTL_MS: '300000',
196
+ SEMANTIC_CACHE_ENABLED: 'true',
197
+ SEMANTIC_CACHE_THRESHOLD: '0.85',
198
+ SEMANTIC_CACHE_MAX_ENTRIES: '50',
199
+ SEMANTIC_CACHE_TTL_MS: '300000',
200
+
201
+ // ── Compression: TOON + Headroom sidecar ──────────────────────────────
202
+ TOON_ENABLED: 'true',
203
+ TOON_MIN_BYTES: '4096',
204
+ TOON_FAIL_OPEN: 'true',
205
+ TOON_LOG_STATS: 'true',
206
+ HEADROOM_ENABLED: 'true',
207
+ HEADROOM_ENDPOINT: 'http://localhost:8787',
208
+ HEADROOM_TIMEOUT_MS: '5000',
209
+ HEADROOM_MIN_TOKENS: '100',
210
+ HEADROOM_MODE: 'optimize',
211
+ HEADROOM_PROVIDER: 'anthropic',
212
+ HEADROOM_DOCKER_ENABLED: 'true',
213
+ HEADROOM_DOCKER_IMAGE: 'lynkr/headroom-sidecar:latest',
214
+ HEADROOM_DOCKER_CONTAINER_NAME: 'lynkr-headroom',
215
+ HEADROOM_DOCKER_PORT: '8787',
216
+ HEADROOM_DOCKER_AUTO_BUILD: 'true',
217
+ HEADROOM_SMART_CRUSHER: 'true',
218
+ HEADROOM_SMART_CRUSHER_MIN_TOKENS: '200',
219
+ HEADROOM_SMART_CRUSHER_MAX_ITEMS: '15',
220
+ HEADROOM_TOOL_CRUSHER: 'true',
221
+ HEADROOM_CACHE_ALIGNER: 'true',
222
+ HEADROOM_ROLLING_WINDOW: 'true',
223
+ HEADROOM_KEEP_TURNS: '10',
224
+ HEADROOM_CCR: 'true',
225
+ HEADROOM_CCR_TTL: '300',
226
+
227
+ // ── Memory + token tracking ───────────────────────────────────────────
228
+ MEMORY_ENABLED: 'true',
229
+ MEMORY_RETRIEVAL_LIMIT: '5',
230
+ MEMORY_SURPRISE_THRESHOLD: '0.3',
231
+ MEMORY_MAX_AGE_DAYS: '90',
232
+ MEMORY_MAX_COUNT: '10000',
233
+ MEMORY_EXTRACTION_ENABLED: 'true',
234
+ MEMORY_DECAY_ENABLED: 'true',
235
+ MEMORY_DECAY_HALF_LIFE: '30',
236
+ MEMORY_FORMAT: 'compact',
237
+ MEMORY_DEDUP_ENABLED: 'true',
238
+ MEMORY_DEDUP_LOOKBACK: '5',
239
+ MEMORY_TTL: '3600000',
240
+ TOKEN_TRACKING_ENABLED: 'true',
241
+ TOOL_TRUNCATION_ENABLED: 'true',
242
+
243
+ // ── Prompt/output shaping ─────────────────────────────────────────────
244
+ SYSTEM_PROMPT_MODE: 'dynamic',
245
+ TOOL_DESCRIPTIONS: 'minimal',
246
+ HISTORY_COMPRESSION_ENABLED: 'true',
247
+ HISTORY_KEEP_RECENT_TURNS: '10',
248
+ HISTORY_SUMMARIZE_OLDER: 'true',
249
+ TOKEN_BUDGET_WARNING: '100000',
250
+ TOKEN_BUDGET_MAX: '180000',
251
+ TOKEN_BUDGET_ENFORCEMENT: 'true',
252
+ CAVEMAN_ENABLED: 'false',
253
+ CAVEMAN_LEVEL: 'full',
254
+ MARKDOWN_RENDER_ANSI: 'false',
255
+
256
+ // ── Policy & budgets ──────────────────────────────────────────────────
257
+ POLICY_MAX_STEPS: '2000',
258
+ POLICY_MAX_TOOL_CALLS: '2000',
259
+ POLICY_TOOL_LOOP_THRESHOLD: '100',
260
+ POLICY_GIT_ALLOW_PUSH: 'false',
261
+ POLICY_GIT_ALLOW_PULL: 'true',
262
+ POLICY_GIT_ALLOW_COMMIT: 'true',
263
+ POLICY_GIT_REQUIRE_TESTS: 'false',
264
+ POLICY_GIT_AUTOSTASH: 'false',
265
+ POLICY_FILE_BLOCKED_PATHS: '/.env,.env,/etc/passwd,/etc/shadow',
266
+ POLICY_SAFE_COMMANDS_ENABLED: 'true',
267
+
268
+ // ── Agents ────────────────────────────────────────────────────────────
269
+ AGENTS_ENABLED: 'true',
270
+ AGENTS_MAX_CONCURRENT: '10',
271
+ AGENTS_DEFAULT_MODEL: 'haiku',
272
+ AGENTS_MAX_STEPS: '15',
273
+ AGENTS_TIMEOUT: '300000',
274
+
275
+ // ── Rate limiting ─────────────────────────────────────────────────────
276
+ RATE_LIMIT_ENABLED: 'true',
277
+ RATE_LIMIT_WINDOW_MS: '60000',
278
+ RATE_LIMIT_MAX: '100',
279
+ RATE_LIMIT_KEY_BY: 'session',
280
+
281
+ // ── Hot reload + load shedding ────────────────────────────────────────
282
+ HOT_RELOAD_ENABLED: 'true',
283
+ HOT_RELOAD_DEBOUNCE_MS: '1000',
284
+ LOAD_SHEDDING_ENABLED: 'true',
285
+ LOAD_SHEDDING_HEAP_THRESHOLD: '0.85',
286
+ LOAD_SHEDDING_MEMORY_THRESHOLD: '0.95',
287
+
288
+ // ── Per-provider extras (secrets stay empty; wizard or user fills in) ─
289
+ AZURE_ANTHROPIC_ENDPOINT: 'https://api.anthropic.com/v1/messages',
290
+ AZURE_ANTHROPIC_VERSION: '2023-06-01',
291
+ AZURE_OPENAI_API_VERSION: '2024-08-01-preview',
292
+ OLLAMA_MODEL: 'minimax-m2.5:cloud',
293
+ OLLAMA_TIMEOUT_MS: '120000',
294
+ OLLAMA_EMBEDDINGS_MODEL: 'nomic-embed-text',
295
+ OLLAMA_EMBEDDINGS_ENDPOINT: 'http://localhost:11434/api/embeddings',
296
+ OPENROUTER_API_KEY: '',
297
+ OPENROUTER_MODEL: 'openai/gpt-4o-mini',
298
+ OPENROUTER_EMBEDDINGS_MODEL: 'openai/text-embedding-ada-002',
299
+ OPENROUTER_ENDPOINT: 'https://openrouter.ai/api/v1/chat/completions',
300
+ OPENROUTER_MAX_TOOLS_FOR_ROUTING: '15',
301
+ MOONSHOT_API_KEY: '',
302
+ MOONSHOT_ENDPOINT: 'https://api.moonshot.ai/v1/chat/completions',
303
+ MOONSHOT_MODEL: 'kimi-k2.6',
304
+ LLAMACPP_ENDPOINT: 'http://localhost:8080',
305
+ LLAMACPP_MODEL: 'default',
306
+ LLAMACPP_TIMEOUT_MS: '120000',
307
+ LLAMACPP_EMBEDDINGS_ENDPOINT: 'http://localhost:8080/embeddings',
308
+ LMSTUDIO_ENDPOINT: 'http://localhost:1234',
309
+ LMSTUDIO_MODEL: 'default',
310
+ LMSTUDIO_TIMEOUT_MS: '120000',
311
+
312
+ // ── MCP sandbox (Docker-isolated MCP tool execution) ──────────────────
313
+ MCP_SANDBOX_ENABLED: 'true',
314
+ MCP_SANDBOX_RUNTIME: 'docker',
315
+ MCP_SANDBOX_CONTAINER_WORKSPACE: '/workspace',
316
+ MCP_SANDBOX_MOUNT_WORKSPACE: 'true',
317
+ MCP_SANDBOX_ALLOW_NETWORKING: 'false',
318
+ MCP_SANDBOX_NETWORK_MODE: 'none',
319
+ MCP_SANDBOX_PASSTHROUGH_ENV: 'PATH,LANG,LC_ALL,TERM,HOME',
320
+ MCP_SANDBOX_TIMEOUT_MS: '20000',
321
+ MCP_SANDBOX_REUSE_SESSION: 'true',
322
+ MCP_SANDBOX_READ_ONLY_ROOT: 'false',
323
+ MCP_SANDBOX_NO_NEW_PRIVILEGES: 'true',
324
+ MCP_SANDBOX_DROP_CAPABILITIES: 'ALL',
325
+ MCP_SANDBOX_MEMORY_LIMIT: '512m',
326
+ MCP_SANDBOX_CPU_LIMIT: '1.0',
327
+ MCP_SANDBOX_PIDS_LIMIT: '100',
328
+ MCP_SANDBOX_PERMISSION_MODE: 'auto',
329
+ MCP_MANIFEST_DIRS: '~/.claude/mcp',
330
+
331
+ // ── Web tools (search + fetch) ────────────────────────────────────────
332
+ WEB_SEARCH_ENDPOINT: 'http://localhost:8888/search',
333
+ WEB_SEARCH_ALLOW_ALL: 'true',
334
+ WEB_SEARCH_TIMEOUT_MS: '10000',
335
+ WEB_FETCH_BODY_PREVIEW_MAX: '10000',
336
+ WEB_SEARCH_RETRY_ENABLED: 'true',
337
+ WEB_SEARCH_MAX_RETRIES: '2',
338
+
339
+ // ── TinyFish (web automation) ─────────────────────────────────────────
340
+ TINYFISH_API_KEY: '',
341
+ TINYFISH_ENDPOINT: 'https://agent.tinyfish.ai/v1/automation/run-sse',
342
+ TINYFISH_BROWSER_PROFILE: 'lite',
343
+ TINYFISH_TIMEOUT_MS: '120000',
344
+ TINYFISH_PROXY_ENABLED: 'false',
345
+ TINYFISH_PROXY_COUNTRY: 'US',
346
+
347
+ // ── Workspace test runner ─────────────────────────────────────────────
348
+ WORKSPACE_TEST_TIMEOUT_MS: '600000',
349
+ WORKSPACE_TEST_SANDBOX: 'auto',
350
+ WORKSPACE_TEST_COVERAGE_FILES: 'coverage/coverage-summary.json',
351
+ };
352
+
353
+ // ──────────────────────────────────────────────────────────────────────────────
354
+ // Args
355
+ // ──────────────────────────────────────────────────────────────────────────────
356
+
357
+ function parseArgs(argv) {
358
+ const opts = { force: false, dryRun: false, output: null, help: false };
359
+ for (let i = 0; i < argv.length; i++) {
360
+ const a = argv[i];
361
+ if (a === '--help' || a === '-h') opts.help = true;
362
+ else if (a === '--force' || a === '-f') opts.force = true;
363
+ else if (a === '--dry-run') opts.dryRun = true;
364
+ else if (a.startsWith('--output=')) opts.output = a.slice('--output='.length);
365
+ else if (a === '--output' || a === '-o') opts.output = argv[++i];
366
+ }
367
+ return opts;
368
+ }
369
+
370
+ function showHelp() {
371
+ console.log(`lynkr init — interactive setup wizard
372
+
373
+ Usage:
374
+ lynkr init Interactive wizard
375
+ lynkr init --force Overwrite existing .env
376
+ lynkr init --output=<path> Write to <path> instead of .env
377
+ lynkr init --dry-run Print to stdout, don't write
378
+ lynkr init --help
379
+
380
+ The wizard asks for:
381
+ 1. Usage mode (Claude Pro/Max via wrap, or direct API keys)
382
+ 2. Provider + model for each tier (SIMPLE / MEDIUM / COMPLEX / REASONING)
383
+ 3. Credentials for each picked provider (re-used across tiers)
384
+ 4. Routing intelligence (visible badge, intent window, decay)
385
+
386
+ Providers covered: ${PROVIDER_ORDER.join(', ')}.
387
+ `);
388
+ }
389
+
390
+ // ──────────────────────────────────────────────────────────────────────────────
391
+ // Prompt helpers
392
+ // ──────────────────────────────────────────────────────────────────────────────
393
+
394
+ function makeAsker() {
395
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
396
+ const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a.trim())));
397
+ const close = () => rl.close();
398
+ return { ask, close };
399
+ }
400
+
401
+ async function pickFromList(ask, label, choices, defaultIdx = 0) {
402
+ console.log(`\n${label}`);
403
+ choices.forEach((c, i) => {
404
+ const marker = i === defaultIdx ? '>' : ' ';
405
+ console.log(` ${marker} ${i + 1}) ${c}`);
406
+ });
407
+ const raw = await ask(`Choice [1-${choices.length}] (default ${defaultIdx + 1}): `);
408
+ if (!raw) return defaultIdx;
409
+ const n = parseInt(raw, 10);
410
+ if (Number.isNaN(n) || n < 1 || n > choices.length) {
411
+ console.log(` → invalid, using default (${choices[defaultIdx]})`);
412
+ return defaultIdx;
413
+ }
414
+ return n - 1;
415
+ }
416
+
417
+ async function askWithDefault(ask, label, defaultValue) {
418
+ const v = await ask(`${label}${defaultValue ? ` [${defaultValue}]` : ''}: `);
419
+ return v || defaultValue || '';
420
+ }
421
+
422
+ async function askYesNo(ask, label, defaultYes = true) {
423
+ const v = await ask(`${label} [${defaultYes ? 'Y/n' : 'y/N'}]: `);
424
+ if (!v) return defaultYes;
425
+ return /^y(es)?$/i.test(v);
426
+ }
427
+
428
+ // ──────────────────────────────────────────────────────────────────────────────
429
+ // Wizard
430
+ // ──────────────────────────────────────────────────────────────────────────────
431
+
432
+ async function runInteractive(opts) {
433
+ console.log('lynkr init — interactive setup\n');
434
+ const { ask, close } = makeAsker();
435
+ const env = {};
436
+ const credsCollected = {}; // dedupe per env key
437
+
438
+ try {
439
+ // ── 1. Usage mode ──
440
+ const modeIdx = await pickFromList(ask,
441
+ 'Usage mode:',
442
+ [
443
+ 'Claude Pro/Max subscription (via `lynkr wrap claude`, OAuth passthrough)',
444
+ 'Direct API usage (pay-as-you-go with API keys)',
445
+ ],
446
+ 0,
447
+ );
448
+ const isWrap = modeIdx === 0;
449
+
450
+ if (isWrap) {
451
+ env.LYNKR_OAUTH_PASSTHROUGH = 'true';
452
+ console.log('\n → OAuth passthrough enabled. COMPLEX/REASONING tiers will be sent');
453
+ console.log(' byte-for-byte to api.anthropic.com against your subscription.');
454
+ console.log(' You only need to configure a local model for SIMPLE/MEDIUM.\n');
455
+ }
456
+
457
+ // ── 2. Per-tier provider + model ──
458
+ const tierConfig = {};
459
+ const collectCreds = async (providerKey) => {
460
+ const p = PROVIDERS[providerKey];
461
+ for (const c of p.creds) {
462
+ if (credsCollected[c.key]) continue;
463
+ const existing = process.env[c.key];
464
+ const def = existing || c.default || '';
465
+ const prompt = ` ${c.label}${c.secret ? ' (hidden output not supported; paste anyway)' : ''}`;
466
+ const v = await askWithDefault(ask, prompt, def);
467
+ if (v) {
468
+ env[c.key] = v;
469
+ credsCollected[c.key] = true;
470
+ }
471
+ }
472
+ for (const ex of p.extras) {
473
+ if (env[ex.key]) continue;
474
+ const v = await askWithDefault(ask, ` ${ex.label}`, ex.default);
475
+ if (v) env[ex.key] = v;
476
+ }
477
+ };
478
+
479
+ const providerChoices = PROVIDER_ORDER.map((k) => PROVIDERS[k].label);
480
+
481
+ for (const tier of TIERS) {
482
+ const headline = isWrap && (tier === 'COMPLEX' || tier === 'REASONING')
483
+ ? `Tier ${tier} — covered by Pro/Max subscription, but you can override:`
484
+ : `Tier ${tier} — pick a provider:`;
485
+ const defaultIdx = isWrap && (tier === 'COMPLEX' || tier === 'REASONING')
486
+ ? PROVIDER_ORDER.indexOf('azure-anthropic')
487
+ : 0;
488
+
489
+ const skipOpt = isWrap && (tier === 'COMPLEX' || tier === 'REASONING')
490
+ ? [...providerChoices, 'Skip — let subscription passthrough handle it']
491
+ : providerChoices;
492
+
493
+ const idx = await pickFromList(ask, headline, skipOpt, defaultIdx);
494
+
495
+ if (idx === providerChoices.length) {
496
+ // Skip selected — leave TIER_<tier> unset
497
+ continue;
498
+ }
499
+
500
+ const providerKey = PROVIDER_ORDER[idx];
501
+ const p = PROVIDERS[providerKey];
502
+ const model = await askWithDefault(ask, ` Model for ${tier}`, p.defaultModel);
503
+ tierConfig[tier] = { provider: providerKey, model };
504
+ await collectCreds(providerKey);
505
+ }
506
+
507
+ for (const tier of TIERS) {
508
+ if (tierConfig[tier]) {
509
+ env[`TIER_${tier}`] = `${tierConfig[tier].provider}:${tierConfig[tier].model}`;
510
+ }
511
+ }
512
+
513
+ // Primary provider hint for legacy code paths
514
+ const firstTier = TIERS.map((t) => tierConfig[t]).find(Boolean);
515
+ if (firstTier) env.MODEL_PROVIDER = firstTier.provider;
516
+
517
+ // ── 3. Routing intelligence ──
518
+ console.log('\nRouting intelligence:');
519
+ if (await askYesNo(ask, 'Show the routing badge in your TUI (`*[Lynkr] …*`)?', isWrap)) {
520
+ env.LYNKR_VISIBLE_ROUTING = 'true';
521
+ }
522
+
523
+ const windowRaw = await askWithDefault(ask, 'Intent-scoring window size (1 = latest message only)', '5');
524
+ const windowN = parseInt(windowRaw, 10);
525
+ if (!Number.isNaN(windowN) && windowN >= 1) env.LYNKR_INTENT_WINDOW_N = String(windowN);
526
+
527
+ const decayRaw = await askWithDefault(ask, 'Intent-scoring per-turn decay (0.1-1.0)', '0.7');
528
+ const decay = parseFloat(decayRaw);
529
+ if (!Number.isNaN(decay) && decay > 0 && decay <= 1) env.LYNKR_INTENT_DECAY = String(decay);
530
+
531
+ close();
532
+ console.log('');
533
+ writeEnvFile(buildEnvContent(env, isWrap, tierConfig), opts);
534
+ } catch (err) {
535
+ close();
536
+ throw err;
537
+ }
538
+ }
539
+
540
+ // ──────────────────────────────────────────────────────────────────────────────
541
+ // Output
542
+ // ──────────────────────────────────────────────────────────────────────────────
543
+
544
+ function buildEnvContent(env, isWrap, tierConfig) {
545
+ // Baseline first, user choices on top — so user input always wins for keys
546
+ // they explicitly answered (e.g. LOG_LEVEL if the wizard ever asks for it).
547
+ const merged = { ...BASELINE_ENV, ...env };
548
+
549
+ const lines = [
550
+ '# Lynkr configuration',
551
+ `# Generated by 'lynkr init' at ${new Date().toISOString()}`,
552
+ `# Mode: ${isWrap ? 'wrap (Claude Pro/Max subscription)' : 'direct API'}`,
553
+ '# Edit directly to tweak; full reference in .env.example',
554
+ '',
555
+ ];
556
+
557
+ // Group output by section in the order it appears in the generated file.
558
+ // Mirrors the layout of the .env.example reference doc.
559
+ const SERVER_KEYS = new Set(['PORT', 'NODE_ENV', 'REQUEST_JSON_LIMIT', 'SESSION_DB_PATH', 'WORKSPACE_ROOT', 'ENABLE_TOOL_SEARCH']);
560
+ const TOOL_EXEC_KEYS = new Set(['TOOL_EXECUTION_MODE', 'TOOL_INJECTION_ENABLED', 'SMART_TOOL_SELECTION_MODE', 'SMART_TOOL_SELECTION_TOKEN_BUDGET', 'CODE_MODE_ENABLED']);
561
+ const CACHE_KEYS = new Set([
562
+ 'PROMPT_CACHE_ENABLED', 'PROMPT_CACHE_MAX_ENTRIES', 'PROMPT_CACHE_TTL_MS',
563
+ 'SEMANTIC_CACHE_ENABLED', 'SEMANTIC_CACHE_THRESHOLD', 'SEMANTIC_CACHE_MAX_ENTRIES', 'SEMANTIC_CACHE_TTL_MS',
564
+ ]);
565
+ const MEMORY_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('MEMORY_') || k === 'TOKEN_TRACKING_ENABLED' || k === 'TOOL_TRUNCATION_ENABLED'));
566
+ const SHAPING_KEYS = new Set([
567
+ 'SYSTEM_PROMPT_MODE', 'TOOL_DESCRIPTIONS',
568
+ 'HISTORY_COMPRESSION_ENABLED', 'HISTORY_KEEP_RECENT_TURNS', 'HISTORY_SUMMARIZE_OLDER',
569
+ 'TOKEN_BUDGET_WARNING', 'TOKEN_BUDGET_MAX', 'TOKEN_BUDGET_ENFORCEMENT',
570
+ 'CAVEMAN_ENABLED', 'CAVEMAN_LEVEL', 'MARKDOWN_RENDER_ANSI',
571
+ ]);
572
+ const POLICY_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('POLICY_')));
573
+ const AGENT_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('AGENTS_')));
574
+ const RATE_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('RATE_LIMIT_')));
575
+ const OPS_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('HOT_RELOAD_') || k.startsWith('LOAD_SHEDDING_')));
576
+ const COMPRESSION_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('TOON_') || k.startsWith('HEADROOM_')));
577
+ const MCP_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('MCP_')));
578
+ const WEB_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('WEB_SEARCH_') || k.startsWith('WEB_FETCH_')));
579
+ const TINYFISH_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('TINYFISH_')));
580
+ const WORKSPACE_TEST_KEYS = new Set(Object.keys(merged).filter((k) => k.startsWith('WORKSPACE_TEST_')));
581
+
582
+ const groups = [
583
+ { heading: '# Tier routing', keys: Object.keys(merged).filter((k) => k.startsWith('TIER_') || k === 'MODEL_PROVIDER') },
584
+ { heading: '# Server', keys: Object.keys(merged).filter((k) => SERVER_KEYS.has(k)) },
585
+ { heading: '# Provider credentials', keys: Object.keys(merged).filter((k) =>
586
+ /(_API_KEY|_ENDPOINT|_API_BASE|_DEPLOYMENT|_MODEL|_ENDPOINT_PATH|_API_VERSION|_VERSION|_TIMEOUT_MS|_EMBEDDINGS_MODEL|_EMBEDDINGS_ENDPOINT|_MAX_TOOLS_FOR_ROUTING)$/.test(k) &&
587
+ !k.startsWith('LYNKR_') && !k.startsWith('HEADROOM_') && !k.startsWith('RATE_LIMIT_') &&
588
+ !k.startsWith('HOT_RELOAD_') && !k.startsWith('LOAD_SHEDDING_') && !k.startsWith('AGENTS_') &&
589
+ !k.startsWith('MCP_') && !k.startsWith('WEB_') && !k.startsWith('TINYFISH_') && !k.startsWith('WORKSPACE_TEST_') &&
590
+ !k.startsWith('NODE_') && !k.startsWith('TOON_')
591
+ ) },
592
+ { heading: '# Routing intelligence', keys: Object.keys(merged).filter((k) => k.startsWith('LYNKR_')) },
593
+ { heading: '# Tool execution', keys: Object.keys(merged).filter((k) => TOOL_EXEC_KEYS.has(k)) },
594
+ { heading: '# Caching', keys: Object.keys(merged).filter((k) => CACHE_KEYS.has(k)) },
595
+ { heading: '# Compression & context', keys: Object.keys(merged).filter((k) => COMPRESSION_KEYS.has(k)) },
596
+ { heading: '# Memory & tracking', keys: Object.keys(merged).filter((k) => MEMORY_KEYS.has(k)) },
597
+ { heading: '# Prompt & output shaping', keys: Object.keys(merged).filter((k) => SHAPING_KEYS.has(k)) },
598
+ { heading: '# Policy & budgets', keys: Object.keys(merged).filter((k) => POLICY_KEYS.has(k)) },
599
+ { heading: '# Agents', keys: Object.keys(merged).filter((k) => AGENT_KEYS.has(k)) },
600
+ { heading: '# Rate limiting', keys: Object.keys(merged).filter((k) => RATE_KEYS.has(k)) },
601
+ { heading: '# MCP sandbox', keys: Object.keys(merged).filter((k) => MCP_KEYS.has(k)) },
602
+ { heading: '# Web tools', keys: Object.keys(merged).filter((k) => WEB_KEYS.has(k)) },
603
+ { heading: '# TinyFish (web automation)', keys: Object.keys(merged).filter((k) => TINYFISH_KEYS.has(k)) },
604
+ { heading: '# Workspace test runner', keys: Object.keys(merged).filter((k) => WORKSPACE_TEST_KEYS.has(k)) },
605
+ { heading: '# Ops (hot reload, load shedding)', keys: Object.keys(merged).filter((k) => OPS_KEYS.has(k)) },
606
+ { heading: '# Logging', keys: ['LOG_LEVEL'].filter((k) => k in merged) },
607
+ ];
608
+
609
+ const seen = new Set();
610
+ for (const g of groups) {
611
+ if (!g.keys.length) continue;
612
+ lines.push(g.heading);
613
+ for (const k of g.keys) {
614
+ if (seen.has(k)) continue;
615
+ lines.push(`${k}=${merged[k]}`);
616
+ seen.add(k);
617
+ }
618
+ lines.push('');
619
+ }
620
+
621
+ // Catch-all for any other keys (e.g. _DEPLOYMENT defaults) we missed.
622
+ const remaining = Object.keys(merged).filter((k) => !seen.has(k));
623
+ if (remaining.length) {
624
+ lines.push('# Other');
625
+ for (const k of remaining) lines.push(`${k}=${merged[k]}`);
626
+ lines.push('');
627
+ }
628
+
629
+ return lines.join('\n');
630
+ }
631
+
632
+ function writeEnvFile(content, opts) {
633
+ if (opts.dryRun) {
634
+ process.stdout.write(content);
635
+ return;
636
+ }
637
+ const target = opts.output || path.join(process.cwd(), '.env');
638
+ if (fs.existsSync(target) && !opts.force) {
639
+ console.error(`✗ ${target} already exists. Use --force to overwrite, or --output=<path>.`);
640
+ process.exit(1);
641
+ }
642
+ fs.writeFileSync(target, content);
643
+ console.log(`✓ Wrote ${target}`);
644
+ }
645
+
646
+ // ──────────────────────────────────────────────────────────────────────────────
647
+ // Entry
648
+ // ──────────────────────────────────────────────────────────────────────────────
649
+
650
+ async function main() {
651
+ const opts = parseArgs(process.argv.slice(2));
652
+ if (opts.help) return showHelp();
653
+
654
+ if (!process.stdin.isTTY) {
655
+ console.error('✗ lynkr init needs an interactive TTY.');
656
+ console.error(' If you need a non-interactive setup, copy .env.example to .env manually,');
657
+ console.error(' or run `lynkr init --dry-run` to preview the wizard prompts.');
658
+ process.exit(1);
659
+ }
660
+
661
+ return runInteractive(opts);
662
+ }
663
+
664
+ // Run when invoked directly (`node bin/lynkr-init.js`) or dispatched from
665
+ // cli.js (which sets _LYNKR_SUBCMD). Stay quiet when require()'d by tests.
666
+ if (require.main === module || process.env._LYNKR_SUBCMD === 'init') {
667
+ main().catch((err) => {
668
+ console.error(`✗ ${err.message}`);
669
+ process.exit(1);
670
+ });
671
+ }
672
+
673
+ module.exports = {
674
+ PROVIDERS,
675
+ PROVIDER_ORDER,
676
+ TIERS,
677
+ parseArgs,
678
+ buildEnvContent,
679
+ };