@duckcodeailabs/dql-cli 1.6.1 → 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 (72) hide show
  1. package/dist/apps-api.d.ts +20 -0
  2. package/dist/apps-api.d.ts.map +1 -1
  3. package/dist/apps-api.js +71 -0
  4. package/dist/apps-api.js.map +1 -1
  5. package/dist/apps-api.test.js +43 -1
  6. package/dist/apps-api.test.js.map +1 -1
  7. package/dist/args.d.ts +2 -0
  8. package/dist/args.d.ts.map +1 -1
  9. package/dist/args.js +3 -0
  10. package/dist/args.js.map +1 -1
  11. package/dist/assets/dql-notebook/assets/index-60sOoPrg.js +3599 -0
  12. package/dist/assets/dql-notebook/assets/index-RaDW1A5g.css +1 -0
  13. package/dist/assets/dql-notebook/index.html +3 -3
  14. package/dist/commands/app.d.ts +4 -3
  15. package/dist/commands/app.d.ts.map +1 -1
  16. package/dist/commands/app.js +161 -75
  17. package/dist/commands/app.js.map +1 -1
  18. package/dist/commands/build.d.ts.map +1 -1
  19. package/dist/commands/build.js +7 -1
  20. package/dist/commands/build.js.map +1 -1
  21. package/dist/commands/compile.d.ts +1 -1
  22. package/dist/commands/compile.d.ts.map +1 -1
  23. package/dist/commands/compile.js +40 -4
  24. package/dist/commands/compile.js.map +1 -1
  25. package/dist/commands/init.d.ts.map +1 -1
  26. package/dist/commands/init.js +3 -1
  27. package/dist/commands/init.js.map +1 -1
  28. package/dist/commands/init.test.js +4 -0
  29. package/dist/commands/init.test.js.map +1 -1
  30. package/dist/commands/lineage.d.ts +3 -0
  31. package/dist/commands/lineage.d.ts.map +1 -1
  32. package/dist/commands/lineage.js +230 -2
  33. package/dist/commands/lineage.js.map +1 -1
  34. package/dist/commands/new.js +61 -2
  35. package/dist/commands/new.js.map +1 -1
  36. package/dist/commands/new.test.js +106 -0
  37. package/dist/commands/new.test.js.map +1 -1
  38. package/dist/commands/parse.d.ts.map +1 -1
  39. package/dist/commands/parse.js +13 -3
  40. package/dist/commands/parse.js.map +1 -1
  41. package/dist/commands/preview.d.ts.map +1 -1
  42. package/dist/commands/preview.js +7 -1
  43. package/dist/commands/preview.js.map +1 -1
  44. package/dist/commands/validate.d.ts.map +1 -1
  45. package/dist/commands/validate.js +49 -3
  46. package/dist/commands/validate.js.map +1 -1
  47. package/dist/commands/validate.test.js +85 -0
  48. package/dist/commands/validate.test.js.map +1 -1
  49. package/dist/commands/verify.d.ts.map +1 -1
  50. package/dist/commands/verify.js +6 -2
  51. package/dist/commands/verify.js.map +1 -1
  52. package/dist/index.js +79 -68
  53. package/dist/index.js.map +1 -1
  54. package/dist/llm/providers/dql-agent-provider.d.ts.map +1 -1
  55. package/dist/llm/providers/dql-agent-provider.js +95 -19
  56. package/dist/llm/providers/dql-agent-provider.js.map +1 -1
  57. package/dist/llm/tools.d.ts.map +1 -1
  58. package/dist/llm/tools.js +29 -1
  59. package/dist/llm/tools.js.map +1 -1
  60. package/dist/llm/types.d.ts +3 -1
  61. package/dist/llm/types.d.ts.map +1 -1
  62. package/dist/local-runtime.d.ts +12 -0
  63. package/dist/local-runtime.d.ts.map +1 -1
  64. package/dist/local-runtime.js +712 -46
  65. package/dist/local-runtime.js.map +1 -1
  66. package/dist/local-runtime.test.js +64 -1
  67. package/dist/local-runtime.test.js.map +1 -1
  68. package/dist/template-adoption.test.js +3 -0
  69. package/dist/template-adoption.test.js.map +1 -1
  70. package/package.json +10 -10
  71. package/dist/assets/dql-notebook/assets/index-C-s-OCLW.css +0 -1
  72. package/dist/assets/dql-notebook/assets/index-DZ_5zsCw.js +0 -869
