@kaelio/ktx 0.12.0 → 0.13.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.
Files changed (53) hide show
  1. package/assets/python/{kaelio_ktx-0.12.0-py3-none-any.whl → kaelio_ktx-0.13.0-py3-none-any.whl} +0 -0
  2. package/assets/python/manifest.json +4 -4
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/commands/setup-commands.js +13 -0
  5. package/dist/connection.js +14 -2
  6. package/dist/connectors/bigquery/connector.js +1 -14
  7. package/dist/connectors/clickhouse/connector.js +1 -15
  8. package/dist/connectors/duckdb/federated-attach.d.ts +7 -0
  9. package/dist/connectors/duckdb/federated-attach.js +86 -0
  10. package/dist/connectors/duckdb/federated-executor.d.ts +5 -0
  11. package/dist/connectors/duckdb/federated-executor.js +59 -0
  12. package/dist/connectors/mysql/connector.js +1 -15
  13. package/dist/connectors/postgres/connector.js +1 -14
  14. package/dist/connectors/shared/string-reference.d.ts +6 -0
  15. package/dist/connectors/shared/string-reference.js +19 -0
  16. package/dist/connectors/snowflake/connector.js +1 -14
  17. package/dist/connectors/sqlserver/connector.js +1 -14
  18. package/dist/context/connections/federation.d.ts +33 -0
  19. package/dist/context/connections/federation.js +51 -0
  20. package/dist/context/connections/local-warehouse-descriptor.d.ts +2 -0
  21. package/dist/context/connections/project-sql-executor.d.ts +18 -0
  22. package/dist/context/connections/project-sql-executor.js +39 -0
  23. package/dist/context/connections/query-executor.d.ts +2 -2
  24. package/dist/context/connections/read-only-sql.js +4 -3
  25. package/dist/context/connections/resolve-connection.d.ts +12 -0
  26. package/dist/context/connections/resolve-connection.js +37 -0
  27. package/dist/context/core/git-env.d.ts +4 -0
  28. package/dist/context/core/git-env.js +5 -1
  29. package/dist/context/ingest/adapters/live-database/manifest.d.ts +3 -0
  30. package/dist/context/ingest/adapters/live-database/manifest.js +19 -11
  31. package/dist/context/llm/claude-code-runtime.js +18 -2
  32. package/dist/context/mcp/context-tools.js +27 -2
  33. package/dist/context/mcp/local-project-ports.js +55 -50
  34. package/dist/context/mcp/types.d.ts +2 -0
  35. package/dist/context/scan/local-enrichment-artifacts.js +31 -3
  36. package/dist/context/sl/local-query.js +29 -12
  37. package/dist/context/sl/local-sl.js +27 -1
  38. package/dist/context/sl/source-files.d.ts +2 -0
  39. package/dist/context/sl/source-files.js +7 -0
  40. package/dist/ingest-query-executor.d.ts +2 -0
  41. package/dist/ingest-query-executor.js +8 -22
  42. package/dist/setup-agents.d.ts +21 -15
  43. package/dist/setup-agents.js +128 -42
  44. package/dist/setup-databases.d.ts +3 -0
  45. package/dist/setup-databases.js +16 -0
  46. package/dist/setup-sources.js +1 -5
  47. package/dist/setup.d.ts +1 -0
  48. package/dist/setup.js +1 -0
  49. package/dist/sql.d.ts +2 -0
  50. package/dist/sql.js +35 -53
  51. package/dist/telemetry/events.d.ts +2 -1
  52. package/dist/telemetry/events.js +11 -1
  53. package/package.json +2 -1
@@ -1,9 +1,11 @@
1
+ import { executeFederatedQuery } from './connectors/duckdb/federated-executor.js';
1
2
  import type { KtxSqlQueryExecutorPort } from './context/connections/query-executor.js';
2
3
  import type { KtxLocalProject } from './context/project/project.js';
3
4
  import { createKtxCliScanConnector } from './local-scan-connectors.js';
4
5
  type CreateConnector = typeof createKtxCliScanConnector;
