scalix 1.2.0 → 1.4.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/dist/headless.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import readline from 'node:readline';
2
2
  import fs from 'node:fs/promises';
3
- import path from 'node:path';
4
3
  import { execSync } from 'node:child_process';
5
4
  import { createAgent } from './agent.js';
6
5
  import { defaultTools } from './tools.js';
@@ -23,6 +22,7 @@ import { loadMemory, autoDetectConventions, saveMemory, memoryToContext, addNote
23
22
  import { buildProjectIndex } from './project-index.js';
24
23
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
25
24
  import { discoverPlugins, getPluginTools, pluginsSummary } from './plugins.js';
25
+ import { isWorkspaceTrusted } from './workspace-trust.js';
26
26
  import { loadCustomCommands, expandCommand, commandsSummary } from './commands.js';
27
27
  import { hooksSummary } from './hooks.js';
28
28
  import { spawnSubAgent, pickCheapestModel } from './sub-agent.js';
@@ -102,6 +102,7 @@ Options:
102
102
  --output-format Alias for --format
103
103
  --output-schema Path to JSON schema file (implies --format json-schema)
104
104
  --worktree, -w Run in an isolated git worktree (optional: --worktree <name>)
105
+ --version, -v Print the installed version
105
106
  --help, -h Show this help
106
107
  `);
107
108
  process.exit(0);
@@ -131,7 +132,7 @@ async function buildEnhancedInstructions() {
131
132
  const ctx = await buildProjectContext(cwd);
132
133
  return contextToInstructions(base, ctx);
133
134
  }
134
- async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
135
+ async function runSinglePrompt(prompt, output, format, outputSchemaPath, beforeExit) {
135
136
  const apiKey = process.env[ENV_API_KEY];
136
137
  if (!apiKey) {
137
138
  console.error('Error: SCALIX_API_KEY is not set.\nSet it via: export SCALIX_API_KEY=sk-...\nOr run the interactive setup: scalix');
@@ -159,7 +160,12 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
159
160
  process.exit(1);
160
161
  }
161
162
  const mcpTools = loadMcpServices();
162
- const plugins = await discoverPlugins(process.cwd());
163
+ // Only discover/load project-local plugins in a trusted workspace — plugins
164
+ // dynamically import and execute code from the project directory.
165
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
166
+ if (!isWorkspaceTrusted()) {
167
+ process.stderr.write('Project plugins skipped (untrusted workspace).\n');
168
+ }
163
169
  const pluginTools = getPluginTools(plugins);
164
170
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
165
171
  const instructions = await buildEnhancedInstructions();
@@ -201,17 +207,29 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
201
207
  groundingCheck: true,
202
208
  contextRetrieval: true,
203
209
  });
210
+ // Register the account's Scalix Cloud platform tools (one attempt; failure
211
+ // is stated once and the run continues with local tools only).
212
+ const cloudStatus = await agent.refreshCloudTools();
213
+ if (cloudStatus.state === 'unavailable') {
214
+ process.stderr.write(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m\n`);
215
+ }
204
216
  let fullResponse = '';
205
- agent.on('stream:end', (text) => { fullResponse = text; });
206
217
  agent.on('tool:call', (name) => process.stderr.write(`\x1b[33m▶ ${name}\x1b[0m\n`));
207
218
  agent.on('verify:pass', (r) => process.stderr.write(`\x1b[32m✓ ${r.checker} passed\x1b[0m\n`));
208
219
  agent.on('verify:fail', (r) => process.stderr.write(`\x1b[31m✗ ${r.checker} failed: ${r.output.split('\n')[0]}\x1b[0m\n`));
209
220
  agent.on('error', (err) => process.stderr.write(`\x1b[31mError: ${err.message}\x1b[0m\n`));