@@ -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';
@@ -213,6 +214,37 @@ export async function startLocalServer(opts) {
213
214
  blockPath: block.filePath,
214
215
  };
215
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
+ };
216
248
  // SSE clients for /api/watch hot-reload
217
249
  const sseClients = new Set();
218
250
  // Watch notebooks/, workbooks/, semantic-layer/, and data/ dirs for changes
@@ -1777,8 +1809,9 @@ export async function startLocalServer(opts) {
1777
1809
  const defaultKey = raw.defaultConnection
1778
1810
  ? 'default'
1779
1811
  : Object.keys(connections)[0] ?? 'default';
1812
+ const dbtProfiles = discoverDbtProfileConnections(projectRoot, cfg);
1780
1813
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1781
- res.end(serializeJSON({ default: defaultKey, connections }));
1814
+ res.end(serializeJSON({ default: defaultKey, connections, dbtProfiles }));
1782
1815
  return;
1783
1816
  }
1784
1817
  // Save/update connections
@@ -2495,7 +2528,15 @@ export async function startLocalServer(opts) {
2495
2528
  req.on('close', () => controller.abort());
2496
2529
  const emit = (turn) => { res.write(`data: ${JSON.stringify(turn)}\n\n`); };
2497
2530
  try {
2498
- 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);
2499
2540
  }
