@duckcodeailabs/dql-cli 1.6.0 → 1.6.2

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 (91) hide show
  1. package/README.md +9 -9
  2. package/dist/apps-api.d.ts +20 -0
  3. package/dist/apps-api.d.ts.map +1 -1
  4. package/dist/apps-api.js +82 -4
  5. package/dist/apps-api.js.map +1 -1
  6. package/dist/apps-api.test.js +43 -1
  7. package/dist/apps-api.test.js.map +1 -1
  8. package/dist/args.d.ts +2 -0
  9. package/dist/args.d.ts.map +1 -1
  10. package/dist/args.js +3 -0
  11. package/dist/args.js.map +1 -1
  12. package/dist/assets/dql-notebook/assets/index-60sOoPrg.js +3599 -0
  13. package/dist/assets/dql-notebook/assets/index-RaDW1A5g.css +1 -0
  14. package/dist/assets/dql-notebook/index.html +3 -3
  15. package/dist/commands/app.d.ts +4 -3
  16. package/dist/commands/app.d.ts.map +1 -1
  17. package/dist/commands/app.js +161 -75
  18. package/dist/commands/app.js.map +1 -1
  19. package/dist/commands/build.d.ts.map +1 -1
  20. package/dist/commands/build.js +7 -1
  21. package/dist/commands/build.js.map +1 -1
  22. package/dist/commands/certify.d.ts.map +1 -1
  23. package/dist/commands/certify.js +35 -5
  24. package/dist/commands/certify.js.map +1 -1
  25. package/dist/commands/compile.d.ts +1 -1
  26. package/dist/commands/compile.d.ts.map +1 -1
  27. package/dist/commands/compile.js +42 -3
  28. package/dist/commands/compile.js.map +1 -1
  29. package/dist/commands/doctor.d.ts.map +1 -1
  30. package/dist/commands/doctor.js +91 -4
  31. package/dist/commands/doctor.js.map +1 -1
  32. package/dist/commands/doctor.test.js +1 -0
  33. package/dist/commands/doctor.test.js.map +1 -1
  34. package/dist/commands/info.d.ts.map +1 -1
  35. package/dist/commands/info.js +21 -6
  36. package/dist/commands/info.js.map +1 -1
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js +3 -1
  39. package/dist/commands/init.js.map +1 -1
  40. package/dist/commands/init.test.js +4 -0
  41. package/dist/commands/init.test.js.map +1 -1
  42. package/dist/commands/lineage.d.ts +3 -0
  43. package/dist/commands/lineage.d.ts.map +1 -1
  44. package/dist/commands/lineage.js +230 -2
  45. package/dist/commands/lineage.js.map +1 -1
  46. package/dist/commands/migrate.d.ts.map +1 -1
  47. package/dist/commands/migrate.js +2 -1
  48. package/dist/commands/migrate.js.map +1 -1
  49. package/dist/commands/new.js +61 -2
  50. package/dist/commands/new.js.map +1 -1
  51. package/dist/commands/new.test.js +106 -0
  52. package/dist/commands/new.test.js.map +1 -1
  53. package/dist/commands/parse.d.ts.map +1 -1
  54. package/dist/commands/parse.js +13 -3
  55. package/dist/commands/parse.js.map +1 -1
  56. package/dist/commands/preview.d.ts.map +1 -1
  57. package/dist/commands/preview.js +7 -1
  58. package/dist/commands/preview.js.map +1 -1
  59. package/dist/commands/validate.d.ts.map +1 -1
  60. package/dist/commands/validate.js +55 -8
  61. package/dist/commands/validate.js.map +1 -1
  62. package/dist/commands/validate.test.js +85 -0
  63. package/dist/commands/validate.test.js.map +1 -1
  64. package/dist/commands/verify.d.ts +1 -1
  65. package/dist/commands/verify.d.ts.map +1 -1
  66. package/dist/commands/verify.js +40 -6
  67. package/dist/commands/verify.js.map +1 -1
  68. package/dist/index.js +136 -64
  69. package/dist/index.js.map +1 -1
  70. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  71. package/dist/llm/providers/dql-agent-provider.js +95 -19
  72. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  73. package/dist/llm/tools.d.ts.map +1 -1
  74. package/dist/llm/tools.js +29 -1
  75. package/dist/llm/tools.js.map +1 -1
  76. package/dist/llm/types.d.ts +3 -1
  77. package/dist/llm/types.d.ts.map +1 -1
  78. package/dist/local-runtime.d.ts +14 -0
  79. package/dist/local-runtime.d.ts.map +1 -1
  80. package/dist/local-runtime.js +763 -58
  81. package/dist/local-runtime.js.map +1 -1
  82. package/dist/local-runtime.test.js +64 -1
  83. package/dist/local-runtime.test.js.map +1 -1
  84. package/dist/template-adoption.test.d.ts +2 -0
  85. package/dist/template-adoption.test.d.ts.map +1 -0
  86. package/dist/template-adoption.test.js +105 -0
  87. package/dist/template-adoption.test.js.map +1 -0
  88. package/package.json +13 -13
  89. package/dist/assets/dql-notebook/assets/index-B5jI3I8Q.js +0 -869
  90. package/dist/assets/dql-notebook/assets/index-cv-O4BEj.css +0 -1
  91. package/dist/package.json +0 -44
@@ -1,6 +1,7 @@
1
1
  import { execSync } from 'node:child_process';
2
2
  import { createServer } from 'node:http';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
4
5
  import { dirname, extname, join, normalize, relative, resolve } from 'node:path';
5
6
  import { buildExecutionPlan, createWelcomeNotebook, deserializeNotebook, getConnectorFormSchemas, hasSemanticRefs, resolveSemanticRefs, } from '@duckcodeailabs/dql-notebook';
6
7
  import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, Parser, buildLineageGraph, buildManifest, findAppDocuments, findDashboardsForApp, isBlockIdRef, loadAppDocument, loadDashboardDocument, analyzeImpact, buildTrustChain, detectDomainFlows, getDomainTrustOverview, queryLineage, queryCompleteLineagePaths, LineageGraph, canonicalize, canonicalizeNotebook, diffDQL, diffNotebook, } from '@duckcodeailabs/dql-core';
@@ -120,7 +121,8 @@ export async function startLocalServer(opts) {
120
121
  config: (sourceCell.chartConfig ?? sourceCell.config),
121
122
  };
122
123
  const resolved = resolveNotebookBlockReferenceCell(cell, projectRoot);