221
+ agent.on('usage', (data) => {
222
+ costTracker.record(data.model || selectedModel[0], data);
223
+ });
210
224
  try {
211
- await agent.send(prompt);
225
+ // Use the return value (applies JSON extraction for --format json/json-schema)
226
+ // rather than the raw stream text.
227
+ fullResponse = await agent.send(prompt);
212
228
  }
213
229
  catch (err) {
214
230
  console.error(`Error: ${err.message}`);
231
+ if (beforeExit)
232
+ await beforeExit();
215
233
  process.exit(1);
216
234
  }
217
235
  let formatted;
@@ -245,9 +263,11 @@ async function runSinglePrompt(prompt, output, format, outputSchemaPath) {
245
263
  process.stdout.write(formatted + '\n');
246
264
  }
247
265
  await closeBrowser();
266
+ if (beforeExit)
267
+ await beforeExit();
248
268
  process.exit(0);
249
269
  }
250
- async function runPipeMode() {
270
+ async function runPipeMode(beforeExit) {
251
271
  const MAX_PIPE_BYTES = 50 * 1024 * 1024;
252
272
  const chunks = [];
253
273
  let totalBytes = 0;
@@ -257,6 +277,8 @@ async function runPipeMode() {
257
277
  totalBytes += Buffer.byteLength(str);
258
278
  if (totalBytes > MAX_PIPE_BYTES) {
259
279
  console.error(`Error: Pipe input exceeds ${MAX_PIPE_BYTES / 1024 / 1024}MB limit.`);
280
+ if (beforeExit)
281
+ await beforeExit();
260
282
  process.exit(1);
261
283
  }
262
284
  chunks.push(str);
@@ -264,14 +286,18 @@ async function runPipeMode() {
264
286
  }
265
287
  catch (err) {
266
288
  console.error(`Error reading pipe input: ${err.message}`);
289
+ if (beforeExit)
290
+ await beforeExit();
267
291
  process.exit(1);
268
292
  }
269
293
  const prompt = chunks.join('').trim();
270
294
  if (!prompt) {
271
295
  console.error('Error: No input received from stdin.');
296
+ if (beforeExit)
297
+ await beforeExit();
272
298
  process.exit(1);
273
299
  }
274
- await runSinglePrompt(prompt);
300
+ await runSinglePrompt(prompt, undefined, undefined, undefined, beforeExit);
275
301
  }
276
302
  async function runInteractive() {
277
303
  const apiKey = process.env[ENV_API_KEY];
@@ -292,7 +318,12 @@ async function runInteractive() {
292
318
  throw new Error('No models configured. Set SCALIX_MODEL env var or run interactive setup.');
293
319
  }
294
320
  const mcpTools = loadMcpServices();
295
- const plugins = await discoverPlugins(process.cwd());
321
+ // Only discover/load project-local plugins in a trusted workspace — plugins
322
+ // dynamically import and execute code from the project directory.
323
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
324
+ if (!isWorkspaceTrusted()) {
325
+ process.stderr.write('Project plugins skipped (untrusted workspace).\n');
326
+ }
296
327
  const pluginTools = getPluginTools(plugins);
297
328
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
298
329
  const instructions = await buildEnhancedInstructions();
@@ -367,27 +398,41 @@ async function runInteractive() {
367
398
  console.log(`\x1b[33m\u26A0 Context usage: ${info.percentage}% (${info.tokens}/${info.maxTokens} tokens)\x1b[0m`);
368
399
  }
369
400
  });
370
- const toolCount = allTools.length;
401
+ // Register the account's Scalix Cloud platform tools (one attempt; failure
402
+ // is stated once and the session continues with local tools only).
403
+ const cloudStatus = await agent.refreshCloudTools();
404
+ if (cloudStatus.state === 'unavailable') {
405
+ console.log(`\x1b[33m☁ Scalix Cloud unavailable — running with local tools only. ${cloudStatus.error ?? ''}\x1b[0m`);
406
+ }
407
+ const toolCount = allTools.length + cloudStatus.toolCount;
371
408
  const modelDisplay = selectedModel.map(shortModel).join(' | ');
372
- console.log(`\x1b[36mAgent ready\x1b[0m (${modelDisplay}) with ${toolCount} tools. Type /help for commands.`);
409
+ const cloudSuffix = cloudStatus.state === 'connected' ? ` (${cloudStatus.toolCount} from Scalix Cloud)` : '';
410
+ console.log(`\x1b[36mAgent ready\x1b[0m (${modelDisplay}) with ${toolCount} tools${cloudSuffix}. Type /help for commands.`);
373
411
  if (plugins.length > 0) {
374
412
  console.log(`\x1b[36mPlugins:\x1b[0m ${plugins.map((p) => p.manifest.name).join(', ')}`);
375
413
  }
376
414
  console.log('');
377
- function shutdown() {
415
+ let shuttingDown = false;
416
+ async function shutdown() {
417
+ if (shuttingDown)
418
+ return;
419
+ shuttingDown = true;
378
420
  const msgs = agent.getMessages();
379
421
  if (msgs.length > 0) {
380
- saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
422
+ // Await the save so sessions aren't lost on SIGINT (process.exit would
423
+ // otherwise cut off the fire-and-forget write).
424
+ await saveSession(sessionId, msgs, agent.getModels()[0], undefined, costTracker.getState()).catch(() => { });
381
425
  }
382
426
  if (costTracker.totalTokens > 0) {
383
427
  console.log(`\n\x1b[36mSession: ${costTracker.totalTokens.toLocaleString()} tokens${costTracker.totalCost > 0 ? ` ($${costTracker.totalCost.toFixed(4)})` : ''}\x1b[0m`);
384
428
  }
385
429
  console.log('Goodbye!');
386
430
  rl.close();
387
- closeBrowser().finally(() => process.exit(0));
431
+ await closeBrowser().catch(() => { });
432
+ process.exit(0);
388
433
  }
389
- process.on('SIGINT', shutdown);
390
- process.on('SIGTERM', shutdown);
434
+ process.on('SIGINT', () => { shutdown().catch(() => process.exit(1)); });
435
+ process.on('SIGTERM', () => { shutdown().catch(() => process.exit(1)); });
391
436
  const handleCommand = async (input) => {
392
437
  const cmd = input.split(' ')[0].toLowerCase();
393
438
  if (cmd === '/help') {
@@ -656,7 +701,7 @@ async function runInteractive() {
656
701
  return true;
657
702
  }
658
703
  if (cmd === '/quit' || cmd === '/exit') {
659
- shutdown();
704
+ await shutdown();
660
705
  return true;
661
706
  }
662
707
  const customCmd = customCmds.find((c) => cmd === `/${c.name}`);
@@ -730,11 +775,15 @@ async function main() {
730
775
  }
731
776
  // Set up worktree isolation if requested
732
777
  let worktreeInfo = null;
778
+ // Capture the original cwd BEFORE any process.chdir so cleanup can restore it
779
+ // and resolve the worktree parent correctly.
780
+ const originalCwd = process.cwd();
781
+ let cleanup = async () => { };
733
782
  if (worktree) {
734
783
  const { createWorktree, removeWorktree } = await import('./worktree.js');
735
784
  const name = worktreeName || `session-${Date.now().toString(36)}`;
736
785
  try {
737
- worktreeInfo = await createWorktree(name, process.cwd());
786
+ worktreeInfo = await createWorktree(name, originalCwd);
738
787
  process.chdir(worktreeInfo.path);
739
788
  setSandboxRoot(worktreeInfo.path);
740
789
  console.log(`Working in isolated worktree: ${worktreeInfo.branch} → ${worktreeInfo.path}`);
@@ -743,38 +792,46 @@ async function main() {
743
792
  console.error(`Failed to create worktree: ${err.message}`);
744
793
  process.exit(1);
745
794
  }
746
- const cleanup = async () => {
747
- if (worktreeInfo) {
748
- try {
749
- const origCwd = process.env.SCALIX_ORIGINAL_CWD || process.cwd();
750
- process.chdir(path.dirname(worktreeInfo.path));
751
- await removeWorktree(worktreeInfo, path.dirname(path.dirname(worktreeInfo.path)));
752
- }
753
- catch { }
795
+ let cleanedUp = false;
796
+ cleanup = async () => {
797
+ if (cleanedUp || !worktreeInfo)
798
+ return;
799
+ cleanedUp = true;
800
+ try {
801
+ // Return to the original directory before removing the worktree so we
802
+ // are not standing inside the directory being deleted.
803
+ process.chdir(originalCwd);
804
+ await removeWorktree(worktreeInfo, originalCwd);
754
805
  }
806
+ catch { }
755
807
  };
756
- process.on('exit', () => { cleanup().catch(() => { }); });
808
+ // 'exit' handlers cannot run async work — SIGINT awaits cleanup explicitly.
757
809
  process.on('SIGINT', () => { cleanup().then(() => process.exit(0)).catch(() => process.exit(1)); });
758
- process.env.SCALIX_ORIGINAL_CWD = process.cwd();
759
810
  }
760
- switch (mode) {
761
- case 'run':
762
- if (!prompt) {
763
- console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
764
- process.exit(1);
765
- }
766
- await runSinglePrompt(prompt, output, format, outputSchema);
767
- break;
768
- case 'pipe':
769
- await runPipeMode();
770
- break;
771
- case 'ipc':
772
- await runIpcServer();
773
- break;
774
- case 'interactive':
775
- default:
776
- await runInteractive();
777
- break;
811
+ try {
812
+ switch (mode) {
813
+ case 'run':
814
+ if (!prompt) {
815
+ console.error('Error: No prompt provided. Usage: scalix run "your prompt"');
816
+ await cleanup();
817
+ process.exit(1);
818
+ }
819
+ await runSinglePrompt(prompt, output, format, outputSchema, cleanup);
820
+ break;
821
+ case 'pipe':
822
+ await runPipeMode(cleanup);
823
+ break;
824
+ case 'ipc':
825
+ await runIpcServer();
826
+ break;
827
+ case 'interactive':
828
+ default:
829
+ await runInteractive();
830
+ break;
831
+ }
832
+ }
833
+ finally {
834
+ await cleanup();
778
835
  }
779
836
  }
780
837
  main().catch((err) => {
package/dist/hooks.js CHANGED
@@ -1,10 +1,42 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { resolve, join, basename } from 'node:path';
3
3
  import { exec } from 'node:child_process';
4
- import { promisify } from 'node:util';
5
4
  import { logger } from './logger.js';
6
5
  import { PROJECT_LOOKUP_DIRS, HOOK_DEFAULTS } from './constants.js';
7
- const execAsync = promisify(exec);
6
+ import { sanitizeEnvForCommand } from './sandbox.js';
7
+ // Hook commands are written in POSIX sh syntax ($VAR, pipes, etc). On Windows
8
+ // the default exec shell is cmd.exe, which silently mangles them — so resolve
9
+ // a real POSIX shell (Git Bash) when one is available. `null` = not resolved.
10
+ let cachedHookShell = null;
11
+ function isPosixShell(shellPath) {
12
+ const name = basename(shellPath).toLowerCase().replace(/\.exe$/, '');
13
+ return name === 'bash' || name === 'sh' || name === 'zsh' || name === 'dash';
14
+ }
15
+ function resolveHookShell() {
16
+ if (cachedHookShell !== null)
17
+ return cachedHookShell;
18
+ if (process.platform !== 'win32') {
19
+ cachedHookShell = undefined;
20
+ return undefined;
21
+ }
22
+ const candidates = [
23
+ process.env.SCALIX_HOOK_SHELL,
24
+ process.env.SHELL,
25
+ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Git', 'bin', 'bash.exe'),
26
+ join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'),
27
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe') : undefined,
28
+ ];
29
+ for (const c of candidates) {
30
+ // Skip WSL/System32 bash implicitly: only explicit paths are checked, and
31
+ // non-POSIX shells (e.g. a SHELL pointing at PowerShell) are rejected.
32
+ if (c && isPosixShell(c) && existsSync(c)) {
33
+ cachedHookShell = c;
34
+ return c;
35
+ }
36
+ }
37
+ cachedHookShell = undefined;
38
+ return undefined;
39
+ }
8
40
  const HOOK_PATHS = PROJECT_LOOKUP_DIRS.map(d => `${d}/hooks.json`);
9
41
  export function loadHooks(cwd = process.cwd()) {
10
42
  for (const relPath of HOOK_PATHS) {
@@ -112,17 +144,24 @@ function parseHookOutput(stdout, exitCode) {
112
144
  }
113
145
  export async function executeHook(hook, env, stdinData) {
114
146
  const timeout = hook.timeout ?? HOOK_DEFAULTS.timeoutMs;
115
- const hookEnv = { ...process.env, ...env };
147
+ // Strip secrets from the inherited environment so hook commands can't read
148
+ // API keys, tokens, cloud credentials, etc. The SCALIX_* context vars in
149
+ // `env` are still applied on top.
150
+ const hookEnv = { ...sanitizeEnvForCommand(process.env), ...env };
116
151
  const ac = new AbortController();
117
152
  const hardKill = setTimeout(() => ac.abort(), timeout + 5000);
118
153
  try {
154
+ const shell = resolveHookShell();
119
155
  const child = exec(hook.command, {
120
156
  timeout,
121
157
  env: hookEnv,
122
158
  maxBuffer: HOOK_DEFAULTS.maxBuffer,
123
159
  signal: ac.signal,
160
+ ...(shell ? { shell } : {}),
124
161
  });
125
162
  if (stdinData && child.stdin) {
163
+ // The hook may exit without reading stdin; swallow the resulting EPIPE.
164
+ child.stdin.on('error', () => { });
126
165
  child.stdin.write(stdinData);
127
166
  child.stdin.end();
128
167
  }
@@ -132,8 +171,19 @@ export async function executeHook(hook, env, stdinData) {
132
171
  child.stdout?.on('data', (d) => { stdout += d; });
133
172
  child.stderr?.on('data', (d) => { stderr += d; });
134
173
  child.on('error', reject);
135
- child.on('close', (code) => resolve({ stdout, stderr, code: code ?? 0 }));
174
+ child.on('close', (code, signal) => resolve({
175
+ stdout,
176
+ stderr,
177
+ code: code ?? 0,
178
+ // A killed child reports code=null + a signal (or child.killed on
179
+ // Windows). We only ever kill on timeout/abort, so treat it as such —
180
+ // otherwise `code ?? 0` would misreport the hook as allowed.
181
+ killed: child.killed || signal !== null,
182
+ }));
136
183
  });
184
+ if (result.killed) {
185
+ return { allowed: false, error: `Hook timed out after ${timeout}ms` };
186
+ }
137
187
  return parseHookOutput(result.stdout, result.code);
138
188
  }
139
189
  catch (err) {
@@ -38,7 +38,24 @@ const SENSITIVITY_THRESHOLDS = {
38
38
  medium: { highConfidence: 3, mediumConfidence: 2 },
39
39
  high: { highConfidence: 2, mediumConfidence: 1 },
40
40
  };
41
- export function detectPromptInjection(content, source, sensitivity = 'medium', customPatterns) {
41
+ // Strip zero-width and other invisible/formatting characters that are used to
42
+ // smuggle instructions past pattern matching (zero-width space/joiner, BOM,
43
+ // word joiner, bidi controls, soft hyphen, etc.).
44
+ const INVISIBLE_CHARS_RE = new RegExp('[' +
45
+ '\\u00AD' + // soft hyphen
46
+ '\\u200B-\\u200F' + // zero-width space/non-joiner/joiner, LRM/RLM
47
+ '\\u202A-\\u202E' + // bidi embedding/override controls
48
+ '\\u2060-\\u2064' + // word joiner, invisible operators
49
+ '\\u206A-\\u206F' + // deprecated format controls
50
+ '\\uFEFF' + // BOM / zero-width no-break space
51
+ ']', 'g');
52
+ function normalizeForDetection(text) {
53
+ // NFKC folds homoglyph/compatibility variants (e.g. fullwidth chars) to their
54
+ // canonical forms so evasions collapse onto the same patterns.
55
+ return text.normalize('NFKC').replace(INVISIBLE_CHARS_RE, '');
56
+ }
57
+ export function detectPromptInjection(rawContent, source, sensitivity = 'medium', customPatterns) {
58
+ const content = normalizeForDetection(rawContent);
42
59
  const matched = [];
43
60
  let totalWeight = 0;
44
61
  for (const rule of ALL_PATTERNS) {
@@ -21,6 +21,7 @@ import { browserTools, closeBrowser } from './tools-browser.js';
21
21
  import { loadMcpServices } from './mcp.js';
22
22
  import { loadConfig, DEFAULT_LIMITS } from './settings.js';
23
23
  import { discoverPlugins, getPluginTools } from './plugins.js';
24
+ import { isWorkspaceTrusted } from './workspace-trust.js';
24
25
  import { DEFAULT_MODELS, DEFAULT_INSTRUCTIONS, ENV_API_KEY, ENV_MODEL, ENV_BASE_URL, fetchModelSpecs } from './constants.js';
25
26
  import { loadMemory, autoDetectConventions, saveMemory } from './project-memory.js';
26
27
  import { buildProjectContext, contextToInstructions } from './context-builder.js';
@@ -68,7 +69,12 @@ export async function runIpcServer() {
68
69
  const baseUrl = process.env[ENV_BASE_URL] || config.baseUrl;
69
70
  fetchModelSpecs(baseUrl, apiKey).catch(() => { });
70
71
  const mcpTools = loadMcpServices();
71
- const plugins = await discoverPlugins(process.cwd());
72
+ // Only discover/load project-local plugins in a trusted workspace — plugins
73
+ // dynamically import and execute code from the project directory.
74
+ const plugins = isWorkspaceTrusted() ? await discoverPlugins(process.cwd()) : [];
75
+ if (!isWorkspaceTrusted()) {
76
+ process.stderr.write('IPC: project plugins skipped (untrusted workspace)\n');
77
+ }
72
78
  const pluginTools = getPluginTools(plugins);
73
79
  const allTools = [...defaultTools, ...extraTools, ...webTools, ...advancedTools, ...visionTools, ...voiceTools, ...browserTools, ...mcpTools, ...pluginTools];
74
80
  const instructions = await buildInstructions();
package/dist/keychain.js CHANGED
@@ -1,11 +1,101 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { USER_CONFIG_DIR } from './constants.js';
3
7
  const exec = promisify(execFile);
4
8
  const SERVICE = 'scalix-coder';
5
9
  const ACCOUNT = 'api-key';
10
+ /**
11
+ * SCALIX_NO_KEYCHAIN=1 disables OS keychain integration entirely — the API
12
+ * key stays in the config file. Intended for CI, headless environments, and
13
+ * hermetic tests where spawning security/secret-tool/PowerShell is unwanted.
14
+ */
15
+ function keychainDisabled() {
16
+ const flag = process.env.SCALIX_NO_KEYCHAIN;
17
+ return flag === '1' || flag?.toLowerCase() === 'true';
18
+ }
19
+ function isWindows() {
20
+ return process.platform === 'win32';
21
+ }
6
22
  async function isMacOS() {
7
23
  return process.platform === 'darwin';
8
24
  }
25
+ // ─── Windows DPAPI (user-scoped) via PowerShell ───
26
+ function windowsCredentialPath() {
27
+ // Resolve the home directory the same way the rest of the codebase does
28
+ // (settings/sessions/workspace-trust) so the credential store stays
29
+ // consistent and honors HOME (set by Git Bash and by tests).
30
+ return path.join(process.env.HOME || process.env.USERPROFILE || os.homedir() || '.', USER_CONFIG_DIR, 'credential.dat');
31
+ }
32
+ async function runPowerShell(script, stdinData) {
33
+ const psExe = process.env.SystemRoot
34
+ ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
35
+ : 'powershell.exe';
36
+ const child = exec(psExe, ['-NoProfile', '-NonInteractive', '-Command', script], {
37
+ windowsHide: true,
38
+ maxBuffer: 1024 * 1024,
39
+ });
40
+ if (stdinData !== undefined) {
41
+ const stdin = child.child?.stdin;
42
+ if (!stdin)
43
+ throw new Error('PowerShell stdin unavailable');
44
+ stdin.write(stdinData);
45
+ stdin.end();
46
+ }
47
+ const { stdout } = await child;
48
+ return stdout;
49
+ }
50
+ async function setApiKeyWindows(apiKey) {
51
+ try {
52
+ const credPath = windowsCredentialPath();
53
+ await fs.mkdir(path.dirname(credPath), { recursive: true });
54
+ // Read the secret from stdin, encrypt it with user-scoped DPAPI, and print
55
+ // the encrypted string. Reading from stdin avoids putting the secret on the
56
+ // command line (where it could be seen in the process table).
57
+ const script = '$s = [Console]::In.ReadToEnd();' +
58
+ '$sec = ConvertTo-SecureString -String $s -AsPlainText -Force;' +
59
+ '[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))';
60
+ const encrypted = (await runPowerShell(script, apiKey)).trim();
61
+ if (!encrypted)
62
+ return false;
63
+ await fs.writeFile(credPath, encrypted, { encoding: 'utf-8', mode: 0o600 });
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ async function getApiKeyWindows() {
71
+ try {
72
+ const credPath = windowsCredentialPath();
73
+ const encrypted = (await fs.readFile(credPath, 'utf-8')).trim();
74
+ if (!encrypted)
75
+ return null;
76
+ // Decrypt the DPAPI blob back to plaintext via Marshal. Pass the blob over
77
+ // stdin to avoid quoting/length issues on the command line.
78
+ const script = '$enc = [Console]::In.ReadToEnd().Trim();' +
79
+ '$sec = ConvertTo-SecureString -String $enc;' +
80
+ '$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec);' +
81
+ 'try { [Console]::Out.Write([Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)) }' +
82
+ 'finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }';
83
+ const plaintext = await runPowerShell(script, encrypted);
84
+ return plaintext.trim() || null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ async function deleteApiKeyWindows() {
91
+ try {
92
+ await fs.unlink(windowsCredentialPath());
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
9
99
  async function isLinuxWithSecretTool() {
10
100
  if (process.platform !== 'linux')
11
101
  return false;
@@ -18,7 +108,12 @@ async function isLinuxWithSecretTool() {
18
108
  }
19
109
  }
20
110
  export async function getApiKeyFromKeychain() {
111
+ if (keychainDisabled())
112
+ return null;
21
113
  try {
114
+ if (isWindows()) {
115
+ return await getApiKeyWindows();
116
+ }
22
117
  if (await isMacOS()) {
23
118
  const { stdout } = await exec('security', [
24
119
  'find-generic-password',
@@ -41,7 +136,12 @@ export async function getApiKeyFromKeychain() {
41
136
  return null;
42
137
  }
43
138
  export async function setApiKeyInKeychain(apiKey) {
139
+ if (keychainDisabled())
140
+ return false;
44
141
  try {
142
+ if (isWindows()) {
143
+ return await setApiKeyWindows(apiKey);
144
+ }
45
145
  if (await isMacOS()) {
46
146
  // Delete existing entry first (ignore errors if it doesn't exist)
47
147
  try {
@@ -70,9 +170,15 @@ export async function setApiKeyInKeychain(apiKey) {
70
170
  'service', SERVICE,
71
171
  'account', ACCOUNT,
72
172
  ]);
73
- // secret-tool reads the secret from stdin
74
- child.child?.stdin?.write(apiKey);
75
- child.child?.stdin?.end();
173
+ // secret-tool reads the secret from stdin. If stdin isn't available we
174
+ // cannot deliver the secret — treat that as a failure rather than
175
+ // silently reporting success (which would lose the key entirely).
176
+ const stdin = child.child?.stdin;
177
+ if (!stdin) {
178
+ return false;
179
+ }
180
+ stdin.write(apiKey);
181
+ stdin.end();
76
182
  await child;
77
183
  return true;
78
184
  }
@@ -83,7 +189,12 @@ export async function setApiKeyInKeychain(apiKey) {
83
189
  return false;
84
190
  }
85
191
  export async function deleteApiKeyFromKeychain() {
192
+ if (keychainDisabled())
193
+ return false;
86
194
  try {
195
+ if (isWindows()) {
196
+ return await deleteApiKeyWindows();
197
+ }
87
198
  if (await isMacOS()) {
88
199
  await exec('security', [
89
200
  'delete-generic-password',
@@ -105,5 +216,7 @@ export async function deleteApiKeyFromKeychain() {
105
216
  return false;
106
217
  }
107
218
  export async function isKeychainAvailable() {
108
- return await isMacOS() || await isLinuxWithSecretTool();
219
+ if (keychainDisabled())
220
+ return false;
221
+ return isWindows() || await isMacOS() || await isLinuxWithSecretTool();
109
222
  }
package/dist/markdown.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import chalk from 'chalk';
2
2
  import { Marked } from 'marked';
3
3
  import { markedTerminal } from 'marked-terminal';
4
+ import { createRequire } from 'node:module';
5
+ // This package is ESM ("type":"module"), so the CommonJS `require` global is
6
+ // not defined. Build one bound to this module's URL so cli-highlight loads.
7
+ const require = createRequire(import.meta.url);
4
8
  let highlightFn = null;
5
9
  let highlightLoaded = false;
6
10
  function getHighlighter() {
@@ -9,7 +13,6 @@ function getHighlighter() {
9
13
  highlightLoaded = true;
10
14
  try {
11
15
  // cli-highlight is a dependency — provides 100+ language highlighting
12
- // eslint-disable-next-line @typescript-eslint/no-require-imports
13
16
  const mod = require('cli-highlight');
14
17
  highlightFn = mod.highlight;
15
18
  }
@@ -19,6 +19,17 @@ export function classifyComplexity(query, toolCallCount = 0, config) {
19
19
  return 'complex';
20
20
  return 'moderate';
21
21
  }
22
+ /**
23
+ * Complexity-based model selection.
24
+ *
25
+ * Intended use: a caller that owns a {@link RouterConfig} (see
26
+ * `createRouterConfig`/`getRouterStatus`, wired from cli.tsx) can call this per
27
+ * user turn to pick a cheaper/stronger model for the request. It is
28
+ * deliberately conservative — it only diverges from the caller's default when
29
+ * the router config is `enabled` (which `createRouterConfig` defaults to
30
+ * `false`), so importing/calling it does NOT change default behavior. When
31
+ * disabled, or with no tiers, it always returns `config.defaultModel`.
32
+ */
22
33
  export function selectModel(query, config, toolCallCount = 0) {
23
34
  if (!config.enabled || config.tiers.length === 0) {
24
35
  return config.defaultModel;