2500
2541
  catch (err) {
2501
2542
  emit({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
@@ -3328,6 +3369,7 @@ export function loadProjectConfig(projectRoot) {
3328
3369
  // Support both `filepath` (correct) and `path` (legacy/init compat)
3329
3370
  const filepath = (defaultConn.filepath ?? defaultConn.path);
3330
3371
  config.defaultConnection = {
3372
+ ...defaultConn,
3331
3373
  driver: defaultConn.driver,
3332
3374
  ...(filepath ? { filepath } : {}),
3333
3375
  };
@@ -3344,6 +3386,100 @@ export function prepareLocalExecution(sql, connection, projectRoot, projectConfi
3344
3386
  connection: normalizedConnection,
3345
3387
  };
3346
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
+ }
3347
3483
  /**
3348
3484
  * Shared resolver for `@metric(name)` / `@dim(name)` refs in raw SQL.
3349
3485
  * Used by notebook SQL execution and Block Studio validation so both paths
@@ -3364,7 +3500,7 @@ export function prepareSemanticSql(sql, semanticLayer) {
3364
3500
  };
3365
3501
  }
3366
3502
  export function normalizeProjectConnection(connection, projectRoot) {
3367
- const normalized = { ...connection };
3503
+ const normalized = expandConnectionEnvPlaceholders({ ...connection });
3368
3504
  if ((normalized.driver === 'file' || normalized.driver === 'duckdb') && normalized.filepath && normalized.filepath !== ':memory:' && !isAbsoluteLikePath(normalized.filepath)) {
3369
3505
  normalized.filepath = resolve(projectRoot, normalized.filepath);
3370
3506
  }
@@ -3373,6 +3509,15 @@ export function normalizeProjectConnection(connection, projectRoot) {
3373
3509
  }
3374
3510
  return normalized;
3375
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
+ }
3376
3521
  export function resolveProjectRelativeSqlPaths(sql, projectRoot, dataDir) {
3377
3522
  const resolvedRoot = resolve(projectRoot);
3378
3523
  const normalizedDataDir = typeof dataDir === 'string' && dataDir.trim().length > 0
@@ -3515,6 +3660,8 @@ function scanNotebookFiles(projectRoot) {
3515
3660
  workbooks: 'workbook',
3516
3661
  blocks: 'block',
3517
3662
  dashboards: 'dashboard',
3663
+ terms: 'term',
3664
+ 'business-views': 'business_view',
3518
3665
  };
3519
3666
  for (const [folder, type] of Object.entries(folderMap)) {
3520
3667
  const dir = join(projectRoot, folder);
@@ -3536,8 +3683,9 @@ function scanNotebookFiles(projectRoot) {
3536
3683
  continue;
3537
3684
  if (!entry.name.endsWith('.dql') && !entry.name.endsWith('.dqlnb'))
3538
3685
  continue;
3686
+ const fallbackName = entry.name.replace(/\.(dql|dqlnb)$/, '');
3539
3687
  result.push({
3540
- name: entry.name.replace(/\.(dql|dqlnb)$/, ''),
3688
+ name: inferDqlArtifactName(fullPath, type, fallbackName),
3541
3689
  path: relativePath,
3542
3690
  type,
3543
3691
  folder: relativeDir.split('/')[0] ?? relativeDir,
@@ -3547,6 +3695,27 @@ function scanNotebookFiles(projectRoot) {
3547
3695
  catch { /* skip unreadable dirs */ }
3548
3696
  }
3549
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
+ }
3550
3719
  function scanDataFiles(projectRoot) {
3551
3720
  const dataDir = join(projectRoot, 'data');
3552
3721
  if (!existsSync(dataDir))
@@ -4984,7 +5153,51 @@ function resolveDbtManifestPath(projectRoot) {
4984
5153
  const candidate = join(projectRoot, 'target', 'manifest.json');
4985
5154
  return existsSync(candidate) ? candidate : undefined;
4986
5155
  }
4987
- 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) {
4988
5201
  const configuredDbtDir = projectConfig.dbt?.projectDir
4989
5202
  ? resolve(projectRoot, projectConfig.dbt.projectDir)
4990
5203
  : undefined;
@@ -4999,7 +5212,259 @@ export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
4999
5212
  resolve(projectRoot, '../dbt'),
5000
5213
  resolve(projectRoot, '../../dbt'),
5001
5214
  ].filter((value) => Boolean(value));
5002
- 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);
5003
5468
  const configuredManifest = projectConfig.dbt?.manifestPath ?? 'target/manifest.json';
5004
5469
  const manifestPath = resolve(dbtProjectPath, configuredManifest);
5005
5470
  const catalogPath = resolve(dbtProjectPath, 'target/catalog.json');
@@ -5029,7 +5494,9 @@ export function buildDbtStatus(projectRoot, projectConfig, lastSyncTime) {
5029
5494
  const savedQueryCount = Array.isArray(semanticManifest?.saved_queries)
5030
5495
  ? semanticManifest.saved_queries.length
5031
5496
  : 0;
5032
- 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);
5033
5500
  const manifestExists = existsSync(manifestPath);
5034
5501
  const semanticExists = existsSync(semanticManifestPath);
5035
5502
  const setupHint = !configured
@@ -5146,14 +5613,21 @@ async function execGit(cwd, args) {
5146
5613
  });
5147
5614
  });
5148
5615
  }
