@kaelio/ktx 0.1.0 → 0.1.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.
Files changed (63) hide show
  1. package/assets/python/{kaelio_ktx-0.1.0-py3-none-any.whl → kaelio_ktx-0.1.1-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/commands/setup-commands.js +14 -26
  4. package/dist/doctor.test.js +3 -4
  5. package/dist/index.test.js +26 -10
  6. package/dist/ingest-depth.js +0 -1
  7. package/dist/ingest.test-utils.js +2 -2
  8. package/dist/ingest.test.js +4 -4
  9. package/dist/managed-local-embeddings.d.ts +2 -0
  10. package/dist/managed-local-embeddings.js +2 -0
  11. package/dist/managed-local-embeddings.test.js +2 -0
  12. package/dist/managed-mcp-daemon.js +3 -2
  13. package/dist/managed-mcp-daemon.test.js +25 -0
  14. package/dist/managed-python-command.test.js +1 -0
  15. package/dist/managed-python-daemon.js +3 -2
  16. package/dist/managed-python-daemon.test.js +20 -0
  17. package/dist/managed-python-runtime.d.ts +4 -0
  18. package/dist/managed-python-runtime.js +47 -3
  19. package/dist/managed-python-runtime.test.js +51 -21
  20. package/dist/proxy-env.d.ts +1 -0
  21. package/dist/proxy-env.js +23 -0
  22. package/dist/proxy-env.test.d.ts +1 -0
  23. package/dist/proxy-env.test.js +17 -0
  24. package/dist/runtime.test.js +1 -0
  25. package/dist/setup-agents.js +3 -1
  26. package/dist/setup-agents.test.js +34 -0
  27. package/dist/setup-embeddings.d.ts +1 -0
  28. package/dist/setup-embeddings.js +28 -6
  29. package/dist/setup-embeddings.test.js +46 -4
  30. package/dist/setup-models.d.ts +0 -1
  31. package/dist/setup-models.js +2 -3
  32. package/dist/setup-models.test.js +8 -10
  33. package/dist/setup-project.d.ts +9 -1
  34. package/dist/setup-project.js +52 -25
  35. package/dist/setup-project.test.js +8 -8
  36. package/dist/setup-runtime.test.js +2 -0
  37. package/dist/setup.d.ts +1 -2
  38. package/dist/setup.js +21 -5
  39. package/dist/setup.test.js +160 -43
  40. package/dist/sl.test.js +2 -1
  41. package/dist/standalone-smoke.test.js +2 -3
  42. package/dist/status-project.js +1 -10
  43. package/node_modules/@ktx/context/dist/ingest/adapters/historic-sql/local-ingest-acceptance.test.js +1 -1
  44. package/node_modules/@ktx/context/dist/ingest/local-bundle-ingest.test.js +8 -8
  45. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.js +1 -1
  46. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.test.js +3 -3
  47. package/node_modules/@ktx/context/dist/ingest/local-embedding-provider.integration.test.js +9 -10
  48. package/node_modules/@ktx/context/dist/llm/local-config.js +2 -15
  49. package/node_modules/@ktx/context/dist/llm/local-config.test.js +3 -7
  50. package/node_modules/@ktx/context/dist/project/config.d.ts +0 -5
  51. package/node_modules/@ktx/context/dist/project/config.js +5 -5
  52. package/node_modules/@ktx/context/dist/project/config.test.js +4 -7
  53. package/node_modules/@ktx/context/dist/scan/enrichment-state.test.js +4 -4
  54. package/node_modules/@ktx/context/dist/scan/index.d.ts +1 -1
  55. package/node_modules/@ktx/context/dist/scan/local-enrichment.d.ts +2 -6
  56. package/node_modules/@ktx/context/dist/scan/local-enrichment.js +31 -47
  57. package/node_modules/@ktx/context/dist/scan/local-enrichment.test.js +35 -18
  58. package/node_modules/@ktx/context/dist/scan/local-scan.test.js +2 -3
  59. package/node_modules/@ktx/llm/dist/embedding-provider.d.ts +0 -7
  60. package/node_modules/@ktx/llm/dist/embedding-provider.js +12 -138
  61. package/node_modules/@ktx/llm/dist/embedding-provider.test.js +10 -25
  62. package/node_modules/@ktx/llm/dist/types.d.ts +1 -1
  63. package/package.json +1 -1
@@ -2,12 +2,28 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { strToU8, zipSync } from 'fflate';
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
7
  import { MISSING_UV_RUNTIME_INSTALL_MESSAGE, doctorManagedPythonRuntime, installManagedPythonRuntime, managedPythonDaemonLayout, managedPythonRuntimeLayout, readManagedPythonRuntimeStatus, verifyRuntimeAsset, } from './managed-python-runtime.js';
7
- async function writeAsset(root, contents = 'wheel-bytes') {
8
+ function runtimeWheelContents(input = {}) {
9
+ const label = input.label ?? 'runtime-wheel';
10
+ const requiresPython = input.requiresPython === null ? [] : [`Requires-Python: ${input.requiresPython ?? '>=3.13'}`];
11
+ return Buffer.from(zipSync({
12
+ 'kaelio_ktx-0.1.0.dist-info/METADATA': strToU8([
13
+ 'Metadata-Version: 2.4',
14
+ 'Name: kaelio-ktx',
15
+ 'Version: 0.1.0',
16
+ ...requiresPython,
17
+ `Summary: ${label}`,
18
+ '',
19
+ ].join('\n')),
20
+ }));
21
+ }
22
+ async function writeAsset(root, options = {}) {
8
23
  const assetDir = join(root, 'assets', 'python');
9
24
  await mkdir(assetDir, { recursive: true });
10
25
  const wheelPath = join(assetDir, 'kaelio_ktx-0.1.0-py3-none-any.whl');
26
+ const contents = options.contents ?? runtimeWheelContents(options);
11
27
  await writeFile(wheelPath, contents);
12
28
  await writeFile(join(assetDir, 'manifest.json'), `${JSON.stringify({
13
29
  schemaVersion: 1,
@@ -17,7 +33,7 @@ async function writeAsset(root, contents = 'wheel-bytes') {
17
33
  wheel: {
18
34
  file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
19
35
  sha256: createHash('sha256').update(contents).digest('hex'),
20
- bytes: Buffer.byteLength(contents),
36
+ bytes: contents.byteLength,
21
37
  },
22
38
  }, null, 2)}\n`);
23
39
  return { assetDir, wheelPath };
@@ -112,19 +128,20 @@ describe('verifyRuntimeAsset', () => {
112
128
  await rm(tempDir, { recursive: true, force: true });
113
129
  });
114
130
  it('reads the manifest and verifies the wheel checksum', async () => {
115
- const { assetDir, wheelPath } = await writeAsset(tempDir, 'valid-wheel');
131
+ const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'valid-wheel' });
116
132
  const asset = await verifyRuntimeAsset({ assetDir });
117
133
  expect(asset.manifest.distributionName).toBe('kaelio-ktx');
118
134
  expect(asset.manifest.normalizedName).toBe('kaelio_ktx');
119
135
  expect(asset.wheelPath).toBe(wheelPath);
136
+ expect(asset.requiresPython).toEqual({ specifier: '>=3.13', minimumVersion: '3.13' });
120
137
  });
121
138
  it('rejects a wheel whose checksum does not match the manifest', async () => {
122
- const { assetDir, wheelPath } = await writeAsset(tempDir, 'original');
139
+ const { assetDir, wheelPath } = await writeAsset(tempDir, { label: 'original' });
123
140
  await writeFile(wheelPath, 'tampered');
124
141
  await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Bundled Python runtime wheel checksum mismatch/);
125
142
  });