5
6
  export interface KtxCliIngestQueryExecutorDeps {
6
7
  createConnector?: CreateConnector;
8
+ executeFederated?: typeof executeFederatedQuery;
7
9
  }
8
10
  export declare function createKtxCliIngestQueryExecutor(project: KtxLocalProject, deps?: KtxCliIngestQueryExecutorDeps): KtxSqlQueryExecutorPort;
9
11
  export {};
@@ -1,30 +1,16 @@
1
+ import { executeProjectReadOnlySql } from './context/connections/project-sql-executor.js';
1
2
  import { createKtxCliScanConnector } from './local-scan-connectors.js';
2
- async function cleanupConnector(connector) {
3
- await connector?.cleanup?.();
4
- }
5
3
  export function createKtxCliIngestQueryExecutor(project, deps = {}) {
6
4
  const createConnector = deps.createConnector ?? createKtxCliScanConnector;
7
5
  return {
8
6
  async execute(input) {
9
- let connector = null;
10
- try {
11
- connector = await createConnector(project, input.connectionId);
12
- if (!connector.capabilities.readOnlySql || !connector.executeReadOnly) {
13
- throw new Error(`Connection "${input.connectionId}" driver "${connector.driver}" does not support read-only SQL execution.`);
14
- }
15
- const ctx = { runId: 'ingest-sql-execution' };
16
- const result = await connector.executeReadOnly({ connectionId: input.connectionId, sql: input.sql, maxRows: input.maxRows }, ctx);
17
- return {
18
- headers: result.headers,
19
- rows: result.rows,
20
- totalRows: result.totalRows,
21
- command: 'SELECT',
22
- rowCount: result.rowCount,
23
- };
24
- }
25
- finally {
26
- await cleanupConnector(connector);
27
- }
7
+ return executeProjectReadOnlySql({
8
+ project,
9
+ input,
10
+ createConnector: (connectionId) => createConnector(project, connectionId),
11
+ executeFederated: deps.executeFederated,
12
+ runId: 'ingest-sql-execution',
13
+ });
28
14
  },
29
15
  };
30
16
  }
@@ -14,15 +14,24 @@ export interface KtxSetupAgentsArgs {
14
14
  mode: KtxAgentInstallMode;
15
15
  skipAgents: boolean;
16
16
  showNextActions?: boolean;
17
+ installRoot?: string;
18
+ cwd?: string;
17
19
  }