5149
- async function readGitStatus(cwd) {
5616
+ async function resolveGitRoot(cwd) {
5150
5617
  const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5151
- 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) {
5152
5626
  return { inRepo: false, branch: null, ahead: 0, behind: 0, changes: [] };
5153
5627
  }
5154
- const branchRes = await execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
5628
+ const branchRes = await execGit(gitRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
5155
5629
  const branch = branchRes.code === 0 ? branchRes.stdout.trim() : null;
5156
- 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']);
5157
5631
  let ahead = 0;
5158
5632
  let behind = 0;
5159
5633
  if (trackRes.code === 0) {
@@ -5161,7 +5635,7 @@ async function readGitStatus(cwd) {
5161
5635
  behind = Number(match[0] ?? 0);
5162
5636
  ahead = Number(match[1] ?? 0);
5163
5637
  }
5164
- const statusRes = await execGit(cwd, ['status', '--porcelain=v1', '--untracked-files=normal']);
5638
+ const statusRes = await execGit(gitRoot, ['status', '--porcelain=v1', '--untracked-files=normal']);
5165
5639
  const changes = [];
5166
5640
  if (statusRes.code === 0) {
5167
5641
  for (const line of statusRes.stdout.split('\n')) {
@@ -5175,13 +5649,13 @@ async function readGitStatus(cwd) {
5175
5649
  return { inRepo: true, branch, ahead, behind, changes };
5176
5650
  }
5177
5651
  async function readGitLog(cwd, limit) {
5178
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5179
- if (isRepo.code !== 0)
5652
+ const gitRoot = await resolveGitRoot(cwd);
5653
+ if (!gitRoot)
5180
5654
  return { inRepo: false, commits: [] };
5181
5655
  const sep = '\x1f';
5182
5656
  const end = '\x1e';
5183
5657
  const fmt = ['%H', '%an', '%ad', '%s'].join(sep) + end;
5184
- 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']);
5185
5659
  if (res.code !== 0)
5186
5660
  return { inRepo: true, commits: [] };
5187
5661
  const commits = [];
@@ -5244,23 +5718,97 @@ function ensureGitignoreEntry(projectRoot, pattern) {
5244
5718
  }
5245
5719
  }
5246
5720
  async function readGitDiff(cwd, filePath, staged = false) {
5247
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5248
- if (isRepo.code !== 0) {
5721
+ const gitRoot = await resolveGitRoot(cwd);
5722
+ if (!gitRoot) {
5249
5723
  return { inRepo: false, diff: '', before: null, after: null, diffReport: null };
5250
5724
  }
5251
5725
  const baseArgs = staged ? ['diff', '--cached', '--no-color'] : ['diff', '--no-color'];
5252
5726
  if (!filePath) {
5253
- const res = await execGit(cwd, baseArgs);
5727
+ const res = await execGit(gitRoot, baseArgs);
5254
5728
  return { inRepo: true, diff: res.stdout, before: null, after: null, diffReport: null };
5255
5729
  }
5256
5730
  const isSemantic = filePath.endsWith('.dql') || filePath.endsWith('.dqlnb');
5257
5731
  const [diffRes, before, after] = await Promise.all([
5258
- execGit(cwd, [...baseArgs, '--', filePath]),
5259
- isSemantic ? readHeadBlob(cwd, filePath) : Promise.resolve(null),
5260
- 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),
5261
5735
  ]);
5736
+ const diffText = !staged && !diffRes.stdout.trim()
5737
+ ? (await readUntrackedTextDiff(gitRoot, filePath)) || diffRes.stdout
5738
+ : diffRes.stdout;
5262
5739
  const diffReport = isSemantic ? computeSemanticDiff(filePath, before, after) : null;
5263
- 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');
5264
5812
  }
5265
5813
  // ── git write operations ──────────────────────────────────────────────────
5266
5814
  // Each helper validates inputs, shells out via execFile (no shell expansion),
@@ -5290,89 +5838,118 @@ function gitErrorOutput(res) {
5290
5838
  return (res.stderr || res.stdout || '').trim();
5291
5839
  }
5292
5840
  async function gitStage(cwd, paths) {
5293
- 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);
5294
5845
  if (!v.ok)
5295
5846
  return { ok: false, error: v.error };
5296
- const res = await execGit(cwd, ['add', '--', ...v.paths]);
5847
+ const res = await execGit(gitRoot, ['add', '--', ...v.paths]);
5297
5848
  return res.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(res) };
5298
5849
  }
5299
5850
  async function gitUnstage(cwd, paths) {
5300
- 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);
5301
5855
  if (!v.ok)
5302
5856
  return { ok: false, error: v.error };
5303
5857
  // `restore --staged` works with or without HEAD; for an initial commit (no
5304
5858
  // HEAD yet) git's `rm --cached` is the fallback. Try restore first.
5305
- const res = await execGit(cwd, ['restore', '--staged', '--', ...v.paths]);
5859
+ const res = await execGit(gitRoot, ['restore', '--staged', '--', ...v.paths]);
5306
5860
  if (res.code === 0)
5307
5861
  return { ok: true };
5308
- const fallback = await execGit(cwd, ['rm', '--cached', '-r', '--', ...v.paths]);
5862
+ const fallback = await execGit(gitRoot, ['rm', '--cached', '-r', '--', ...v.paths]);
5309
5863
  return fallback.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(fallback) };
5310
5864
  }
5311
5865
  async function gitDiscard(cwd, paths) {
5312
- 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);
5313
5870
  if (!v.ok)
5314
5871
  return { ok: false, error: v.error };
5315
5872
  // For tracked files: `restore --worktree` reverts to HEAD. For untracked
5316
5873
  // files: that's a no-op and we delete them via `clean -f`. Run both so
5317
5874
  // the caller doesn't have to know which list each path is in.
5318
- const restore = await execGit(cwd, ['restore', '--worktree', '--', ...v.paths]);
5319
- 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]);
5320
5877
  if (restore.code !== 0 && clean.code !== 0) {
5321
5878
  return { ok: false, error: gitErrorOutput(restore) || gitErrorOutput(clean) };
5322
5879
  }