126
143
  it('rejects an unsafe wheel filename in the manifest', async () => {
127
- const { assetDir } = await writeAsset(tempDir, 'valid-wheel');
144
+ const { assetDir } = await writeAsset(tempDir, { label: 'valid-wheel' });
128
145
  await writeFile(join(assetDir, 'manifest.json'), `${JSON.stringify({
129
146
  schemaVersion: 1,
130
147
  distributionName: 'kaelio-ktx',
@@ -142,6 +159,14 @@ describe('verifyRuntimeAsset', () => {
142
159
  const assetDir = join(tempDir, 'packages', 'cli', 'assets', 'python');
143
160
  await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Missing bundled Python runtime manifest.*pnpm run artifacts:build/s);
144
161
  });
162
+ it('rejects a bundled wheel without Requires-Python metadata', async () => {
163
+ const { assetDir } = await writeAsset(tempDir, { requiresPython: null });
164
+ await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Bundled Python runtime wheel metadata is missing Requires-Python/);
165
+ });
166
+ it('rejects a bundled wheel without a supported minimum Python version', async () => {
167
+ const { assetDir } = await writeAsset(tempDir, { requiresPython: '<4' });
168
+ await expect(verifyRuntimeAsset({ assetDir })).rejects.toThrow(/Unsupported bundled Python runtime Requires-Python: <4/);
169
+ });
145
170
  });
146
171
  describe('installManagedPythonRuntime', () => {
147
172
  let tempDir;
@@ -152,7 +177,7 @@ describe('installManagedPythonRuntime', () => {
152
177
  await rm(tempDir, { recursive: true, force: true });
153
178
  });
154
179
  it('creates a venv, installs the core wheel, and writes a manifest', async () => {
155
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
180
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
156
181
  const commands = [];
157
182
  const exec = vi.fn(async (command, args) => {
158
183
  commands.push({ command, args });
@@ -168,7 +193,8 @@ describe('installManagedPythonRuntime', () => {
168
193
  expect(result.status).toBe('installed');
169
194
  expect(commands).toEqual([
170
195
  { command: 'uv', args: ['--version'] },
171
- { command: 'uv', args: ['venv', result.layout.venvDir] },
196
+ { command: 'uv', args: ['python', 'install', '3.13'] },
197
+ { command: 'uv', args: ['venv', '--python', '3.13', result.layout.venvDir] },
172
198
  {
173
199
  command: 'uv',
174
200
  args: ['pip', 'install', '--python', result.layout.pythonPath, result.asset.wheelPath],
@@ -181,7 +207,7 @@ describe('installManagedPythonRuntime', () => {
181
207
  expect(manifest.python.daemonExecutable).toBe(result.layout.daemonPath);
182
208
  });
183
209
  it('disables repo uv config for managed runtime uv commands', async () => {
184
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
210
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
185
211
  const commands = [];
186
212
  const exec = vi.fn(async (command, args, options) => {
187
213
  commands.push({ command, args, env: options?.env });
@@ -197,12 +223,13 @@ describe('installManagedPythonRuntime', () => {
197
223
  });
198
224
  expect(commands.map((call) => [call.command, call.args[0], call.env?.UV_NO_CONFIG, call.env?.PATH])).toEqual([
199
225
  ['uv', '--version', '1', '/opt/homebrew/bin'],
226
+ ['uv', 'python', '1', '/opt/homebrew/bin'],
200
227
  ['uv', 'venv', '1', '/opt/homebrew/bin'],
201
228
  ['uv', 'pip', '1', '/opt/homebrew/bin'],
202
229
  ]);
203
230
  });
204
231
  it('installs the local-embeddings extra when requested', async () => {
205
- const { assetDir } = await writeAsset(tempDir, 'embedding-wheel');
232
+ const { assetDir } = await writeAsset(tempDir, { label: 'embedding-wheel' });
206
233
  const commands = [];
207
234
  const exec = vi.fn(async (command, args) => {
208
235
  commands.push({ command, args });
@@ -223,7 +250,7 @@ describe('installManagedPythonRuntime', () => {
223
250
  expect(manifest.features).toEqual(['core', 'local-embeddings']);
224
251
  });
225
252
  it('fails with the hard-prerequisite message when uv is missing', async () => {
226
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
253
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
227
254
  const commands = [];
228
255
  const exec = vi.fn(async (command, args) => {
229
256
  commands.push({ command, args });
@@ -239,7 +266,7 @@ describe('installManagedPythonRuntime', () => {
239
266
  expect(commands).toEqual([{ command: 'uv', args: ['--version'] }]);
240
267
  });
241
268
  it('reuses an existing compatible runtime when force is false', async () => {
242
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
269
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
243
270
  const exec = vi.fn(async (command, args) => ({
244
271
  stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
245
272
  stderr: '',
@@ -262,13 +289,16 @@ describe('installManagedPythonRuntime', () => {
262
289
  exec,
263
290
  });
264
291
  expect(second.status).toBe('ready');
265
- expect(exec).toHaveBeenCalledTimes(3);
292
+ expect(exec).toHaveBeenCalledTimes(4);
266
293
  });
267
294
  it('keeps failed install logs in the versioned runtime directory', async () => {
268
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
295
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
269
296
  const exec = vi.fn(async (command, args) => {
270
297
  if (command === 'uv' && args[0] === 'venv') {
271
- throw Object.assign(new Error('uv venv failed'), { stdout: 'creating\n', stderr: 'bad python\n' });
298
+ throw Object.assign(new Error('uv venv failed'), {
299
+ stdout: 'creating\n',
300
+ stderr: '× No solution found\n╰─▶ current Python version (3.12.3) does not satisfy Python>=3.13\n',
301
+ });
272
302
  }
273
303
  return { stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '', stderr: '' };
274
304
  });
@@ -278,10 +308,10 @@ describe('installManagedPythonRuntime', () => {
278
308
  assetDir,
279
309
  features: ['core'],
280
310
  exec,
281
- })).rejects.toThrow(/Python runtime install failed/);
311
+ })).rejects.toThrow(/current Python version \(3\.12\.3\) does not satisfy Python>=3\.13/);
282
312
  const log = await readFile(join(tempDir, 'runtime', '0.2.0', 'install.log'), 'utf8');
283
- expect(log).toContain('$ uv venv');
284
- expect(log).toContain('bad python');
313
+ expect(log).toContain('$ uv venv --python 3.13');
314
+ expect(log).toContain('current Python version (3.12.3) does not satisfy Python>=3.13');
285
315
  });
286
316
  });
287
317
  describe('readManagedPythonRuntimeStatus', () => {
@@ -302,7 +332,7 @@ describe('readManagedPythonRuntimeStatus', () => {
302
332
  expect(status.detail).toContain('No runtime manifest');
303
333
  });
304
334
  it('reports ready when manifest and executables exist', async () => {
305
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
335
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
306
336
  const exec = vi.fn(async (command, args) => ({
307
337
  stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
308
338
  stderr: '',
@@ -326,7 +356,7 @@ describe('readManagedPythonRuntimeStatus', () => {
326
356
  expect(status.manifest?.features).toEqual(['core']);
327
357
  });
328
358
  it('reports broken when an executable is missing', async () => {
329
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
359
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
330
360
  const exec = vi.fn(async (command, args) => ({
331
361
  stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
332
362
  stderr: '',
@@ -356,7 +386,7 @@ describe('doctorManagedPythonRuntime', () => {
356
386
  await rm(tempDir, { recursive: true, force: true });
357
387
  });
358
388
  it('checks uv, bundled assets, and installed runtime status', async () => {
359
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
389
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
360
390
  const exec = vi.fn(async (command, args) => ({
361
391
  stdout: command === 'uv' && args[0] === '--version' ? 'uv 0.9.5\n' : '',
362
392
  stderr: '',
@@ -375,7 +405,7 @@ describe('doctorManagedPythonRuntime', () => {
375
405
  expect(checks[2]?.fix).toBe('Run: ktx dev runtime install --yes');
376
406
  });
377
407
  it('reports uv as a hard prerequisite when uv is missing', async () => {
378
- const { assetDir } = await writeAsset(tempDir, 'core-wheel');
408
+ const { assetDir } = await writeAsset(tempDir, { label: 'core-wheel' });
379
409
  const exec = vi.fn(async () => {
380
410
  throw new Error('spawn uv ENOENT');
381
411
  });
@@ -0,0 +1 @@
1
+ export declare function sanitizeChildProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,23 @@
1
+ const NO_PROXY_KEYS = ['NO_PROXY', 'no_proxy'];
2
+ function isIpv6CidrNoProxyEntry(entry) {
3
+ return entry.includes('/') && entry.includes(':');
4
+ }
5
+ function cleanedNoProxyValue(env) {
6
+ const entries = NO_PROXY_KEYS.flatMap((key) => (env[key] ?? '').split(','))
7
+ .map((entry) => entry.trim())
8
+ .filter((entry) => entry.length > 0 && !isIpv6CidrNoProxyEntry(entry));
9
+ if (!NO_PROXY_KEYS.some((key) => env[key] !== undefined)) {
10
+ return undefined;
11
+ }
12
+ return [...new Set(entries)].join(',');
13
+ }
14
+ export function sanitizeChildProxyEnv(env) {
15
+ const sanitized = { ...env };
16
+ const noProxy = cleanedNoProxyValue(env);
17
+ if (noProxy === undefined) {
18
+ return sanitized;
19
+ }
20
+ sanitized.NO_PROXY = noProxy;
21
+ sanitized.no_proxy = noProxy;
22
+ return sanitized;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sanitizeChildProxyEnv } from './proxy-env.js';
3
+ describe('sanitizeChildProxyEnv', () => {
4
+ it('drops IPv6 CIDR no-proxy entries and normalizes both env keys', () => {
5
+ const env = sanitizeChildProxyEnv({
6
+ NO_PROXY: 'localhost,127.0.0.1,127.0.0.0/8,fd07:b51a:cc66:f0::/64,*.orb.local',
7
+ no_proxy: '::1,0.250.250.0/24,fd00::/8,*.orb.internal',
8
+ });
9
+ expect(env.NO_PROXY).toBe('localhost,127.0.0.1,127.0.0.0/8,*.orb.local,::1,0.250.250.0/24,*.orb.internal');
10
+ expect(env.no_proxy).toBe(env.NO_PROXY);
11
+ });
12
+ it('preserves the input object and leaves missing proxy env unset', () => {
13
+ const input = { PATH: '/usr/bin' };
14
+ expect(sanitizeChildProxyEnv(input)).toEqual({ PATH: '/usr/bin' });
15
+ expect(input).toEqual({ PATH: '/usr/bin' });
16
+ });
17
+ });
@@ -40,6 +40,7 @@ describe('runKtxRuntime', () => {
40
40
  },
41
41
  asset: {
42
42
  wheelPath: '/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl',
43
+ requiresPython: { specifier: '>=3.13', minimumVersion: '3.13' },
43
44
  manifest: {
44
45
  schemaVersion: 1,
45
46
  distributionName: 'kaelio-ktx',
@@ -965,7 +965,9 @@ export async function runKtxSetupAgentsStep(args, io, deps = {}) {
965
965
  if (targets.includes('back'))
966
966
  return { status: 'back', projectDir: args.projectDir };
967
967
  if (targets.length === 0) {
968
- io.stderr.write('Missing agent target: pass --target or use interactive setup.\n');
968
+ io.stderr.write(args.inputMode === 'disabled'
969
+ ? 'Run in a TTY, or pass --target <target>.\n'
970
+ : 'Missing agent target: pass --target or use interactive setup.\n');
969
971
  return { status: 'missing-input', projectDir: args.projectDir };
970
972
  }
971
973
  const scopeTargets = targets.filter((target) => target !== 'claude-desktop');
@@ -163,6 +163,40 @@ describe('setup agents', () => {
163
163
  expect(await readKtxSetupState(tempDir)).toEqual({ completed_steps: ['agents'] });
164
164
  expect(io.stderr()).toBe('');
165
165
  });
166
+ it('installs a specified target in non-interactive mode without --yes', async () => {
167
+ const io = makeIo();
168
+ await expect(runKtxSetupAgentsStep({
169
+ projectDir: tempDir,
170
+ inputMode: 'disabled',
171
+ yes: false,
172
+ agents: true,
173
+ target: 'claude-code',
174
+ scope: 'project',
175
+ mode: 'mcp',
176
+ skipAgents: false,
177
+ }, io.io)).resolves.toMatchObject({
178
+ status: 'ready',
179
+ projectDir: tempDir,
180
+ installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
181
+ });
182
+ await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined();
183
+ const mcpConfig = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8'));
184
+ expect(mcpConfig.mcpServers).toHaveProperty('ktx');
185
+ expect(io.stderr()).toBe('');
186
+ });
187
+ it('prints concrete target guidance when non-interactive agent setup has no target', async () => {
188
+ const io = makeIo();
189
+ await expect(runKtxSetupAgentsStep({
190
+ projectDir: tempDir,
191
+ inputMode: 'disabled',
192
+ yes: false,
193
+ agents: true,
194
+ scope: 'project',
195
+ mode: 'mcp',
196
+ skipAgents: false,
197
+ }, io.io)).resolves.toEqual({ status: 'missing-input', projectDir: tempDir });
198
+ expect(io.stderr()).toBe('Run in a TTY, or pass --target <target>.\n');
199
+ });
166
200
  it('prints standalone agent next actions after successful installation', async () => {
167
201
  const io = makeIo();
168
202
  const result = await runKtxSetupAgentsStep({
@@ -49,6 +49,7 @@ export interface KtxSetupEmbeddingsDeps {
49
49
  healthCheck?: (config: KtxEmbeddingConfig) => Promise<KtxEmbeddingHealthCheckResult>;
50
50
  ensureLocalEmbeddings?: (options: {
51
51
  cliVersion: string;
52
+ projectDir: string;
52
53
  installPolicy: KtxManagedPythonInstallPolicy;
53
54
  io: KtxCliIo;
54
55
  }) => Promise<ManagedLocalEmbeddingsDaemon>;
@@ -1,4 +1,4 @@
1
- import { writeFile } from 'node:fs/promises';
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { resolveKtxConfigReference } from '@ktx/context/core';
3
3
  import { loadKtxProject, markKtxSetupStateStepComplete, readKtxSetupState, serializeKtxProjectConfig, } from '@ktx/context/project';
4
4
  import { runKtxEmbeddingHealthCheck } from '@ktx/llm';
@@ -20,13 +20,13 @@ const LOCAL_EMBEDDING_BACKEND = 'sentence-transformers';
20
20
  const EMBEDDING_OPTION_PROMPT_CONTEXT = 'KTX uses embeddings for semantic search over semantic-layer sources, wiki context, schema metadata, ' +
21
21
  'and relationship evidence.';
22
22
  const LOCAL_EMBEDDING_HEALTH_TIMEOUT_MS = 120_000;
23
+ const LOCAL_EMBEDDING_STDERR_TAIL_LINES = 40;
23
24
  function createPromptAdapter() {
24
25
  return createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
25
26
  }
26
27
  async function hasCompletedEmbeddings(projectDir, config) {
27
28
  return ((await readKtxSetupState(projectDir)).completed_steps.includes('embeddings') &&
28
29
  config.ingest.embeddings.backend !== 'none' &&
29
- config.ingest.embeddings.backend !== 'deterministic' &&
30
30
  typeof config.ingest.embeddings.model === 'string' &&
31
31
  config.ingest.embeddings.model.length > 0 &&
32
32
  config.ingest.embeddings.dimensions > 0);
@@ -190,14 +190,33 @@ async function chooseEmbeddingBackend(args, deps) {
190
190
  }
191
191
  return 'back';
192
192
  }
193
- function localEmbeddingSetupMessage(message) {
194
- return [
193
+ async function readLocalEmbeddingDaemonStderrTail(stderrLog) {
194
+ if (!stderrLog) {
195
+ return [];
196
+ }
197
+ try {
198
+ const lines = (await readFile(stderrLog, 'utf8'))
199
+ .split(/\r?\n/)
200
+ .map((line) => line.trimEnd())
201
+ .filter((line) => line.trim().length > 0);
202
+ return lines.slice(-LOCAL_EMBEDDING_STDERR_TAIL_LINES);
203
+ }
204
+ catch {
205
+ return [];
206
+ }
207
+ }
208
+ function localEmbeddingSetupMessage(message, stderrTail = []) {
209
+ const lines = [
195
210
  `Local embedding health check failed: ${message}`,
196
211
  'Local embeddings use the KTX-managed Python runtime.',
197
212
  'Prepare the runtime with: ktx dev runtime start --feature local-embeddings',
198
213
  'Use --yes with setup to install and start the runtime without prompting.',
199
214
  'The first run may download Python packages and the all-MiniLM-L6-v2 model.',
200
- ].join('\n');
215
+ ];
216
+ if (stderrTail.length > 0) {
217
+ lines.push('Recent local embeddings daemon stderr:', ...stderrTail);
218
+ }
219
+ return lines.join('\n');
201
220
  }
202
221
  async function promptAfterLocalEmbeddingFailure(deps) {
203
222
  const choice = await (deps.prompts ?? createPromptAdapter()).select({
@@ -324,8 +343,11 @@ export async function runKtxSetupEmbeddingsStep(args, io, deps = {}) {
324
343
  return { status: 'ready', projectDir: args.projectDir };
325
344
  }
326
345
  progress.fail('Embedding test failed');
346
+ const stderrTail = selectedBackend === 'sentence-transformers'
347
+ ? await readLocalEmbeddingDaemonStderrTail(managedLocalEmbeddings?.stderrLog)
348
+ : [];
327
349
  io.stderr.write(selectedBackend === 'sentence-transformers'
328
- ? `${localEmbeddingSetupMessage(health.message)}\n`
350
+ ? `${localEmbeddingSetupMessage(health.message, stderrTail)}\n`
329
351
  : `Embedding health check failed: ${health.message}\n`);
330
352
  if (args.inputMode === 'disabled') {
331
353
  return { status: 'failed', projectDir: args.projectDir };
@@ -39,9 +39,11 @@ function makePromptAdapter(options) {
39
39
  cancel: vi.fn(),
40
40
  };
41
41
  }
42
- function managedDaemon(baseUrl = 'http://127.0.0.1:61234') {
42
+ function managedDaemon(baseUrl = 'http://127.0.0.1:61234', logs = {}) {
43
43
  return {
44
44
  baseUrl,
45
+ stdoutLog: logs.stdoutLog ?? '/tmp/ktx-daemon.stdout.log',
46
+ stderrLog: logs.stderrLog ?? '/tmp/ktx-daemon.stderr.log',
45
47
  env: {
46
48
  KTX_MANAGED_SENTENCE_TRANSFORMERS_BASE_URL: baseUrl,
47
49
  },
@@ -251,11 +253,51 @@ describe('setup embeddings step', () => {
251
253
  expect(result.status).toBe('failed');
252
254
  const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
253
255
  expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
254
- expect(config.ingest.embeddings.backend).toBe('deterministic');
256
+ expect(config.ingest.embeddings.backend).toBe('none');
255
257
  expect(io.stderr()).toContain('Local embedding health check failed: 401 invalid api key [redacted]');
256
258
  expect(io.stderr()).toContain('Prepare the runtime with: ktx dev runtime start --feature local-embeddings');
257
259
  expect(io.stderr()).not.toContain('skip for now');
258
260
  });
261
+ it('prints the recent daemon stderr tail when local embedding health check fails', async () => {
262
+ const io = makeIo();
263
+ const stderrLog = join(tempDir, '.ktx', 'runtime', 'daemon.stderr.log');
264
+ await mkdir(join(tempDir, '.ktx', 'runtime'), { recursive: true });
265
+ await writeFile(stderrLog, Array.from({ length: 45 }, (_value, index) => `daemon traceback line ${index + 1}`).join('\n'));
266
+ const result = await runKtxSetupEmbeddingsStep({
267
+ projectDir: tempDir,
268
+ inputMode: 'disabled',
269
+ cliVersion: '0.2.0',
270
+ runtimeInstallPolicy: 'auto',
271
+ skipEmbeddings: false,
272
+ }, io.io, {
273
+ env: {},
274
+ ensureLocalEmbeddings: vi.fn(async () => managedDaemon('http://127.0.0.1:61234', { stderrLog })),
275
+ healthCheck: vi.fn(async () => ({ ok: false, message: 'HTTP 500' })),
276
+ });
277
+ expect(result.status).toBe('failed');
278
+ expect(io.stderr()).toContain('Recent local embeddings daemon stderr:');
279
+ expect(io.stderr()).toContain('daemon traceback line 6');
280
+ expect(io.stderr()).toContain('daemon traceback line 45');
281
+ expect(io.stderr()).not.toContain('daemon traceback line 5');
282
+ });
283
+ it('does not print daemon stderr diagnostics when the log is unavailable or empty', async () => {
284
+ const io = makeIo();
285
+ const result = await runKtxSetupEmbeddingsStep({
286
+ projectDir: tempDir,
287
+ inputMode: 'disabled',
288
+ cliVersion: '0.2.0',
289
+ runtimeInstallPolicy: 'auto',
290
+ skipEmbeddings: false,
291
+ }, io.io, {
292
+ env: {},
293
+ ensureLocalEmbeddings: vi.fn(async () => managedDaemon('http://127.0.0.1:61234', {
294
+ stderrLog: join(tempDir, '.ktx', 'runtime', 'missing.stderr.log'),
295
+ })),
296
+ healthCheck: vi.fn(async () => ({ ok: false, message: 'HTTP 500' })),
297
+ });
298
+ expect(result.status).toBe('failed');
299
+ expect(io.stderr()).not.toContain('Recent local embeddings daemon stderr:');
300
+ });
259
301
  it('uses fixed OpenAI defaults and only asks for credentials when OpenAI is selected', async () => {
260
302
  const io = makeIo();
261
303
  const healthCheck = vi.fn(async () => ({ ok: true }));
@@ -342,7 +384,7 @@ describe('setup embeddings step', () => {
342
384
  expect(result.status).toBe('skipped');
343
385
  const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
344
386
  expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
345
- expect(config.ingest.embeddings.backend).toBe('deterministic');
387
+ expect(config.ingest.embeddings.backend).toBe('none');
346
388
  });
347
389
  it('returns back without writing config when the local health check fails and Back is selected', async () => {
348
390
  const prompts = makePromptAdapter({ selectValues: ['sentence-transformers', 'back'] });
@@ -360,7 +402,7 @@ describe('setup embeddings step', () => {
360
402
  });
361
403
  expect(result.status).toBe('back');
362
404
  const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
363
- expect(config.ingest.embeddings.backend).toBe('deterministic');
405
+ expect(config.ingest.embeddings.backend).toBe('none');
364
406
  });
365
407
  it('preserves already completed embeddings setup when no embedding args request changes', async () => {
366
408
  await mkdir(join(tempDir, '.ktx'), { recursive: true });
@@ -10,7 +10,6 @@ export interface KtxSetupModelArgs {
10
10
  anthropicApiKeyEnv?: string;
11
11
  anthropicApiKeyFile?: string;
12
12
  llmModel?: string;
13
- anthropicModel?: string;
14
13
  vertexProject?: string;
15
14
  vertexLocation?: string;
16
15
  forcePrompt?: boolean;
@@ -312,13 +312,13 @@ function requestedBackend(args) {
312
312
  if (args.vertexProject || args.vertexLocation) {
313
313
  return 'vertex';
314
314
  }
315
- if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel || args.anthropicModel) {
315
+ if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel) {
316
316
  return 'anthropic';
317
317
  }
318
318
  return undefined;
319
319
  }
320
320
  function requestedModel(args) {
321
- return args.llmModel ?? args.anthropicModel;
321
+ return args.llmModel;
322
322
  }
323
323
  async function chooseBackend(args, io, deps) {
324
324
  const explicit = requestedBackend(args);
@@ -701,7 +701,6 @@ export async function runKtxSetupAnthropicModelStep(args, io, deps = {}) {
701
701
  !args.anthropicApiKeyEnv &&
702
702
  !args.anthropicApiKeyFile &&
703
703
  !args.llmModel &&
704
- !args.anthropicModel &&
705
704
  !args.vertexProject &&
706
705
  !args.vertexLocation) {
707
706
  io.stdout.write(`│ LLM ready: yes (${project.config.llm.models.default})\n`);
@@ -216,7 +216,7 @@ describe('setup Anthropic model step', () => {
216
216
  projectDir: tempDir,
217
217
  inputMode: 'disabled',
218
218
  anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
219
- anthropicModel: 'claude-sonnet-4-6',
219
+ llmModel: 'claude-sonnet-4-6',
220
220
  skipLlm: false,
221
221
  }, io.io, {
222
222
  env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
@@ -253,7 +253,7 @@ describe('setup Anthropic model step', () => {
253
253
  llmBackend: 'vertex',
254
254
  vertexProject: 'local-gcp-project',
255
255
  vertexLocation: 'us-east5',
256
- anthropicModel: 'claude-sonnet-4-6',
256
+ llmModel: 'claude-sonnet-4-6',
257
257
  skipLlm: false,
258
258
  }, io.io, { env: {}, healthCheck, spinner });
259
259
  expect(result.status).toBe('ready');
@@ -476,7 +476,7 @@ describe('setup Anthropic model step', () => {
476
476
  llmBackend: 'vertex',
477
477
  vertexProject: 'kaelio-orbit-looker-20260430',
478
478
  vertexLocation: 'us-east5',
479
- anthropicModel: 'claude-sonnet-4-6',
479
+ llmModel: 'claude-sonnet-4-6',
480
480
  skipLlm: false,
481
481
  }, io.io, {
482
482
  env: {},
@@ -497,7 +497,7 @@ describe('setup Anthropic model step', () => {
497
497
  projectDir: tempDir,
498
498
  inputMode: 'disabled',
499
499
  anthropicApiKeyFile: secretPath,
500
- anthropicModel: 'claude-sonnet-4-6',
500
+ llmModel: 'claude-sonnet-4-6',
501
501
  skipLlm: false,
502
502
  }, io.io, { env: {}, healthCheck });
503
503
  expect(result.status).toBe('ready');
@@ -525,7 +525,7 @@ describe('setup Anthropic model step', () => {
525
525
  projectDir: tempDir,
526
526
  inputMode: 'disabled',
527
527
  anthropicApiKeyFile: missingSecretPath,
528
- anthropicModel: 'claude-sonnet-4-6',
528
+ llmModel: 'claude-sonnet-4-6',
529
529
  skipLlm: false,
530
530
  }, io.io, { env: {}, healthCheck });
531
531
  expect(result.status).toBe('missing-input');
@@ -720,7 +720,7 @@ describe('setup Anthropic model step', () => {
720
720
  projectDir: tempDir,
721
721
  inputMode: 'disabled',
722
722
  anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
723
- anthropicModel: 'claude-sonnet-4-6',
723
+ llmModel: 'claude-sonnet-4-6',
724
724
  skipLlm: false,
725
725
  }, io.io, {
726
726
  env: { ANTHROPIC_API_KEY: 'sk-ant-test' }, // pragma: allowlist secret
@@ -830,8 +830,7 @@ describe('setup Anthropic model step', () => {
830
830
  ' default: claude-sonnet-4-6',
831
831
  'ingest:',
832
832
  ' embeddings:',
833
- ' backend: deterministic',
834
- ' model: deterministic',
833
+ ' backend: none',
835
834
  ' dimensions: 8',
836
835
  ].join('\n'), 'utf-8');
837
836
  await writeKtxSetupState(tempDir, { completed_steps: ['project', 'llm'] });
@@ -865,8 +864,7 @@ describe('setup Anthropic model step', () => {
865
864
  ` default: ${fixture.model}`,
866
865
  'ingest:',
867
866
  ' embeddings:',
868
- ' backend: deterministic',
869
- ' model: deterministic',
867
+ ' backend: none',
870
868
  ' dimensions: 8',
871
869
  ].join('\n'), 'utf-8');
872
870
  await writeKtxSetupState(tempDir, { completed_steps: ['project', 'llm'] });
@@ -1,7 +1,7 @@
1
1
  import { initKtxProject, type KtxLocalProject, loadKtxProject } from '@ktx/context/project';
2
2
  import type { KtxCliIo } from './cli-runtime.js';
3
3
  import { type KtxSetupPromptOption } from './setup-prompts.js';
4
- export type KtxSetupProjectMode = 'auto' | 'new' | 'existing' | 'prompt-new';
4
+ export type KtxSetupProjectMode = 'auto' | 'prompt-new';
5
5
  export type KtxSetupInputMode = 'auto' | 'disabled';
6
6
  export interface KtxSetupProjectArgs {
7
7
  projectDir: string;
@@ -10,11 +10,19 @@ export interface KtxSetupProjectArgs {
10
10
  yes: boolean;
11
11
  allowBack?: boolean;
12
12
  }
13
+ export type KtxSetupCreatedProjectCleanup = {
14
+ kind: 'remove-project-dir';
15
+ projectDir: string;
16
+ } | {
17
+ kind: 'remove-ktx-scaffold';
18
+ projectDir: string;
19
+ };
13
20
  export type KtxSetupProjectResult = {
14
21
  status: 'ready';
15
22
  projectDir: string;
16
23
  project: KtxLocalProject;
17
24
  confirmedCreation?: boolean;
25
+ createdProjectCleanup?: KtxSetupCreatedProjectCleanup;
18
26
  } | {
19
27
  status: 'back';
20
28
  projectDir: string;