20
+ /** The directory project-scoped agent files land in; equals projectDir unless an install root is chosen. */
21
+ interface KtxAgentInstall {
22
+ target: KtxAgentTarget;
23
+ scope: KtxAgentScope;
24
+ mode: KtxAgentInstallMode;
25
+ installRoot: string;
26
+ }
27
+ /** Install shape for formatting helpers; installRoot falls back to projectDir when absent. */
28
+ type KtxAgentInstallLike = Omit<KtxAgentInstall, 'installRoot'> & {
29
+ installRoot?: string;
30
+ };
18
31
  export type KtxSetupAgentsResult = {
19
32
  status: 'ready';
20
33
  projectDir: string;
21
- installs: Array<{
22
- target: KtxAgentTarget;
23
- scope: KtxAgentScope;
24
- mode: KtxAgentInstallMode;
25
- }>;
34
+ installs: KtxAgentInstall[];
26
35
  nextActions?: string;
27
36
  } | {
28
37
  status: 'skipped';
@@ -41,11 +50,7 @@ export interface KtxAgentInstallManifest {
41
50
  version: 1;
42
51
  projectDir: string;
43
52
  installedAt: string;
44
- installs: Array<{
45
- target: KtxAgentTarget;
46
- scope: KtxAgentScope;
47
- mode: KtxAgentInstallMode;
48
- }>;
53
+ installs: KtxAgentInstall[];
49
54
  entries: Array<{
50
55
  kind: 'file';
51
56
  path: string;
@@ -65,6 +70,7 @@ export declare function plannedKtxAgentFiles(input: {
65
70
  target: KtxAgentTarget;
66
71
  scope: KtxAgentScope;
67
72
  mode: KtxAgentInstallMode;
73
+ installRoot?: string;
68
74
  }): InstallEntry[];
69
75
  export declare function readKtxAgentInstallManifest(projectDir: string): Promise<KtxAgentInstallManifest | null>;
70
76
  /** @internal */
@@ -79,6 +85,10 @@ interface KtxSetupAgentsPromptAdapter {
79
85
  options: KtxSetupPromptOption[];
80
86
  required?: boolean;
81
87
  }): Promise<string[]>;
88
+ text(options: {
89
+ message: string;
90
+ placeholder?: string;
91
+ }): Promise<string | undefined>;
82
92
  cancel(message: string): void;
83
93
  }
84
94
  export interface KtxSetupAgentsDeps {
@@ -91,10 +101,6 @@ export interface InstallSummaryEntry {
91
101
  lines: string[];
92
102
  }
93
103
  /** @internal */
94
- export declare function formatInstallSummaryLines(installs: Array<{
95
- target: KtxAgentTarget;
96
- scope: KtxAgentScope;
97
- mode: KtxAgentInstallMode;
98
- }>, entries: InstallEntry[], projectDir: string): InstallSummaryEntry[];
104
+ export declare function formatInstallSummaryLines(installs: KtxAgentInstallLike[], entries: InstallEntry[], projectDir: string): InstallSummaryEntry[];
99
105
  export declare function runKtxSetupAgentsStep(args: KtxSetupAgentsArgs, io: KtxCliIo, deps?: KtxSetupAgentsDeps): Promise<KtxSetupAgentsResult>;
100
106
  export {};
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import { dirname, join, relative, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { styleText } from 'node:util';
@@ -166,7 +166,7 @@ function universalMcpSnippet(endpoint) {
166
166
  ...(endpoint.tokenAuth ? ['Header: Authorization: Bearer ${KTX_MCP_TOKEN}'] : []),
167
167
  ].join('\n');
168
168
  }
169
- function claudeConfigPath(projectDir, scope) {
169
+ function claudeConfigPath(projectDir, installRoot, scope) {
170
170
  const home = process.env.HOME ?? '';
171
171
  if (scope === 'global') {
172
172
  return { path: join(home, '.claude.json'), jsonPath: ['mcpServers', 'ktx'] };
@@ -174,12 +174,12 @@ function claudeConfigPath(projectDir, scope) {
174
174
  if (scope === 'local') {
175
175
  return { path: join(home, '.claude.json'), jsonPath: ['projects', resolve(projectDir), 'mcpServers', 'ktx'] };
176
176
  }
177
- return { path: join(resolve(projectDir), '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] };
177
+ return { path: join(resolve(installRoot), '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] };
178
178
  }
179
- function cursorConfigPath(projectDir, scope) {
179
+ function cursorConfigPath(installRoot, scope) {
180
180
  const home = process.env.HOME ?? '';
181
181
  return {
182
- path: scope === 'global' ? join(home, '.cursor/mcp.json') : join(resolve(projectDir), '.cursor/mcp.json'),
182
+ path: scope === 'global' ? join(home, '.cursor/mcp.json') : join(resolve(installRoot), '.cursor/mcp.json'),
183
183
  jsonPath: ['mcpServers', 'ktx'],
184
184
  };
185
185
  }
@@ -226,12 +226,12 @@ async function installMcpClientConfig(input) {
226
226
  notices.push(MCP_DAEMON_REQUIRED_NOTICE);
227
227
  }
228
228
  if (input.target === 'claude-code') {
229
- const config = claudeConfigPath(input.projectDir, input.scope);
229
+ const config = claudeConfigPath(input.projectDir, input.installRoot, input.scope);
230
230
  await writeJsonKey(config.path, config.jsonPath, claudeMcpEntry(endpoint));
231
231
  entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
232
232
  }
233
233
  else if (input.target === 'cursor') {
234
- const config = cursorConfigPath(input.projectDir, input.scope);
234
+ const config = cursorConfigPath(input.installRoot, input.scope);
235
235
  await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint));
236
236
  entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath });
237
237
  }
@@ -241,7 +241,7 @@ async function installMcpClientConfig(input) {
241
241
  else if (input.target === 'opencode') {
242
242
  const path = input.scope === 'global'
243
243
  ? '~/.config/opencode/opencode.json'
244
- : relative(input.projectDir, join(input.projectDir, 'opencode.json'));
244
+ : relative(input.installRoot, join(input.installRoot, 'opencode.json'));
245
245
  snippets.push(`Add this OpenCode MCP snippet to ${path}:\n${opencodeSnippet(endpoint)}`);
246
246
  }
247
247
  else if (input.target === 'universal') {
@@ -251,7 +251,7 @@ async function installMcpClientConfig(input) {
251
251
  }
252
252
  function plannedMcpJsonEntries(input) {
253
253
  if (input.target === 'claude-code') {
254
- const config = claudeConfigPath(input.projectDir, input.scope);
254
+ const config = claudeConfigPath(input.projectDir, input.installRoot, input.scope);
255
255
  return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
256
256
  }
257
257
  if (input.target === 'claude-desktop') {
@@ -259,7 +259,7 @@ function plannedMcpJsonEntries(input) {
259
259
  return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
260
260
  }
261
261
  if (input.target === 'cursor') {
262
- const config = cursorConfigPath(input.projectDir, input.scope);
262
+ const config = cursorConfigPath(input.installRoot, input.scope);
263
263
  return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }];
264
264
  }
265
265
  return [];
@@ -324,7 +324,7 @@ export function plannedKtxAgentFiles(input) {
324
324
  }
325
325
  throw new Error(`Global ${input.target} installation is not supported; omit --global.`);
326
326
  }
327
- const root = resolve(input.projectDir);
327
+ const root = resolve(input.installRoot ?? input.projectDir);
328
328
  const analyticsEntries = {
329
329
  'claude-code': [
330
330
  { kind: 'file', path: join(root, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' },
@@ -502,7 +502,8 @@ function entryKey(entry) {
502
502
  function mergeManifest(projectDir, existing, installs, entries) {
503
503
  const installMap = new Map();
504
504
  for (const install of [...(existing?.installs ?? []), ...installs]) {
505
- installMap.set(`${install.target}:${install.scope}:${install.mode}`, install);
505
+ const installRoot = install.installRoot ?? resolve(projectDir);
506
+ installMap.set(`${install.target}:${install.scope}:${install.mode}:${installRoot}`, { ...install, installRoot });
506
507
  }
507
508
  const entryMap = new Map();
508
509
  for (const entry of [...(existing?.entries ?? []), ...entries]) {
@@ -611,18 +612,31 @@ function formatInlinePath(path) {
611
612
  }
612
613
  return path;
613
614
  }
615
+ function installSummaryTitle(install, projectDir) {
616
+ const name = targetDisplayName(install.target);
617
+ if (install.scope !== 'project') {
618
+ return `${name} · ${scopeDisplayName(install.scope)}`;
619
+ }
620
+ const installRoot = resolve(install.installRoot ?? projectDir);
621
+ if (installRoot === resolve(projectDir)) {
622
+ return `${name} · ${scopeDisplayName('project')}`;
623
+ }
624
+ return `${name} · ${formatInlinePath(installRoot)}`;
625
+ }
614
626
  /** @internal */
615
627
  export function formatInstallSummaryLines(installs, entries, projectDir) {
616
628
  const entriesByTarget = new Map();
617
629
  for (const install of installs) {
618
- const plannedFilePaths = new Set(plannedKtxAgentFiles({ projectDir, ...install })
630
+ const installRoot = install.installRoot ?? projectDir;
631
+ const plannedFilePaths = new Set(plannedKtxAgentFiles({ projectDir, ...install, installRoot })
619
632
  .filter((entry) => entry.kind === 'file')
620
633
  .map((entry) => entry.path));
621
634
  entriesByTarget.set(install.target, entries.filter((entry) => entry.kind === 'file' && plannedFilePaths.has(entry.path)));
622
635
  }
623
636
  const mcpEntriesByTarget = new Map();
624
637
  for (const install of installs) {
625
- const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, ...install }).map(entryKey));
638
+ const installRoot = install.installRoot ?? projectDir;
639
+ const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, installRoot, target: install.target, scope: install.scope }).map(entryKey));
626
640
  mcpEntriesByTarget.set(install.target, entries.filter((entry) => entry.kind === 'json-key' && plannedMcpKeys.has(entryKey(entry))));
627
641
  }
628
642
  return installs.map((install) => {
@@ -663,7 +677,7 @@ export function formatInstallSummaryLines(installs, entries, projectDir) {
663
677
  lines.push(`${guidanceInstallLine(install.target)}.`);
664
678
  }
665
679
  return {
666
- title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`,
680
+ title: installSummaryTitle(install, projectDir),
667
681
  lines,
668
682
  };
669
683
  });
@@ -726,6 +740,9 @@ function manualActionFromSnippet(snippet) {
726
740
  body,
727
741
  };
728
742
  }
743
+ function openFromDirectoryLabel(installRoot, projectDir) {
744
+ return resolve(installRoot) === resolve(projectDir) ? 'the ktx project directory' : 'the install directory';
745
+ }
729
746
  function formatAgentNextActions(input) {
730
747
  const projectDir = resolve(input.projectDir);
731
748
  const lines = [];
@@ -762,10 +779,11 @@ function formatAgentNextActions(input) {
762
779
  if (claudeCodeInstall) {
763
780
  lines.push(`${step}. Open Claude Code`);
764
781
  if (claudeCodeInstall.scope === 'project') {
765
- lines.push(' Open Claude Code from the ktx project directory:');
782
+ const installRoot = resolve(claudeCodeInstall.installRoot ?? projectDir);
783
+ lines.push(` Open Claude Code from ${openFromDirectoryLabel(installRoot, projectDir)}:`);
766
784
  lines.push('');
767
785
  lines.push(' RUN:');
768
- lines.push(` cd ${shellScriptQuote(projectDir)}`);
786
+ lines.push(` cd ${shellScriptQuote(installRoot)}`);
769
787
  lines.push(' claude');
770
788
  }
771
789
  else {
@@ -779,10 +797,11 @@ function formatAgentNextActions(input) {
779
797
  if (cursorInstall) {
780
798
  lines.push(`${step}. Open Cursor`);
781
799
  if (cursorInstall.scope === 'project') {
782
- lines.push(' Open Cursor from the ktx project directory:');
800
+ const installRoot = resolve(cursorInstall.installRoot ?? projectDir);
801
+ lines.push(` Open Cursor from ${openFromDirectoryLabel(installRoot, projectDir)}:`);
783
802
  lines.push('');
784
803
  lines.push(' OPEN:');
785
- lines.push(` ${projectDir}`);
804
+ lines.push(` ${installRoot}`);
786
805
  }
787
806
  else {
788
807
  lines.push(' Open Cursor.');
@@ -844,6 +863,70 @@ async function markAgentsComplete(projectDir) {
844
863
  await writeFile(project.configPath, serializeKtxProjectConfig(project.config), 'utf-8');
845
864
  await markKtxSetupStateStepComplete(projectDir, 'agents');
846
865
  }
866
+ // A typed path never passes through a shell, so expand a leading ~ here; HOME
867
+ // matches formatInlinePath so the ~/… hints shown in the menu round-trip.
868
+ function resolveTypedInstallDir(cwd, raw) {
869
+ const home = process.env.HOME;
870
+ if (home && (raw === '~' || raw.startsWith('~/'))) {
871
+ return resolve(home, raw.slice(2));
872
+ }
873
+ return resolve(cwd, raw);
874
+ }
875
+ async function ensureInstallDir(resolvedPath) {
876
+ if (existsSync(resolvedPath)) {
877
+ if (!(await stat(resolvedPath)).isDirectory()) {
878
+ throw new Error(`Install directory path is a file, not a directory: ${resolvedPath}`);
879
+ }
880
+ return resolvedPath;
881
+ }
882
+ await mkdir(resolvedPath, { recursive: true });
883
+ return resolvedPath;
884
+ }
885
+ async function promptInstallDirectory(input) {
886
+ const { prompts, io, cwd, projectRoot, scopeTargets } = input;
887
+ const options = [
888
+ { value: 'project', label: 'ktx project directory', hint: formatInlinePath(projectRoot) },
889
+ ...(cwd !== projectRoot
890
+ ? [{ value: 'current', label: 'Current directory', hint: formatInlinePath(cwd) }]
891
+ : []),
892
+ { value: 'custom', label: 'Custom directory…', hint: 'Enter a path' },
893
+ ...(scopeTargets.every(targetSupportsGlobalScope)
894
+ ? [
895
+ {
896
+ value: 'global',
897
+ label: 'Global scope (user config)',
898
+ hint: 'Agents can load this ktx project from any working directory.',
899
+ },
900
+ ]
901
+ : []),
902
+ ];
903
+ const choice = await prompts.select({
904
+ message: `Where should ktx install agent config?\n\nktx project: ${projectRoot}`,
905
+ options,
906
+ });
907
+ if (choice === 'back')
908
+ return 'back';
909
+ if (choice === 'global')
910
+ return { scope: 'global', installRoot: projectRoot };
911
+ if (choice === 'current')
912
+ return { scope: 'project', installRoot: cwd };
913
+ if (choice === 'project')
914
+ return { scope: 'project', installRoot: projectRoot };
915
+ while (true) {
916
+ const typed = await prompts.text({ message: 'Enter the directory to install agent config into' });
917
+ if (typed === undefined)
918
+ return 'back';
919
+ const trimmed = typed.trim();
920
+ if (trimmed === '')
921
+ continue;
922
+ try {
923
+ return { scope: 'project', installRoot: await ensureInstallDir(resolveTypedInstallDir(cwd, trimmed)) };
924
+ }
925
+ catch (error) {
926
+ io.stderr.write(`${errorMessage(error)}\n`);
927
+ }
928
+ }
929
+ }
847
930
  export async function runKtxSetupAgentsStep(args, io, deps = {}) {
848
931
  if (args.skipAgents) {
849
932
  io.stdout.write('│ Agent integration skipped.\n');
@@ -905,30 +988,32 @@ export async function runKtxSetupAgentsStep(args, io, deps = {}) {
905
988
  : 'Missing agent target: pass --target or use interactive setup.\n');
906
989
  return { status: 'missing-input', projectDir: args.projectDir };
907
990
  }
991
+ const cwd = resolve(args.cwd ?? process.cwd());
992
+ const projectRoot = resolve(args.projectDir);
908
993
  const scopeTargets = targets.filter((target) => target !== 'claude-desktop');
909
- const selectedScope = args.inputMode !== 'disabled' &&
910
- args.scope === 'project' &&
911
- scopeTargets.length > 0 &&
912
- scopeTargets.every(targetSupportsGlobalScope)
913
- ? (await prompts.select({
914
- message: `Where should ktx install supported agent config?\n\nktx project: ${resolve(args.projectDir)}`,
915
- options: [
916
- {
917
- value: 'project',
918
- label: 'Project scope (ktx project directory)',
919
- hint: 'Only agents opened from this ktx project path load the project-scoped config.',
920
- },
921
- {
922
- value: 'global',
923
- label: 'Global scope (user config)',
924
- hint: 'Agents can load this ktx project from any working directory.',
925
- },
926
- ],
927
- }))
928
- : args.scope;
929
- if (selectedScope === 'back')
930
- return { status: 'back', projectDir: args.projectDir };
931
- const installs = targets.map((target) => ({ target, scope: effectiveInstallScope(target, selectedScope), mode }));
994
+ let selectedScope = args.scope;
995
+ let installRoot = projectRoot;
996
+ if (args.installRoot !== undefined) {
997
+ try {
998
+ installRoot = await ensureInstallDir(resolveTypedInstallDir(cwd, args.installRoot));
999
+ }
1000
+ catch (error) {
1001
+ writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error));
1002
+ return { status: 'failed', projectDir: args.projectDir };
1003
+ }
1004
+ selectedScope = 'project';
1005
+ }
1006
+ else if (args.inputMode !== 'disabled' && args.scope === 'project' && scopeTargets.length > 0) {
1007
+ const decision = await promptInstallDirectory({ prompts, io, cwd, projectRoot, scopeTargets });
1008
+ if (decision === 'back')
1009
+ return { status: 'back', projectDir: args.projectDir };
1010
+ selectedScope = decision.scope;
1011
+ installRoot = decision.installRoot;
1012
+ }
1013
+ const installs = targets.map((target) => {
1014
+ const scope = effectiveInstallScope(target, selectedScope);
1015
+ return { target, scope, mode, installRoot: scope === 'project' ? installRoot : projectRoot };
1016
+ });
932
1017
  const entries = [];
933
1018
  const snippets = [];
934
1019
  const notices = new Set();
@@ -938,6 +1023,7 @@ export async function runKtxSetupAgentsStep(args, io, deps = {}) {
938
1023
  entries.push(...targetEntries);
939
1024
  const mcpResult = await installMcpClientConfig({
940
1025
  projectDir: args.projectDir,
1026
+ installRoot: install.installRoot,
941
1027
  target: install.target,
942
1028
  scope: install.scope,
943
1029
  });
@@ -1,6 +1,7 @@
1
1
  import type { KtxLlmRuntimePort } from './context/llm/runtime-port.js';
2
2
  import { type ProposeQueryHistoryServiceAccountFiltersInput, type QueryHistoryFilterProposal } from './context/ingest/adapters/historic-sql/query-history-filter-picker.js';
3
3
  import { type HistoricSqlReadinessProbe } from './context/ingest/historic-sql-probes.js';
4
+ import { type KtxProjectConnectionConfig } from './context/project/config.js';
4
5
  import { loadKtxProject } from './context/project/project.js';
5
6
  import type { KtxTableListEntry } from './context/scan/types.js';
6
7
  import { type KtxCliIo } from './cli-runtime.js';
@@ -90,6 +91,8 @@ export interface KtxSetupDatabasesDeps {
90
91
  createQueryHistoryLlmRuntime?: (projectDir: string, project: Awaited<ReturnType<typeof loadKtxProject>>) => KtxLlmRuntimePort | null;
91
92
  }
92
93
  /** @internal */
94
+ export declare function federationNoticeFor(connections: Record<string, KtxProjectConnectionConfig>, projectDir: string): string | null;
95
+ /** @internal */
93
96
  export declare function managedDaemonOptionsForSetupQueryHistoryPicker(input: {
94
97
  projectDir: string;
95
98
  args: Pick<KtxSetupDatabasesArgs, 'cliVersion' | 'runtimeInstallPolicy' | 'inputMode'>;
@@ -3,6 +3,7 @@ import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { delimiter, dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { promisify } from 'node:util';
6
+ import { deriveFederatedConnection, FEDERATED_CONNECTION_ID } from './context/connections/federation.js';
6
7
  import { getDriverRegistration } from './context/connections/drivers.js';
7
8
  import { createLocalKtxLlmRuntimeFromConfig } from './context/llm/local-config.js';
8
9
  import { queryHistoryDialectForConnection } from './context/ingest/adapters/historic-sql/connection-dialect.js';
@@ -845,6 +846,21 @@ async function writeConnectionConfig(input) {
845
846
  if (queryHistory?.enabled === true) {
846
847
  await ensureHistoricSqlIngestDefaults(input.projectDir);
847
848
  }
849
+ if (input.io) {
850
+ const federationNotice = federationNoticeFor(config.connections, input.projectDir);
851
+ if (federationNotice) {
852
+ writeSetupSection(input.io, 'Federated connection available', [federationNotice]);
853
+ }
854
+ }
855
+ }
856
+ /** @internal */
857
+ export function federationNoticeFor(connections, projectDir) {
858
+ const descriptor = deriveFederatedConnection(connections, projectDir);
859
+ if (!descriptor) {
860
+ return null;
861
+ }
862
+ const names = descriptor.members.map((m) => m.connectionId).join(', ');
863
+ return `Detected ${descriptor.members.length} attach-compatible databases (${names}). Run a cross-database join as read-only SQL against \`${FEDERATED_CONNECTION_ID}\` (ktx sql -c ${FEDERATED_CONNECTION_ID} "SELECT ..."), using catalog-qualified table names.`;
848
864
  }
849
865
  async function disableConnectionQueryHistory(projectDir, connectionId) {
850
866
  const project = await loadKtxProject({ projectDir });
@@ -19,6 +19,7 @@ import { markKtxSetupStateStepComplete } from './context/project/setup-config.js
19
19
  import { createCliSpinner, errorMessage, writePrefixedLines } from './clack.js';
20
20
  import { pickNotionRootPages } from './notion-page-picker.js';
21
21
  import { runKtxSourceMapping } from './source-mapping.js';
22
+ import { assertSafeConnectionId } from './context/sl/source-files.js';
22
23
  import { runConnectionSetupWithRecovery, } from './connection-recovery.js';
23
24
  import { withMultiselectNavigation, withTextInputNavigation } from './prompt-navigation.js';
24
25
  import { runKtxPublicIngest } from './public-ingest.js';
@@ -100,11 +101,6 @@ async function findDbtProjectSubpaths(rootDir) {
100
101
  async function promptText(prompts, options) {
101
102
  return await prompts.text({ ...options, message: withTextInputNavigation(options.message) });
102
103
  }
103
- function assertSafeConnectionId(connectionId) {
104
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(connectionId)) {
105
- throw new Error(`Unsafe connection id: ${connectionId}`);
106
- }
107
- }
108
104
  function credentialRef(value, label) {
109
105
  const ref = value?.trim();
110
106
  if (!ref) {
package/dist/setup.d.ts CHANGED
@@ -57,6 +57,7 @@ export type KtxSetupArgs = {
57
57
  agents: boolean;
58
58
  target?: KtxAgentTarget;
59
59
  agentScope?: KtxAgentScope;
60
+ installRoot?: string;
60
61
  skipAgents?: boolean;
61
62
  inputMode: 'auto' | 'disabled';
62
63
  debug?: boolean;
package/dist/setup.js CHANGED
@@ -624,6 +624,7 @@ async function runKtxSetupInner(args, io, deps = {}) {
624
624
  agents: true,
625
625
  ...(args.target ? { target: args.target } : {}),
626
626
  scope: args.agentScope ?? 'project',
627
+ ...(args.installRoot ? { installRoot: args.installRoot } : {}),
627
628
  mode: 'mcp',
628
629
  skipAgents: false,
629
630
  showNextActions: agentsRequested,
package/dist/sql.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { executeFederatedQuery } from './connectors/duckdb/federated-executor.js';
1
2
  import { loadKtxProject } from './context/project/project.js';
2
3
  import type { SqlAnalysisPort } from './context/sql-analysis/ports.js';
3
4
  import type { KtxCliIo } from './cli-runtime.js';
@@ -18,6 +19,7 @@ export interface KtxSqlDeps {
18
19
  loadProject?: typeof loadKtxProject;
19
20
  createSqlAnalysis?: () => SqlAnalysisPort;
20
21
  createScanConnector?: typeof createKtxCliScanConnector;
22
+ executeFederated?: typeof executeFederatedQuery;
21
23
  }
22
24
  export declare function runKtxSql(args: KtxSqlArgs, io?: KtxCliIo, deps?: KtxSqlDeps): Promise<number>;
23
25
  export {};