5323
5880
  return { ok: true };
5324
5881
  }
5325
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' };
5326
5886
  const trimmed = message.trim();
5327
5887
  if (!trimmed)
5328
5888
  return { ok: false, error: 'Commit message required' };
5329
5889
  if (stageAll) {
5330
- const add = await execGit(cwd, ['add', '-A']);
5890
+ const add = await execGit(gitRoot, ['add', '-A']);
5331
5891
  if (add.code !== 0)
5332
5892
  return { ok: false, error: gitErrorOutput(add) };
5333
5893
  }
5334
- const res = await execGit(cwd, ['commit', '-m', trimmed]);
5894
+ const res = await execGit(gitRoot, ['commit', '-m', trimmed]);
5335
5895
  if (res.code !== 0)
5336
5896
  return { ok: false, error: gitErrorOutput(res) };
5337
- const hashRes = await execGit(cwd, ['rev-parse', 'HEAD']);
5897
+ const hashRes = await execGit(gitRoot, ['rev-parse', 'HEAD']);
5338
5898
  return { ok: true, hash: hashRes.code === 0 ? hashRes.stdout.trim() : undefined };
5339
5899
  }
5340
5900
  async function gitPush(cwd) {
5341
- 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);
5342
5913
  return res.code === 0
5343
5914
  ? { ok: true, output: gitErrorOutput(res) }
5344
5915
  : { ok: false, error: gitErrorOutput(res) };
5345
5916
  }
5346
5917
  async function gitPull(cwd) {
5918
+ const gitRoot = await resolveGitRoot(cwd);
5919
+ if (!gitRoot)
5920
+ return { ok: false, error: 'Not a git repository' };
5347
5921
  // `--ff-only` keeps the operation non-destructive: if the local branch has
5348
5922
  // diverged from upstream, we surface the error rather than auto-merging.
5349
5923
  // The user can resolve via the terminal or a future merge UI.
5350
- const res = await execGit(cwd, ['pull', '--ff-only']);
5924
+ const res = await execGit(gitRoot, ['pull', '--ff-only']);
5351
5925
  return res.code === 0
5352
5926
  ? { ok: true, output: gitErrorOutput(res) }
5353
5927
  : { ok: false, error: gitErrorOutput(res) };
5354
5928
  }