123
- const plan = buildExecutionPlan(resolved.cell, { semanticLayer, driver: connection.driver });
124
+ const tableMapping = await resolveSemanticTableMapping(executor, connection, semanticLayer);
125
+ const plan = buildExecutionPlan(resolved.cell, { semanticLayer, driver: connection.driver, tableMapping });
124
126
  if (!plan) {
125
127
  snapshotCells.push({ cellId, status: 'idle', executionCount: 0, executedAt });
126
128
  continue;
@@ -181,15 +183,17 @@ export async function startLocalServer(opts) {
181
183
  }
182
184
  const absBlockPath = join(projectRoot, block.filePath);
183
185
  const source = readFileSync(absBlockPath, 'utf-8');
186
+ const tableMapping = await resolveSemanticTableMapping(executor, connection, semanticLayer);
184
187
  const semanticCompose = semanticLayer
185
188
  ? composeSemanticBlockSql(source, semanticLayer, {
186
189
  driver: connection.driver,
190
+ tableMapping,
187
191
  projectRoot,
188
192
  projectConfig,
189
193
  detectedProvider: semanticDetectedProvider,
190
194
  })
191
195
  : null;
192
- const plan = buildExecutionPlan({ id: `agent-${block.name}`, type: 'dql', source, title: block.name }, { semanticLayer, driver: connection.driver });
196
+ const plan = buildExecutionPlan({ id: `agent-${block.name}`, type: 'dql', source, title: block.name }, { semanticLayer, driver: connection.driver, tableMapping });
193
197
  if (!plan && !semanticCompose?.sql) {
194
198
  const semanticError = semanticCompose?.diagnostics.find((diagnostic) => diagnostic.severity === 'error')?.message;
195
199
  throw new Error(semanticError ?? `Block "${block.name}" produced no executable SQL.`);
@@ -210,6 +214,37 @@ export async function startLocalServer(opts) {
210
214
  blockPath: block.filePath,
211
215
  };
212
216
  };
217
+ const executeGeneratedSqlForAgent = async (sql) => {
218
+ const boundedSql = buildAgentPreviewSql(sql);
219
+ const semantic = prepareSemanticSql(boundedSql, semanticLayer);
220
+ if (semantic.unresolvedRefs.length > 0) {
221
+ throw new Error(`Unknown semantic reference${semantic.unresolvedRefs.length > 1 ? 's' : ''}: ${semantic.unresolvedRefs.join(', ')}`);
222
+ }
223
+ const prepared = prepareLocalExecution(semantic.sql, connection, projectRoot, projectConfig);
224
+ const app = loadRuntimeApp(projectRoot, activePersonaAppId());
225
+ assertAppAccess({ app, domain: app?.domain, level: 'execute' });
226
+ const rawResult = await executor.executeQuery(prepared.sql, [], runtimeVariables({}), prepared.connection);
227
+ const normalized = normalizeQueryResult(rawResult, semantic.semanticRefs);
228
+ return {
229
+ columns: normalized.columns,
230
+ rows: normalized.rows,
231
+ rowCount: normalized.rowCount,
232
+ executionTime: normalized.executionTime,
233
+ sql: prepared.sql,
234
+ };
235
+ };
236
+ const getSchemaContextForAgent = async (question) => {
237
+ try {
238
+ const result = await executor.executeQuery(`SELECT table_schema, table_name, column_name, data_type
239
+ FROM information_schema.columns
240
+ WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
241
+ ORDER BY table_schema, table_name, ordinal_position`, [], runtimeVariables({}), connection);
242
+ return buildAgentSchemaContext(question, result.rows);
243
+ }
244
+ catch {
245
+ return [];
246
+ }
247
+ };
213
248
  // SSE clients for /api/watch hot-reload
214
249
  const sseClients = new Set();
215
250
  // Watch notebooks/, workbooks/, semantic-layer/, and data/ dirs for changes
@@ -298,7 +333,7 @@ export async function startLocalServer(opts) {
298
333
  ?? 'No executable SQL found in block source.';
299
334
  throw new Error(message);
300
335
  }
301
- const plan = buildExecutionPlan({ id: 'block-studio', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver });
336
+ const plan = buildExecutionPlan({ id: 'block-studio', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver, tableMapping });
302
337
  const sql = resolveProjectRelativeSqlPaths(semanticCompose?.sql ?? plan?.sql ?? executableSql, projectRoot, projectConfig.dataDir);
303
338
  const result = await executor.executeQuery(sql, plan?.sqlParams ?? [], runtimeVariables(plan?.variables ?? {}), targetConnection);
304
339
  return {
@@ -309,7 +344,8 @@ export async function startLocalServer(opts) {
309
344
  };
310
345
  const runBlockStudioTestSummary = async (source, targetConnection = connection) => {
311
346
  const start = Date.now();
312
- const plan = buildExecutionPlan({ id: 'block-studio-tests', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver });
347
+ const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer);
348
+ const plan = buildExecutionPlan({ id: 'block-studio-tests', type: 'dql', source, title: 'Block Studio' }, { semanticLayer, driver: targetConnection.driver, tableMapping });
313
349
  const tests = plan?.tests ?? [];
314
350
  if (!plan || !plan.sql) {
315
351
  return {
@@ -578,7 +614,7 @@ export async function startLocalServer(opts) {
578
614
  ? body.variables
579
615
  : {};
580
616
  const tiles = [];
581
- const localApps = new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
617
+ let localApps = null;
582
618
  for (const item of loaded.dashboard.layout.items) {
583
619
  if (item.text) {
584
620
  tiles.push({
@@ -592,6 +628,18 @@ export async function startLocalServer(opts) {
592
628
  continue;
593
629
  }
594
630
  if (item.aiPin) {
631
+ try {
632
+ localApps ??= new LocalAppStorage(defaultLocalAppsDbPath(projectRoot));
633
+ }
634
+ catch (err) {
635
+ tiles.push({
636
+ tileId: item.i,
637
+ status: 'error',
638
+ tileType: 'aiPin',
639
+ error: err instanceof Error ? err.message : String(err),
640
+ });
641
+ continue;
642
+ }
595
643
  let pin = localApps.getAiPin(item.aiPin.id);
596
644
  if (!pin) {
597
645
  tiles.push({
@@ -645,15 +693,18 @@ export async function startLocalServer(opts) {
645
693
  });
646
694
  const absBlockPath = join(projectRoot, block.filePath);
647
695
  const source = readFileSync(absBlockPath, 'utf-8');
696
+ const targetConnection = isConnectionConfig(body.connection) ? body.connection : connection;
697
+ const tableMapping = await resolveSemanticTableMapping(executor, targetConnection, semanticLayer);
648
698
  const semanticCompose = semanticLayer
649
699
  ? composeSemanticBlockSql(source, semanticLayer, {
650
- driver: connection.driver,
700
+ driver: targetConnection.driver,
701
+ tableMapping,
651
702
  projectRoot,
652
703
  projectConfig,
653
704
  detectedProvider: semanticDetectedProvider,
654
705
  })
655
706
  : null;
656
- const plan = buildExecutionPlan({ id: item.i, type: 'dql', source, title: item.title ?? block.name }, { semanticLayer, driver: connection.driver });
707
+ const plan = buildExecutionPlan({ id: item.i, type: 'dql', source, title: item.title ?? block.name }, { semanticLayer, driver: targetConnection.driver, tableMapping });
657
708
  if (!plan && !semanticCompose?.sql) {
658
709
  tiles.push({
659
710
  tileId: item.i,
@@ -663,7 +714,7 @@ export async function startLocalServer(opts) {
663
714
  });
664
715
  continue;
665
716
  }
666
- const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan.sql, isConnectionConfig(body.connection) ? body.connection : connection, projectRoot, projectConfig);
717
+ const prepared = prepareLocalExecution(semanticCompose?.sql ?? plan.sql, targetConnection, projectRoot, projectConfig);
667
718
  const result = await executor.executeQuery(prepared.sql, plan?.sqlParams ?? [], runtimeVariables({ ...(plan?.variables ?? {}), ...variables }), prepared.connection);
668
719
  tiles.push({
669
720
  tileId: item.i,
@@ -701,7 +752,7 @@ export async function startLocalServer(opts) {
701
752
  }
702
753
  }
703
754
  }
704
- localApps.close();
755
+ localApps?.close();
705
756
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
706
757
  res.end(serializeJSON({
707
758
  appId,
@@ -1758,8 +1809,9 @@ export async function startLocalServer(opts) {
1758
1809
  const defaultKey = raw.defaultConnection
1759
1810
  ? 'default'
1760
1811
  : Object.keys(connections)[0] ?? 'default';
1812
+ const dbtProfiles = discoverDbtProfileConnections(projectRoot, cfg);
1761
1813
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1762
- res.end(serializeJSON({ default: defaultKey, connections }));
1814
+ res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles }));
1763
1815
  return;
1764
1816
  }
1765
1817
  // Save/update connections
@@ -2476,7 +2528,15 @@ export async function startLocalServer(opts) {
2476
2528
  req.on('close', () => controller.abort());
2477
2529
  const emit = (turn) => { res.write(`data: ${JSON.stringify(turn)}\n\n`); };
2478
2530
  try {
2479
- await runner.run({ provider: resolvedProvider, messages, upstream, projectRoot, executeCertifiedBlock: executeCertifiedBlockForAgent }, emit, controller.signal);
2531
+ await runner.run({
2532
+ provider: resolvedProvider,
2533
+ messages,
2534
+ upstream,
2535
+ projectRoot,
2536
+ executeCertifiedBlock: executeCertifiedBlockForAgent,
2537
+ executeGeneratedSql: executeGeneratedSqlForAgent,
2538
+ getSchemaContext: getSchemaContextForAgent,
2539
+ }, emit, controller.signal);
2480
2540
  }
2481
2541
  catch (err) {
2482
2542
  emit({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
@@ -3048,7 +3108,8 @@ export async function startLocalServer(opts) {
3048
3108
  const resolved = resolveNotebookBlockReferenceCell(cell, projectRoot);
3049
3109
  const executableCell = resolved.cell;
3050
3110
  const cellConnection = isConnectionConfig(body.connection) ? body.connection : connection;
3051
- const plan = buildExecutionPlan(executableCell, { semanticLayer, driver: cellConnection.driver });
3111
+ const tableMapping = await resolveSemanticTableMapping(executor, cellConnection, semanticLayer);
3112
+ const plan = buildExecutionPlan(executableCell, { semanticLayer, driver: cellConnection.driver, tableMapping });
3052
3113
  if (!plan) {
3053
3114
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
3054
3115
  res.end(serializeJSON({ cellType: cell.type, result: null }));
@@ -3308,6 +3369,7 @@ export function loadProjectConfig(projectRoot) {
3308
3369
  // Support both `filepath` (correct) and `path` (legacy/init compat)
3309
3370
  const filepath = (defaultConn.filepath ?? defaultConn.path);
3310
3371
  config.defaultConnection = {
3372
+ ...defaultConn,
3311
3373
  driver: defaultConn.driver,
3312
3374
  ...(filepath ? { filepath } : {}),
3313
3375
  };
@@ -3324,6 +3386,100 @@ export function prepareLocalExecution(sql, connection, projectRoot, projectConfi
3324
3386
  connection: normalizedConnection,
3325
3387
  };
3326
3388
  }
3389
+ const AGENT_PREVIEW_FORBIDDEN_SQL = [
3390
+ 'alter',
3391
+ 'analyze',
3392
+ 'attach',
3393
+ 'call',
3394
+ 'copy',
3395
+ 'create',
3396
+ 'delete',
3397
+ 'detach',
3398
+ 'drop',
3399
+ 'export',
3400
+ 'grant',
3401
+ 'import',
3402
+ 'insert',
3403
+ 'install',
3404
+ 'load',
3405
+ 'merge',
3406
+ 'pragma',
3407
+ 'reset',
3408
+ 'revoke',
3409
+ 'set',
3410
+ 'truncate',
3411
+ 'update',
3412
+ 'vacuum',
3413
+ ];
3414
+ export function buildAgentPreviewSql(sql) {
3415
+ const trimmed = sql.trim();
3416
+ if (!trimmed)
3417
+ throw new Error('Generated SQL preview is empty.');
3418
+ const withoutTrailingSemicolon = trimmed.replace(/;\s*$/, '').trim();
3419
+ const scanSql = stripSqlStringsAndComments(withoutTrailingSemicolon).trim();
3420
+ if (!/^(select|with)\b/i.test(scanSql)) {
3421
+ throw new Error('Generated SQL preview only supports read-only SELECT or WITH queries.');
3422
+ }
3423
+ if (scanSql.includes(';')) {
3424
+ throw new Error('Generated SQL preview only supports one statement.');
3425
+ }
3426
+ const forbiddenPattern = new RegExp(`\\b(${AGENT_PREVIEW_FORBIDDEN_SQL.join('|')})\\b`, 'i');
3427
+ const forbidden = scanSql.match(forbiddenPattern)?.[1];
3428
+ if (forbidden) {
3429
+ throw new Error(`Generated SQL preview rejected unsupported statement keyword: ${forbidden.toUpperCase()}.`);
3430
+ }
3431
+ return `SELECT * FROM (\n${withoutTrailingSemicolon}\n) AS dql_agent_preview LIMIT 200`;
3432
+ }
3433
+ function stripSqlStringsAndComments(sql) {
3434
+ let output = '';
3435
+ for (let index = 0; index < sql.length; index += 1) {
3436
+ const current = sql[index];
3437
+ const next = sql[index + 1];
3438
+ if (current === '-' && next === '-') {
3439
+ output += ' ';
3440
+ index += 2;
3441
+ while (index < sql.length && sql[index] !== '\n') {
3442
+ output += ' ';
3443
+ index += 1;
3444
+ }
3445
+ if (index < sql.length)
3446
+ output += '\n';
3447
+ continue;
3448
+ }
3449
+ if (current === '/' && next === '*') {
3450
+ output += ' ';
3451
+ index += 2;
3452
+ while (index < sql.length && !(sql[index] === '*' && sql[index + 1] === '/')) {
3453
+ output += sql[index] === '\n' ? '\n' : ' ';
3454
+ index += 1;
3455
+ }
3456
+ if (index < sql.length) {
3457
+ output += ' ';
3458
+ index += 1;
3459
+ }
3460
+ continue;
3461
+ }
3462
+ if (current === "'" || current === '"') {
3463
+ const quote = current;
3464
+ output += ' ';
3465
+ while (index + 1 < sql.length) {
3466
+ index += 1;
3467
+ output += sql[index] === '\n' ? '\n' : ' ';
3468
+ if (sql[index] === quote) {
3469
+ if (sql[index + 1] === quote) {
3470
+ index += 1;
3471
+ output += ' ';
3472
+ continue;
3473
+ }
3474
+ break;
3475
+ }
3476
+ }
3477
+ continue;
3478
+ }
3479
+ output += current;
3480
+ }
3481
+ return output;
3482
+ }
3327
3483
  /**
3328
3484
  * Shared resolver for `@metric(name)` / `@dim(name)` refs in raw SQL.
3329
3485
  * Used by notebook SQL execution and Block Studio validation so both paths
@@ -3344,7 +3500,7 @@ export function prepareSemanticSql(sql, semanticLayer) {
3344
3500
  };
3345
3501
  }
3346
3502
  export function normalizeProjectConnection(connection, projectRoot) {
3347
- const normalized = { ...connection };
3503
+ const normalized = expandConnectionEnvPlaceholders({ ...connection });
3348
3504
  if ((normalized.driver === 'file' || normalized.driver === 'duckdb') && normalized.filepath && normalized.filepath !== ':memory:' && !isAbsoluteLikePath(normalized.filepath)) {
3349
3505
  normalized.filepath = resolve(projectRoot, normalized.filepath);
3350
3506
  }
@@ -3353,6 +3509,15 @@ export function normalizeProjectConnection(connection, projectRoot) {
3353
3509
  }
3354
3510
  return normalized;
3355
3511
  }
3512
+ function expandConnectionEnvPlaceholders(connection) {
3513
+ const expanded = {};
3514
+ for (const [key, value] of Object.entries(connection)) {
3515
+ expanded[key] = typeof value === 'string'
3516
+ ? value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, envKey) => process.env[envKey] ?? match)
3517
+ : value;
3518
+ }
3519
+ return expanded;
3520
+ }
3356
3521
  export function resolveProjectRelativeSqlPaths(sql, projectRoot, dataDir) {
3357
3522
  const resolvedRoot = resolve(projectRoot);
3358
3523
  const normalizedDataDir = typeof dataDir === 'string' && dataDir.trim().length > 0
@@ -3495,6 +3660,8 @@ function scanNotebookFiles(projectRoot) {
3495
3660
  workbooks: 'workbook',
3496
3661
  blocks: 'block',
3497
3662
  dashboards: 'dashboard',
3663
+ terms: 'term',
3664
+ 'business-views': 'business_view',
3498
3665
  };
3499
3666
  for (const [folder, type] of Object.entries(folderMap)) {
3500
3667
  const dir = join(projectRoot, folder);
@@ -3516,8 +3683,9 @@ function scanNotebookFiles(projectRoot) {
3516
3683
  continue;
3517
3684
  if (!entry.name.endsWith('.dql') && !entry.name.endsWith('.dqlnb'))
3518
3685
  continue;
3686
+ const fallbackName = entry.name.replace(/\.(dql|dqlnb)$/, '');
3519
3687
  result.push({
3520
- name: entry.name.replace(/\.(dql|dqlnb)$/, ''),
3688
+ name: inferDqlArtifactName(fullPath, type, fallbackName),
3521
3689
  path: relativePath,
3522
3690
  type,
3523
3691
  folder: relativeDir.split('/')[0] ?? relativeDir,
@@ -3527,6 +3695,27 @@ function scanNotebookFiles(projectRoot) {
3527
3695
  catch { /* skip unreadable dirs */ }
3528
3696
  }
3529
3697
  }
3698
+ function inferDqlArtifactName(fullPath, type, fallbackName) {
3699
+ if (!fullPath.endsWith('.dql'))
3700
+ return fallbackName;
3701
+ const expectedKind = {
3702
+ block: 'BlockDecl',
3703
+ dashboard: 'Dashboard',
3704
+ term: 'TermDecl',
3705
+ business_view: 'BusinessViewDecl',
3706
+ };
3707
+ const kind = expectedKind[type];
3708
+ if (!kind)
3709
+ return fallbackName;
3710
+ try {
3711
+ const ast = new Parser(readFileSync(fullPath, 'utf-8')).parse();
3712
+ const statement = ast.statements.find((item) => item.kind === kind && typeof item.name === 'string');
3713
+ return statement?.name ?? fallbackName;
3714
+ }
3715
+ catch {
3716
+ return fallbackName;
3717
+ }
3718
+ }
3530
3719
  function scanDataFiles(projectRoot) {
3531
3720
  const dataDir = join(projectRoot, 'data');
3532
3721
  if (!existsSync(dataDir))
@@ -3756,7 +3945,20 @@ function parseSemanticBlockConfig(source) {
3756
3945
  limit: limitMatch ? Number.parseInt(limitMatch[1], 10) : undefined,
3757
3946
  };
3758
3947
  }
3759
- function buildSemanticTableMapping(semanticLayer, rows) {
3948
+ export async function resolveSemanticTableMapping(executor, connection, semanticLayer) {
3949
+ if (!semanticLayer)
3950
+ return undefined;
3951
+ try {
3952
+ const tablesResult = await executor.executeQuery(`SELECT table_schema, table_name
3953
+ FROM information_schema.tables
3954
+ WHERE table_schema NOT IN ('information_schema', 'pg_catalog')`, [], {}, connection);
3955
+ return buildSemanticTableMapping(semanticLayer, tablesResult.rows);
3956
+ }
3957
+ catch {
3958
+ return undefined;
3959
+ }
3960
+ }
3961
+ export function buildSemanticTableMapping(semanticLayer, rows) {
3760
3962
  const dbTableNames = new Set();
3761
3963
  const schemaQualified = new Map();
3762
3964
  for (const row of rows) {
@@ -4569,7 +4771,10 @@ function buildSemanticBlockContent(options) {
4569
4771
  if (options.tags && options.tags.length > 0) {
4570
4772
  lines.push(` tags = [${options.tags.map((tag) => `"${escapeDqlString(tag)}"`).join(', ')}]`);
4571
4773
  }
4572
- if (options.metrics.length === 1) {
4774
+ if (options.metrics.length === 0) {
4775
+ lines.push(' metric = ""');
4776
+ }
4777
+ else if (options.metrics.length === 1) {
4573
4778
  lines.push(` metric = "${escapeDqlString(options.metrics[0])}"`);
4574
4779
  }
4575
4780
  else {
@@ -4578,6 +4783,9 @@ function buildSemanticBlockContent(options) {
4578
4783
  if (options.dimensions.length > 0) {
4579
4784
  lines.push(` dimensions = [${options.dimensions.map((dimension) => `"${escapeDqlString(dimension)}"`).join(', ')}]`);
4580
4785
  }
4786
+ else {
4787
+ lines.push(' dimensions = []');
4788
+ }
4581
4789
  if (options.timeDimension) {
4582
4790
  lines.push(` time_dimension = "${escapeDqlString(options.timeDimension.name)}"`);
4583
4791
  lines.push(` granularity = "${escapeDqlString(options.timeDimension.granularity)}"`);
@@ -4945,7 +5153,51 @@ function resolveDbtManifestPath(projectRoot) {
4945
5153
  const candidate = join(projectRoot, 'target', 'manifest.json');
4946
5154
  return existsSync(candidate) ? candidate : undefined;
4947
5155
  }
4948
- export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
5156
+ export function discoverDbtProfileConnections(projectRoot, projectConfig) {
5157
+ const dbtProjectPath = findDbtProjectPath(projectRoot, projectConfig);
5158
+ const projectProfileName = readDbtProjectProfileName(dbtProjectPath);
5159
+ const profilePaths = findDbtProfilePaths(projectRoot, dbtProjectPath);
5160
+ const candidates = [];
5161
+ for (const profilePath of profilePaths) {
5162
+ const profiles = readYamlFile(profilePath);
5163
+ if (!profiles)
5164
+ continue;
5165
+ for (const [profileName, rawProfile] of Object.entries(profiles)) {
5166
+ if (!rawProfile || typeof rawProfile !== 'object')
5167
+ continue;
5168
+ if (projectProfileName && profileName !== projectProfileName)
5169
+ continue;
5170
+ const profile = rawProfile;
5171
+ const outputs = profile.outputs && typeof profile.outputs === 'object'
5172
+ ? profile.outputs
5173
+ : {};
5174
+ const defaultTarget = typeof profile.target === 'string' ? profile.target : 'default';
5175
+ for (const [targetName, output] of Object.entries(outputs)) {
5176
+ if (!output || typeof output !== 'object')
5177
+ continue;
5178
+ const mapped = mapDbtProfileOutput(output);
5179
+ if (!mapped)
5180
+ continue;
5181
+ const warnings = [...mapped.warnings];
5182
+ if (targetName !== defaultTarget) {
5183
+ warnings.push(`Not the default dbt target "${defaultTarget}".`);
5184
+ }
5185
+ candidates.push({
5186
+ id: `${profilePath}:${profileName}:${targetName}`,
5187
+ profileName,
5188
+ targetName,
5189
+ adapter: mapped.adapter,
5190
+ path: profilePath,
5191
+ connection: mapped.connection,
5192
+ missingFields: requiredConnectionFields(mapped.connection, mapped.envRefs),
5193
+ warnings,
5194
+ });
5195
+ }
5196
+ }
5197
+ }
5198
+ return candidates.slice(0, 20);
5199
+ }
5200
+ function findDbtProjectPath(projectRoot, projectConfig) {
4949
5201
  const configuredDbtDir = projectConfig.dbt?.projectDir
4950
5202
  ? resolve(projectRoot, projectConfig.dbt.projectDir)
4951
5203
  : undefined;
@@ -4960,7 +5212,259 @@ export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
4960
5212
  resolve(projectRoot, '../dbt'),
4961
5213
  resolve(projectRoot, '../../dbt'),
4962
5214
  ].filter((value) => Boolean(value));
4963
- const dbtProjectPath = candidateDirs.find((dir, index, list) => list.indexOf(dir) === index && existsSync(join(dir, 'dbt_project.yml'))) ?? configuredDbtDir ?? semanticDbtDir ?? projectRoot;
5215
+ return candidateDirs.find((dir, index, list) => list.indexOf(dir) === index && existsSync(join(dir, 'dbt_project.yml')))
5216
+ ?? configuredDbtDir
5217
+ ?? semanticDbtDir
5218
+ ?? projectRoot;
5219
+ }
5220
+ function findDbtProfilePaths(projectRoot, dbtProjectPath) {
5221
+ const dirs = [
5222
+ process.env.DBT_PROFILES_DIR,
5223
+ dbtProjectPath,
5224
+ projectRoot,
5225
+ join(homedir(), '.dbt'),
5226
+ ].filter((value) => Boolean(value));
5227
+ const paths = [];
5228
+ for (const dir of dirs) {
5229
+ for (const filename of ['profiles.yml', 'profiles.yaml']) {
5230
+ const profilePath = resolve(dir, filename);
5231
+ if (existsSync(profilePath) && !paths.includes(profilePath)) {
5232
+ paths.push(profilePath);
5233
+ }
5234
+ }
5235
+ }
5236
+ return paths;
5237
+ }
5238
+ function readDbtProjectProfileName(dbtProjectPath) {
5239
+ const projectFile = join(dbtProjectPath, 'dbt_project.yml');
5240
+ const projectYaml = readYamlFile(projectFile);
5241
+ return typeof projectYaml?.profile === 'string' ? projectYaml.profile : null;
5242
+ }
5243
+ function readYamlFile(path) {
5244
+ if (!existsSync(path))
5245
+ return null;
5246
+ try {
5247
+ const parsed = loadYaml(readFileSync(path, 'utf-8'));
5248
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
5249
+ ? parsed
5250
+ : null;
5251
+ }
5252
+ catch {
5253
+ return null;
5254
+ }
5255
+ }
5256
+ function mapDbtProfileOutput(output) {
5257
+ const adapter = text(output, 'type').value?.toLowerCase();
5258
+ const envRefs = new Set();
5259
+ const warnings = [];
5260
+ const read = (...keys) => {
5261
+ const result = text(output, ...keys);
5262
+ result.envRefs.forEach((ref) => envRefs.add(ref));
5263
+ return result.value;
5264
+ };
5265
+ const port = numberValue(output, 'port');
5266
+ const sslRaw = read('ssl', 'sslmode');
5267
+ const ssl = sslRaw === undefined
5268
+ ? undefined
5269
+ : !['false', '0', 'disable', 'disabled', 'off'].includes(sslRaw.toLowerCase());
5270
+ switch (adapter) {
5271
+ case 'postgres':
5272
+ case 'postgresql':
5273
+ return {
5274
+ adapter,
5275
+ connection: compactConnection({
5276
+ driver: 'postgresql',
5277
+ host: read('host'),
5278
+ port,
5279
+ database: read('dbname', 'database'),
5280
+ schema: read('schema'),
5281
+ username: read('user', 'username'),
5282
+ password: read('password', 'pass'),
5283
+ ssl,
5284
+ }),
5285
+ envRefs: [...envRefs],
5286
+ warnings,
5287
+ };
5288
+ case 'redshift':
5289
+ return {
5290
+ adapter,
5291
+ connection: compactConnection({
5292
+ driver: 'redshift',
5293
+ host: read('host'),
5294
+ port: port ?? 5439,
5295
+ database: read('dbname', 'database'),
5296
+ schema: read('schema'),
5297
+ username: read('user', 'username'),
5298
+ password: read('password', 'pass'),
5299
+ ssl,
5300
+ }),
5301
+ envRefs: [...envRefs],
5302
+ warnings,
5303
+ };
5304
+ case 'snowflake': {
5305
+ const privateKeyPath = read('private_key_path', 'privateKeyPath');
5306
+ const authenticator = read('authenticator');
5307
+ const authMethod = privateKeyPath
5308
+ ? 'key_pair'
5309
+ : authenticator?.toLowerCase() === 'externalbrowser'
5310
+ ? 'external_browser'
5311
+ : 'password';
5312
+ return {
5313
+ adapter,
5314
+ connection: compactConnection({
5315
+ driver: 'snowflake',
5316
+ account: read('account'),
5317
+ warehouse: read('warehouse'),
5318
+ database: read('database'),
5319
+ schema: read('schema'),
5320
+ username: read('user', 'username'),
5321
+ password: read('password'),
5322
+ role: read('role'),
5323
+ privateKeyPath,
5324
+ privateKeyPassphrase: read('private_key_passphrase', 'privateKeyPassphrase'),
5325
+ authenticator,
5326
+ authMethod,
5327
+ }),
5328
+ envRefs: [...envRefs],
5329
+ warnings,
5330
+ };
5331
+ }
5332
+ case 'bigquery': {
5333
+ const keyFilename = read('keyfile', 'keyFilename');
5334
+ return {
5335
+ adapter,
5336
+ connection: compactConnection({
5337
+ driver: 'bigquery',
5338
+ projectId: read('project', 'projectId'),
5339
+ schema: read('dataset', 'schema'),
5340
+ location: read('location'),
5341
+ keyFilename,
5342
+ authMethod: keyFilename ? 'service_account_key_file' : 'application_default',
5343
+ }),
5344
+ envRefs: [...envRefs],
5345
+ warnings,
5346
+ };
5347
+ }
5348
+ case 'duckdb':
5349
+ return {
5350
+ adapter,
5351
+ connection: compactConnection({
5352
+ driver: 'duckdb',
5353
+ filepath: read('path', 'database') ?? ':memory:',
5354
+ }),
5355
+ envRefs: [...envRefs],
5356
+ warnings,
5357
+ };
5358
+ case 'databricks':
5359
+ return {
5360
+ adapter,
5361
+ connection: compactConnection({
5362
+ driver: 'databricks',
5363
+ host: read('host', 'server_hostname'),
5364
+ httpPath: read('http_path', 'httpPath'),
5365
+ warehouse: read('warehouse', 'warehouse_id'),
5366
+ catalog: read('catalog'),
5367
+ database: read('catalog', 'database'),
5368
+ schema: read('schema'),
5369
+ token: read('token'),
5370
+ authMethod: 'token',
5371
+ }),
5372
+ envRefs: [...envRefs],
5373
+ warnings,
5374
+ };
5375
+ default:
5376
+ return null;
5377
+ }
5378
+ }
5379
+ function text(source, ...keys) {
5380
+ for (const key of keys) {
5381
+ const raw = source[key];
5382
+ if (raw === undefined || raw === null)
5383
+ continue;
5384
+ const value = String(raw).trim();
5385
+ if (!value)
5386
+ continue;
5387
+ return resolveDbtEnvVars(value);
5388
+ }
5389
+ return { envRefs: [] };
5390
+ }
5391
+ function resolveDbtEnvVars(value) {
5392
+ const envRefs = [];
5393
+ const replaced = value.replace(/\{\{\s*env_var\(\s*['"]([^'"]+)['"]\s*(?:,\s*(['"])(.*?)\2)?\s*\)\s*\}\}/g, (_match, envKey, _quote, fallback) => {
5394
+ const envValue = process.env[envKey];
5395
+ if (envValue !== undefined)
5396
+ return envValue;
5397
+ if (fallback !== undefined)
5398
+ return fallback;
5399
+ envRefs.push(envKey);
5400
+ return `\${${envKey}}`;
5401
+ });
5402
+ return { value: replaced, envRefs };
5403
+ }
5404
+ function numberValue(source, key) {
5405
+ const raw = source[key];
5406
+ if (raw === undefined || raw === null || raw === '')
5407
+ return undefined;
5408
+ const value = Number(raw);
5409
+ return Number.isFinite(value) ? value : undefined;
5410
+ }
5411
+ function compactConnection(connection) {
5412
+ const compact = {};
5413
+ for (const [key, value] of Object.entries(connection)) {
5414
+ if (value === undefined || value === null || value === '')
5415
+ continue;
5416
+ compact[key] = value;
5417
+ }
5418
+ return compact;
5419
+ }
5420
+ function requiredConnectionFields(connection, envRefs) {
5421
+ const missing = new Set();
5422
+ const needs = (field) => {
5423
+ const value = connection[field];
5424
+ if (value === undefined || value === null || value === '')
5425
+ missing.add(String(field));
5426
+ };
5427
+ switch (connection.driver) {
5428
+ case 'postgresql':
5429
+ case 'redshift':
5430
+ needs('host');
5431
+ needs('database');
5432
+ needs('username');
5433
+ break;
5434
+ case 'snowflake':
5435
+ needs('account');
5436
+ needs('warehouse');
5437
+ needs('database');
5438
+ needs('schema');
5439
+ needs('username');
5440
+ if (connection.authMethod === 'key_pair') {
5441
+ needs('privateKeyPath');
5442
+ }
5443
+ else if (connection.authMethod !== 'external_browser') {
5444
+ needs('password');
5445
+ }
5446
+ break;
5447
+ case 'bigquery':
5448
+ needs('projectId');
5449
+ break;
5450
+ case 'duckdb':
5451
+ needs('filepath');
5452
+ break;
5453
+ case 'databricks':
5454
+ needs('host');
5455
+ if (!connection.httpPath && !connection.warehouse)
5456
+ missing.add('httpPath');
5457
+ needs('token');
5458
+ break;
5459
+ }
5460
+ for (const envKey of envRefs) {
5461
+ if (!process.env[envKey])
5462
+ missing.add(`env:${envKey}`);
5463
+ }
5464
+ return [...missing];
5465
+ }
5466
+ export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
5467
+ const dbtProjectPath = findDbtProjectPath(projectRoot, projectConfig);
4964
5468
  const configuredManifest = projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
4965
5469
  const manifestPath = resolve(dbtProjectPath, configuredManifest);
4966
5470
  const catalogPath = resolve(dbtProjectPath, 'target/catalog.json');
@@ -4990,7 +5494,9 @@ export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
4990
5494
  const savedQueryCount = Array.isArray(semanticManifest?.saved_queries)
4991
5495
  ? semanticManifest.saved_queries.length
4992
5496
  : 0;
4993
- const configured = existsSync(join(dbtProjectPath, 'dbt_project.yml')) || Boolean(configuredDbtDir || semanticDbtDir);
5497
+ const configured = existsSync(join(dbtProjectPath, 'dbt_project.yml'))
5498
+ || Boolean(projectConfig.dbt?.projectDir)
5499
+ || Boolean(projectConfig.semanticLayer?.provider === 'dbt' && projectConfig.semanticLayer.projectPath);
4994
5500
  const manifestExists = existsSync(manifestPath);
4995
5501
  const semanticExists = existsSync(semanticManifestPath);
4996
5502
  const setupHint = !configured
@@ -5107,14 +5613,21 @@ async function execGit(cwd, args) {
5107
5613
  });
5108
5614
  });
5109
5615
  }
5110
- async function readGitStatus(cwd) {
5616
+ async function resolveGitRoot(cwd) {
5111
5617
  const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5112
- if (isRepo.code !== 0 || isRepo.stdout.trim() !== 'true') {
5618
+ if (isRepo.code !== 0 || isRepo.stdout.trim() !== 'true')
5619
+ return null;
5620
+ const root = await execGit(cwd, ['rev-parse', '--show-toplevel']);
5621
+ return root.code === 0 && root.stdout.trim() ? root.stdout.trim() : cwd;
5622
+ }
5623
+ async function readGitStatus(cwd) {
5624
+ const gitRoot = await resolveGitRoot(cwd);
5625
+ if (!gitRoot) {
5113
5626
  return { inRepo: false, branch: null, ahead: 0, behind: 0, changes: [] };
5114
5627
  }
5115
- const branchRes = await execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
5628
+ const branchRes = await execGit(gitRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
5116
5629
  const branch = branchRes.code === 0 ? branchRes.stdout.trim() : null;
5117
- const trackRes = await execGit(cwd, ['rev-list', '--left-right', '--count', '@{u}...HEAD']);
5630
+ const trackRes = await execGit(gitRoot, ['rev-list', '--left-right', '--count', '@{u}...HEAD']);
5118
5631
  let ahead = 0;
5119
5632
  let behind = 0;
5120
5633
  if (trackRes.code === 0) {
@@ -5122,7 +5635,7 @@ async function readGitStatus(cwd) {
5122
5635
  behind = Number(match[0] ?? 0);
5123
5636
  ahead = Number(match[1] ?? 0);
5124
5637
  }
5125
- const statusRes = await execGit(cwd, ['status', '--porcelain=v1', '--untracked-files=normal']);
5638
+ const statusRes = await execGit(gitRoot, ['status', '--porcelain=v1', '--untracked-files=normal']);
5126
5639
  const changes = [];
5127
5640
  if (statusRes.code === 0) {
5128
5641
  for (const line of statusRes.stdout.split('\n')) {
@@ -5136,13 +5649,13 @@ async function readGitStatus(cwd) {
5136
5649
  return { inRepo: true, branch, ahead, behind, changes };
5137
5650
  }
5138
5651
  async function readGitLog(cwd, limit) {
5139
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5140
- if (isRepo.code !== 0)
5652
+ const gitRoot = await resolveGitRoot(cwd);
5653
+ if (!gitRoot)
5141
5654
  return { inRepo: false, commits: [] };
5142
5655
  const sep = '\x1f';
5143
5656
  const end = '\x1e';
5144
5657
  const fmt = ['%H', '%an', '%ad', '%s'].join(sep) + end;
5145
- const res = await execGit(cwd, ['log', `-${limit}`, `--pretty=format:${fmt}`, '--date=short']);
5658
+ const res = await execGit(gitRoot, ['log', `-${limit}`, `--pretty=format:${fmt}`, '--date=short']);
5146
5659
  if (res.code !== 0)
5147
5660
  return { inRepo: true, commits: [] };
5148
5661
  const commits = [];
@@ -5205,23 +5718,97 @@ function ensureGitignoreEntry(projectRoot, pattern) {
5205
5718
  }
5206
5719
  }
5207
5720
  async function readGitDiff(cwd, filePath, staged = false) {
5208
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5209
- if (isRepo.code !== 0) {
5721
+ const gitRoot = await resolveGitRoot(cwd);
5722
+ if (!gitRoot) {
5210
5723
  return { inRepo: false, diff: '', before: null, after: null, diffReport: null };
5211
5724
  }
5212
5725
  const baseArgs = staged ? ['diff', '--cached', '--no-color'] : ['diff', '--no-color'];
5213
5726
  if (!filePath) {
5214
- const res = await execGit(cwd, baseArgs);
5727
+ const res = await execGit(gitRoot, baseArgs);
5215
5728
  return { inRepo: true, diff: res.stdout, before: null, after: null, diffReport: null };
5216
5729
  }
5217
5730
  const isSemantic = filePath.endsWith('.dql') || filePath.endsWith('.dqlnb');
5218
5731
  const [diffRes, before, after] = await Promise.all([
5219
- execGit(cwd, [...baseArgs, '--', filePath]),
5220
- isSemantic ? readHeadBlob(cwd, filePath) : Promise.resolve(null),
5221
- isSemantic ? readWorkingCopy(join(cwd, filePath)) : Promise.resolve(null),
5732
+ execGit(gitRoot, [...baseArgs, '--', filePath]),
5733
+ isSemantic ? readHeadBlob(gitRoot, filePath) : Promise.resolve(null),
5734
+ isSemantic ? readWorkingCopy(join(gitRoot, filePath)) : Promise.resolve(null),
5222
5735
  ]);
5736
+ const diffText = !staged && !diffRes.stdout.trim()
5737
+ ? (await readUntrackedTextDiff(gitRoot, filePath)) || diffRes.stdout
5738
+ : diffRes.stdout;
5223
5739
  const diffReport = isSemantic ? computeSemanticDiff(filePath, before, after) : null;
5224
- return { inRepo: true, diff: diffRes.stdout, before, after, diffReport };
5740
+ return { inRepo: true, diff: diffText, before, after, diffReport };
5741
+ }
5742
+ const MAX_UNTRACKED_DIFF_FILES = 20;
5743
+ const MAX_UNTRACKED_DIFF_BYTES = 512 * 1024;
5744
+ async function readUntrackedTextDiff(cwd, filePath) {
5745
+ const status = await execGit(cwd, ['status', '--porcelain=v1', '--untracked-files=normal', '--', filePath]);
5746
+ if (status.code !== 0 || !status.stdout.split('\n').some((line) => line.startsWith('?? '))) {
5747
+ return '';
5748
+ }
5749
+ const listed = await execGit(cwd, ['ls-files', '--others', '--exclude-standard', '--', filePath]);
5750
+ if (listed.code !== 0)
5751
+ return '';
5752
+ const chunks = [];
5753
+ let totalBytes = 0;
5754
+ for (const rawPath of listed.stdout.split('\n').map((p) => p.trim()).filter(Boolean)) {
5755
+ if (chunks.length >= MAX_UNTRACKED_DIFF_FILES || totalBytes >= MAX_UNTRACKED_DIFF_BYTES)
5756
+ break;
5757
+ const absPath = safeJoin(cwd, rawPath);
5758
+ if (!absPath || !existsSync(absPath))
5759
+ continue;
5760
+ const st = statSync(absPath);
5761
+ if (!st.isFile())
5762
+ continue;
5763
+ if (st.size > MAX_UNTRACKED_DIFF_BYTES) {
5764
+ chunks.push(formatBinaryAddedDiff(rawPath));
5765
+ continue;
5766
+ }
5767
+ const buf = readFileSync(absPath);
5768
+ if (buf.includes(0)) {
5769
+ chunks.push(formatBinaryAddedDiff(rawPath));
5770
+ continue;
5771
+ }
5772
+ totalBytes += buf.length;
5773
+ chunks.push(formatAddedFileDiff(rawPath, buf.toString('utf-8')));
5774
+ }
5775
+ if (chunks.length === 0)
5776
+ return '';
5777
+ const omitted = listed.stdout.split('\n').filter(Boolean).length - chunks.length;
5778
+ if (omitted > 0) {
5779
+ chunks.push(`diff --git a/${filePath} b/${filePath}\n# ${omitted} additional untracked file${omitted === 1 ? '' : 's'} omitted from preview`);
5780
+ }
5781
+ return chunks.join('\n');
5782
+ }
5783
+ function formatAddedFileDiff(filePath, content) {
5784
+ const normalized = content.replace(/\r\n/g, '\n');
5785
+ const hasFinalNewline = normalized.endsWith('\n');
5786
+ const lines = normalized.length === 0
5787
+ ? []
5788
+ : normalized.split('\n').slice(0, hasFinalNewline ? -1 : undefined);
5789
+ const hunk = lines.length > 0
5790
+ ? [`@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`)]
5791
+ : [];
5792
+ if (!hasFinalNewline && normalized.length > 0)
5793
+ hunk.push('\');
5794
+ return [
5795
+ `diff --git a/${filePath} b/${filePath}`,
5796
+ 'new file mode 100644',
5797
+ 'index 0000000..0000000',
5798
+ '--- /dev/null',
5799
+ `+++ b/${filePath}`,
5800
+ ...hunk,
5801
+ ].join('\n');
5802
+ }
5803
+ function formatBinaryAddedDiff(filePath) {
5804
+ return [
5805
+ `diff --git a/${filePath} b/${filePath}`,
5806
+ 'new file mode 100644',
5807
+ 'index 0000000..0000000',
5808
+ '--- /dev/null',
5809
+ `+++ b/${filePath}`,
5810
+ `Binary file ${filePath} added`,
5811
+ ].join('\n');
5225
5812
  }
5226
5813
  // ── git write operations ──────────────────────────────────────────────────
5227
5814
  // Each helper validates inputs, shells out via execFile (no shell expansion),
@@ -5251,89 +5838,118 @@ function gitErrorOutput(res) {
5251
5838
  return (res.stderr || res.stdout || '').trim();
5252
5839
  }
5253
5840
  async function gitStage(cwd, paths) {
5254
- const v = validatePaths(cwd, paths);
5841
+ const gitRoot = await resolveGitRoot(cwd);
5842
+ if (!gitRoot)
5843
+ return { ok: false, error: 'Not a git repository' };
5844
+ const v = validatePaths(gitRoot, paths);
5255
5845
  if (!v.ok)
5256
5846
  return { ok: false, error: v.error };
5257
- const res = await execGit(cwd, ['add', '--', ...v.paths]);
5847
+ const res = await execGit(gitRoot, ['add', '--', ...v.paths]);
5258
5848
  return res.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(res) };
5259
5849
  }
5260
5850
  async function gitUnstage(cwd, paths) {
5261
- const v = validatePaths(cwd, paths);
5851
+ const gitRoot = await resolveGitRoot(cwd);
5852
+ if (!gitRoot)
5853
+ return { ok: false, error: 'Not a git repository' };
5854
+ const v = validatePaths(gitRoot, paths);
5262
5855
  if (!v.ok)
5263
5856
  return { ok: false, error: v.error };
5264
5857
  // `restore --staged` works with or without HEAD; for an initial commit (no
5265
5858
  // HEAD yet) git's `rm --cached` is the fallback. Try restore first.
5266
- const res = await execGit(cwd, ['restore', '--staged', '--', ...v.paths]);
5859
+ const res = await execGit(gitRoot, ['restore', '--staged', '--', ...v.paths]);
5267
5860
  if (res.code === 0)
5268
5861
  return { ok: true };
5269
- const fallback = await execGit(cwd, ['rm', '--cached', '-r', '--', ...v.paths]);
5862
+ const fallback = await execGit(gitRoot, ['rm', '--cached', '-r', '--', ...v.paths]);
5270
5863
  return fallback.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(fallback) };
5271
5864
  }
5272
5865
  async function gitDiscard(cwd, paths) {
5273
- const v = validatePaths(cwd, paths);
5866
+ const gitRoot = await resolveGitRoot(cwd);
5867
+ if (!gitRoot)
5868
+ return { ok: false, error: 'Not a git repository' };
5869
+ const v = validatePaths(gitRoot, paths);
5274
5870
  if (!v.ok)
5275
5871
  return { ok: false, error: v.error };
5276
5872
  // For tracked files: `restore --worktree` reverts to HEAD. For untracked
5277
5873
  // files: that's a no-op and we delete them via `clean -f`. Run both so
5278
5874
  // the caller doesn't have to know which list each path is in.
5279
- const restore = await execGit(cwd, ['restore', '--worktree', '--', ...v.paths]);
5280
- const clean = await execGit(cwd, ['clean', '-f', '--', ...v.paths]);
5875
+ const restore = await execGit(gitRoot, ['restore', '--worktree', '--', ...v.paths]);
5876
+ const clean = await execGit(gitRoot, ['clean', '-f', '--', ...v.paths]);
5281
5877
  if (restore.code !== 0 && clean.code !== 0) {
5282
5878
  return { ok: false, error: gitErrorOutput(restore) || gitErrorOutput(clean) };
5283
5879
  }
5284
5880
  return { ok: true };
5285
5881
  }
5286
5882
  async function gitCommit(cwd, message, stageAll) {
5883
+ const gitRoot = await resolveGitRoot(cwd);
5884
+ if (!gitRoot)
5885
+ return { ok: false, error: 'Not a git repository' };
5287
5886
  const trimmed = message.trim();
5288
5887
  if (!trimmed)
5289
5888
  return { ok: false, error: 'Commit message required' };
5290
5889
  if (stageAll) {
5291
- const add = await execGit(cwd, ['add', '-A']);
5890
+ const add = await execGit(gitRoot, ['add', '-A']);
5292
5891
  if (add.code !== 0)
5293
5892
  return { ok: false, error: gitErrorOutput(add) };
5294
5893
  }
5295
- const res = await execGit(cwd, ['commit', '-m', trimmed]);
5894
+ const res = await execGit(gitRoot, ['commit', '-m', trimmed]);
5296
5895
  if (res.code !== 0)
5297
5896
  return { ok: false, error: gitErrorOutput(res) };
5298
- const hashRes = await execGit(cwd, ['rev-parse', 'HEAD']);
5897
+ const hashRes = await execGit(gitRoot, ['rev-parse', 'HEAD']);
5299
5898
  return { ok: true, hash: hashRes.code === 0 ? hashRes.stdout.trim() : undefined };
5300
5899
  }
5301
5900
  async function gitPush(cwd) {
5302
- const res = await execGit(cwd, ['push']);
5901
+ const gitRoot = await resolveGitRoot(cwd);
5902
+ if (!gitRoot)
5903
+ return { ok: false, error: 'Not a git repository' };
5904
+ const branch = await execGit(gitRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
5905
+ const upstream = await execGit(gitRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
5906
+ const remotes = await execGit(gitRoot, ['remote']);
5907
+ const remote = remotes.stdout.split('\n').map((s) => s.trim()).find(Boolean) ?? 'origin';
5908
+ const branchName = branch.code === 0 ? branch.stdout.trim() : '';
5909
+ const args = upstream.code === 0 || !branchName || branchName === 'HEAD'
5910
+ ? ['push']
5911
+ : ['push', '-u', remote, branchName];
5912
+ const res = await execGit(gitRoot, args);
5303
5913
  return res.code === 0
5304
5914
  ? { ok: true, output: gitErrorOutput(res) }
5305
5915
  : { ok: false, error: gitErrorOutput(res) };
5306
5916
  }
5307
5917
  async function gitPull(cwd) {
5918
+ const gitRoot = await resolveGitRoot(cwd);
5919
+ if (!gitRoot)
5920
+ return { ok: false, error: 'Not a git repository' };
5308
5921
  // `--ff-only` keeps the operation non-destructive: if the local branch has
5309
5922
  // diverged from upstream, we surface the error rather than auto-merging.
5310
5923
  // The user can resolve via the terminal or a future merge UI.
5311
- const res = await execGit(cwd, ['pull', '--ff-only']);
5924
+ const res = await execGit(gitRoot, ['pull', '--ff-only']);
5312
5925
  return res.code === 0
5313
5926
  ? { ok: true, output: gitErrorOutput(res) }
5314
5927
  : { ok: false, error: gitErrorOutput(res) };
5315
5928
  }
5316
5929
  async function readGitBranches(cwd) {
5317
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5318
- if (isRepo.code !== 0)
5930
+ const gitRoot = await resolveGitRoot(cwd);
5931
+ if (!gitRoot)
5319
5932
  return { inRepo: false, current: null, branches: [] };
5320
- const cur = await execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
5321
- const list = await execGit(cwd, ['branch', '--list', '--format=%(refname:short)']);
5933
+ const cur = await execGit(gitRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
5934
+ const list = await execGit(gitRoot, ['branch', '--list', '--format=%(refname:short)']);
5322
5935
  const branches = list.code === 0
5323
5936
  ? list.stdout.split('\n').map((s) => s.trim()).filter(Boolean)
5324
5937
  : [];
5325
5938
  return { inRepo: true, current: cur.code === 0 ? cur.stdout.trim() : null, branches };
5326
5939
  }
5327
5940
  async function readGitRemote(cwd) {
5328
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5329
- if (isRepo.code !== 0)
5941
+ const gitRoot = await resolveGitRoot(cwd);
5942
+ if (!gitRoot)
5330
5943
  return { inRepo: false, url: null, name: null };
5331
- const remoteName = await execGit(cwd, ['config', '--get', 'remote.pushDefault']);
5944
+ const remoteName = await execGit(gitRoot, ['config', '--get', 'remote.pushDefault']);
5332
5945
  const name = remoteName.code === 0 && remoteName.stdout.trim() ? remoteName.stdout.trim() : 'origin';
5333
- const url = await execGit(cwd, ['remote', 'get-url', name]);
5946
+ const url = await execGit(gitRoot, ['remote', 'get-url', name]);
5334
5947
  return { inRepo: true, url: url.code === 0 ? url.stdout.trim() : null, name };
5335
5948
  }
5336
5949
  async function gitCreateBranch(cwd, name, checkout) {
5950
+ const gitRoot = await resolveGitRoot(cwd);
5951
+ if (!gitRoot)
5952
+ return { ok: false, error: 'Not a git repository' };
5337
5953
  const trimmed = name.trim();
5338
5954
  // Branch names can't start with `-` (would be parsed as a flag) and must be
5339
5955
  // non-empty. git itself enforces the rest of the ref-name rules.
@@ -5342,17 +5958,20 @@ async function gitCreateBranch(cwd, name, checkout) {
5342
5958
  if (trimmed.startsWith('-'))
5343
5959
  return { ok: false, error: 'Invalid branch name' };
5344
5960
  const res = checkout
5345
- ? await execGit(cwd, ['checkout', '-b', trimmed])
5346
- : await execGit(cwd, ['branch', trimmed]);
5961
+ ? await execGit(gitRoot, ['checkout', '-b', trimmed])
5962
+ : await execGit(gitRoot, ['branch', trimmed]);
5347
5963
  return res.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(res) };
5348
5964
  }
5349
5965
  async function gitCheckout(cwd, name) {
5966
+ const gitRoot = await resolveGitRoot(cwd);
5967
+ if (!gitRoot)
5968
+ return { ok: false, error: 'Not a git repository' };
5350
5969
  const trimmed = name.trim();
5351
5970
  if (!trimmed)
5352
5971
  return { ok: false, error: 'Branch name required' };
5353
5972
  if (trimmed.startsWith('-'))
5354
5973
  return { ok: false, error: 'Invalid branch name' };
5355
- const res = await execGit(cwd, ['checkout', trimmed]);
5974
+ const res = await execGit(gitRoot, ['checkout', trimmed]);
5356
5975
  return res.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(res) };
5357
5976
  }
5358
5977
  async function readHeadBlob(cwd, filePath) {
@@ -5482,6 +6101,92 @@ function isAiPinRefreshDue(lastRefreshedAt) {
5482
6101
  return true;
5483
6102
  return Date.now() - last >= 24 * 60 * 60 * 1000;
5484
6103
  }
6104
+ function buildAgentSchemaContext(question, rows) {
6105
+ const byRelation = new Map();
6106
+ for (const row of rows) {
6107
+ if (!row || typeof row !== 'object')
6108
+ continue;
6109
+ const record = row;
6110
+ const schema = stringFromRecord(record, 'table_schema');
6111
+ const table = stringFromRecord(record, 'table_name');
6112
+ const column = stringFromRecord(record, 'column_name');
6113
+ if (!schema || !table || !column)
6114
+ continue;
6115
+ const relation = `${schema}.${table}`;
6116
+ const current = byRelation.get(relation) ?? {
6117
+ relation,
6118
+ schema,
6119
+ name: table,
6120
+ source: 'runtime information_schema',
6121
+ columns: [],
6122
+ };
6123
+ if (current.columns.length < 80) {
6124
+ current.columns.push({
6125
+ name: column,
6126
+ type: stringFromRecord(record, 'data_type'),
6127
+ });
6128
+ }
6129
+ byRelation.set(relation, current);
6130
+ }
6131
+ const tokens = agentSchemaTokens(question);
6132
+ return Array.from(byRelation.values())
6133
+ .map((table) => ({ table, score: scoreAgentSchemaTable(table, tokens) }))
6134
+ .filter((entry) => entry.score > 0)
6135
+ .sort((a, b) => b.score - a.score || a.table.relation.localeCompare(b.table.relation))
6136
+ .slice(0, 12)
6137
+ .map((entry) => entry.table);
6138
+ }
6139
+ function scoreAgentSchemaTable(table, tokens) {
6140
+ let score = 0;
6141
+ const relationTokens = agentSchemaTokens(`${table.schema ?? ''} ${table.name} ${table.relation}`);
6142
+ for (const token of tokens) {
6143
+ if (relationTokens.has(token))
6144
+ score += 8;
6145
+ }
6146
+ for (const column of table.columns) {
6147
+ const columnTokens = agentSchemaTokens(column.name);
6148
+ for (const token of tokens) {
6149
+ if (columnTokens.has(token))
6150
+ score += 3;
6151
+ }
6152
+ }
6153
+ if (/(customer|order|revenue|product|location|date|month)/i.test(table.name))
6154
+ score += 1;
6155
+ return score;
6156
+ }
6157
+ function agentSchemaTokens(value) {
6158
+ const tokens = new Set();
6159
+ for (const raw of value.toLowerCase().match(/[a-z0-9_]+/g) ?? []) {
6160
+ for (const part of raw.split('_')) {
6161
+ const normalized = normalizeAgentSchemaToken(part);
6162
+ if (!normalized || normalized.length < 3 || AGENT_SCHEMA_STOPWORDS.has(normalized))
6163
+ continue;
6164
+ tokens.add(normalized);
6165
+ }
6166
+ }
6167
+ return tokens;
6168
+ }
6169
+ const AGENT_SCHEMA_STOPWORDS = new Set([
6170
+ 'all', 'and', 'are', 'can', 'data', 'for', 'from', 'have', 'how', 'many', 'me',
6171
+ 'show', 'the', 'this', 'who', 'with', 'value',
6172
+ ]);
6173
+ function normalizeAgentSchemaToken(token) {
6174
+ if (token === 'orders')
6175
+ return 'order';
6176
+ if (token === 'customers')
6177
+ return 'customer';
6178
+ if (token === 'products')
6179
+ return 'product';
6180
+ if (token.endsWith('ies') && token.length > 4)
6181
+ return `${token.slice(0, -3)}y`;
6182
+ if (token.endsWith('s') && token.length > 4)
6183
+ return token.slice(0, -1);
6184
+ return token;
6185
+ }
6186
+ function stringFromRecord(record, key) {
6187
+ const value = record[key];
6188
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
6189
+ }
5485
6190
  function isMemoryScope(value) {
5486
6191
  return value === 'thread'
5487
6192
  || value === 'notebook'