5355
5929
  async function readGitBranches(cwd) {
5356
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5357
- if (isRepo.code !== 0)
5930
+ const gitRoot = await resolveGitRoot(cwd);
5931
+ if (!gitRoot)
5358
5932
  return { inRepo: false, current: null, branches: [] };
5359
- const cur = await execGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
5360
- 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)']);
5361
5935
  const branches = list.code === 0
5362
5936
  ? list.stdout.split('\n').map((s) => s.trim()).filter(Boolean)
5363
5937
  : [];
5364
5938
  return { inRepo: true, current: cur.code === 0 ? cur.stdout.trim() : null, branches };
5365
5939
  }
5366
5940
  async function readGitRemote(cwd) {
5367
- const isRepo = await execGit(cwd, ['rev-parse', '--is-inside-work-tree']);
5368
- if (isRepo.code !== 0)
5941
+ const gitRoot = await resolveGitRoot(cwd);
5942
+ if (!gitRoot)
5369
5943
  return { inRepo: false, url: null, name: null };
5370
- const remoteName = await execGit(cwd, ['config', '--get', 'remote.pushDefault']);
5944
+ const remoteName = await execGit(gitRoot, ['config', '--get', 'remote.pushDefault']);
5371
5945
  const name = remoteName.code === 0 && remoteName.stdout.trim() ? remoteName.stdout.trim() : 'origin';
5372
- const url = await execGit(cwd, ['remote', 'get-url', name]);
5946
+ const url = await execGit(gitRoot, ['remote', 'get-url', name]);
5373
5947
  return { inRepo: true, url: url.code === 0 ? url.stdout.trim() : null, name };
5374
5948
  }
5375
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' };
5376
5953
  const trimmed = name.trim();
5377
5954
  // Branch names can't start with `-` (would be parsed as a flag) and must be
5378
5955
  // non-empty. git itself enforces the rest of the ref-name rules.
@@ -5381,17 +5958,20 @@ async function gitCreateBranch(cwd, name, checkout) {
5381
5958
  if (trimmed.startsWith('-'))
5382
5959
  return { ok: false, error: 'Invalid branch name' };
5383
5960
  const res = checkout
5384
- ? await execGit(cwd, ['checkout', '-b', trimmed])
5385
- : await execGit(cwd, ['branch', trimmed]);
5961
+ ? await execGit(gitRoot, ['checkout', '-b', trimmed])
5962
+ : await execGit(gitRoot, ['branch', trimmed]);
5386
5963
  return res.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(res) };
5387
5964
  }
5388
5965
  async function gitCheckout(cwd, name) {
5966
+ const gitRoot = await resolveGitRoot(cwd);
5967
+ if (!gitRoot)
5968
+ return { ok: false, error: 'Not a git repository' };
5389
5969
  const trimmed = name.trim();
5390
5970
  if (!trimmed)
5391
5971
  return { ok: false, error: 'Branch name required' };
5392
5972
  if (trimmed.startsWith('-'))
5393
5973
  return { ok: false, error: 'Invalid branch name' };
5394
- const res = await execGit(cwd, ['checkout', trimmed]);
5974
+ const res = await execGit(gitRoot, ['checkout', trimmed]);
5395
5975
  return res.code === 0 ? { ok: true } : { ok: false, error: gitErrorOutput(res) };
5396
5976
  }
5397
5977
  async function readHeadBlob(cwd, filePath) {
@@ -5521,6 +6101,92 @@ function isAiPinRefreshDue(lastRefreshedAt) {
5521
6101
  return true;
5522
6102
  return Date.now() - last >= 24 * 60 * 60 * 1000;
5523
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
+ }
5524
6190
  function isMemoryScope(value) {
5525
6191
  return value === 'thread'
5526
6192
  || value === 'notebook'