diff --git a/.gitignore b/.gitignore index 4baea79..02455f1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist/ .claude/ .cursor/ .obsidian/ +CLAUDE.md --- # Keep lockfile out of the repo — biome's optionalDependencies need to # resolve at install time per platform, and a macOS-generated lockfile diff --git a/README.md b/README.md index 7f0563c..589bd67 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,24 @@ Installed by `buron setup` into every editor folder you select. | `buron file move ` | Move or rename a file | | `buron file replace ` | Find and replace text in a file | +#### Data + +You query by picking metrics, dimensions, and filters from a dataset — there is no query language. Add `--json` to any read command for pipeable output. + +| Command | Purpose | +|---------|---------| +| `buron datasets list` | List datasets this team can query, with availability | +| `buron datasets describe ` | Show a dataset's metrics, dimensions, and filter operators | +| `buron datasets query ` | Run a query (`-m` measures, `-d` dimensions, `-f field:op:value`, `--from/--to/--granularity`, `--sort`, `--limit`, or `--spec `) | +| `buron sql ` | Read-only SELECT against your warehouse (escape hatch; prefer `datasets query`) | +| `buron queries list` | List saved queries (`--dataset ` to filter) | +| `buron queries create --spec ` | Save a structured query from a SemanticQuery spec | +| `buron queries run ` | Run a saved query and return fresh results | +| `buron dashboards list` | List available dashboards | +| `buron dashboards run --from --to` | Run a dashboard for a date range | +| `buron dashboards add ` | Add a saved query as a tile on a custom dashboard | +| `buron integration ` | Check an integration's connection status | + #### Skills | Command | Purpose | @@ -113,10 +131,14 @@ buron file read /wiki/launches/2025-05-onboarding-flow/blog.md \ buron file read /wiki/launches/2025-05-onboarding-flow/changelog.md \ > CHANGELOG.md -# Check which ad creatives are performing best before a redesign -buron query "SELECT ad_group_ad.ad.name, metrics.conversions, metrics.cost_micros - FROM ad_group_ad WHERE segments.date DURING LAST_30_DAYS - ORDER BY metrics.conversions DESC LIMIT 10" --source gaql +# Discover what data you can query, then pick fields — no query language +buron datasets list +buron datasets describe cross-channel-ads + +# Which ad creatives are performing best before a redesign +buron datasets query cross-channel-ads \ + -m cost,conversions -d ad_name \ + --from 2026-06-01 --to 2026-06-30 --sort conversions:desc --limit 10 # See what competitors are doing buron file grep "positioning" --directory /wiki/entities/competitors/ diff --git a/package.json b/package.json index 5f2201e..cdf5586 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@buron/cli", - "version": "0.2.5", + "version": "0.3.0", "description": "Turn code changes into launch-ready marketing. Capture what you ship, and let Buron's agents generate content, update campaigns, and track performance.", "type": "module", "bin": { diff --git a/src/bin.ts b/src/bin.ts index 9b55095..f5422d1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,12 +1,16 @@ import { Command } from "commander"; import { + dashboardsAddCommand, dashboardsListCommand, dashboardsRunCommand, + datasetsDescribeCommand, + datasetsListCommand, + datasetsQueryCommand, integrationCommand, queriesCreateCommand, queriesListCommand, queriesRunCommand, - queryCommand, + sqlCommand, } from "./commands/data.js"; import { fileAppendCommand, @@ -107,15 +111,47 @@ file .option("-a, --all", "Replace all occurrences (default: first only)") .action(fileReplaceCommand); -// ── query ─────────────────────────────────────────────────────────── +// ── datasets ──────────────────────────────────────────────────────── -program - .command("query ") - .description("Execute an ad-hoc query against a connected data source") - .requiredOption("-s, --source ", "Query source (e.g. gaql)") +function collect(value: string, previous: string[]): string[] { + return previous.concat([value]); +} + +const datasets = program.command("datasets").description("Discover and query your data"); + +datasets + .command("list") + .description("List datasets this team can query, with availability") + .option("--json", "Output raw JSON") + .action(datasetsListCommand); + +datasets + .command("describe ") + .description("Show a dataset's metrics, dimensions, and filter operators") + .action(datasetsDescribeCommand); + +datasets + .command("query ") + .description("Run a query by picking measures, dimensions, and filters — no SQL") + .option("-m, --measures ", "Metric field keys (comma-separated)") + .option("-d, --dimensions ", "Dimension field keys (comma-separated)") + .option("-f, --filter ", "Filter (repeatable)", collect, []) .option("--from ", "Start date (YYYY-MM-DD)") .option("--to ", "End date (YYYY-MM-DD)") - .action(queryCommand); + .option("--granularity ", "day|week|month|quarter|year") + .option("--sort ", "Sort (repeatable)", collect, []) + .option("--limit ", "Max rows") + .option("--spec ", "Full SemanticQuery JSON (overrides flags)") + .option("--json", "Output raw JSON") + .action(datasetsQueryCommand); + +// ── sql (warehouse escape hatch) ──────────────────────────────────── + +program + .command("sql ") + .description("Run a read-only SELECT against your warehouse (prefer datasets query)") + .option("--json", "Output raw JSON") + .action(sqlCommand); // ── queries ───────────────────────────────────────────────────────── @@ -124,27 +160,32 @@ const queries = program.command("queries").description("Manage saved queries"); queries .command("list") .description("List saved queries") - .option("-s, --source ", "Filter by source") + .option("--dataset ", "Filter by dataset id") + .option("--json", "Output raw JSON") .action(queriesListCommand); queries .command("create ") - .description("Create a saved query") - .requiredOption("-s, --source ", "Query source (e.g. gaql)") - .requiredOption("-q, --query ", "The query string") - .option("--config ", "Chart/display config as JSON") + .description("Save a structured query from a SemanticQuery spec") + .requiredOption("--spec ", "SemanticQuery JSON (dataset + picks)") + .option("--chart ", "table|line|bar|area|donut") .action(queriesCreateCommand); queries .command("run ") .description("Execute a saved query and return fresh results") + .option("--json", "Output raw JSON") .action(queriesRunCommand); // ── dashboards ────────────────────────────────────────────────────── const dashboards = program.command("dashboards").description("View and run dashboards"); -dashboards.command("list").description("List available dashboards").action(dashboardsListCommand); +dashboards + .command("list") + .description("List available dashboards") + .option("--json", "Output raw JSON") + .action(dashboardsListCommand); dashboards .command("run ") @@ -154,6 +195,11 @@ dashboards .option("--fresh", "Bypass cache") .action(dashboardsRunCommand); +dashboards + .command("add ") + .description("Add a saved query as a tile on a custom dashboard") + .action(dashboardsAddCommand); + // ── integration ───────────────────────────────────────────────────── program diff --git a/src/commands/data.ts b/src/commands/data.ts index e29820e..391431a 100644 --- a/src/commands/data.ts +++ b/src/commands/data.ts @@ -1,4 +1,4 @@ -import { api } from "../lib/api.js"; +import { api, type SemanticQuery } from "../lib/api.js"; import { requireAuth } from "../lib/auth.js"; import { requireConfig } from "../lib/config.js"; import { blank, error, info, success } from "../lib/ui.js"; @@ -9,33 +9,212 @@ function fail(message: string): never { process.exit(1); } -// ── query ─────────────────────────────────────────────────────────── +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +/** Read a --spec value: a JSON string, a file path, or `-` for stdin. */ +async function readSpec(value: string): Promise { + let raw = value; + if (value === "-") { + raw = await readStdin(); + } else if (!value.trim().startsWith("{")) { + const { readFile } = await import("node:fs/promises"); + raw = await readFile(value, "utf8"); + } + try { + return JSON.parse(raw) as SemanticQuery; + } catch { + return fail("--spec must be a JSON object, a path to one, or - for stdin"); + } +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", reject); + }); +} + +function splitList(value?: string): string[] | undefined { + if (!value) return undefined; + const items = value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return items.length ? items : undefined; +} + +/** Parse repeatable `-f field:op:value` (value comma-split for one_of). */ +function parseFilters(raw?: string[]): SemanticQuery["filters"] { + if (!raw || raw.length === 0) return undefined; + return raw.map((item) => { + const first = item.indexOf(":"); + const second = item.indexOf(":", first + 1); + if (first === -1 || second === -1) { + fail(`Bad filter "${item}". Use field:op:value (e.g. device:equals:MOBILE)`); + } + const field = item.slice(0, first); + const op = item.slice(first + 1, second); + const values = item + .slice(second + 1) + .split(",") + .map((v) => v.trim()); + return { field, op, value: values.length > 1 ? values : values[0] }; + }); +} + +/** Parse repeatable `--sort field:dir`. */ +function parseSort(raw?: string[]): SemanticQuery["orderBy"] { + if (!raw || raw.length === 0) return undefined; + return raw.map((item) => { + const [field, dir] = item.split(":"); + return { field, dir: dir === "desc" ? "desc" : "asc" }; + }); +} + +/** Render a wire QueryResult ({columns, rows} or {notReady}/{error}) as text. */ +function printResult(result: unknown): void { + const r = result as { + columns?: Array<{ label: string }>; + rows?: unknown[][]; + rowCount?: number; + truncated?: boolean; + notReady?: { reason: string; action: string }; + error?: { message: string; suggestions?: string[] }; + }; + if (r.error) { + const hint = r.error.suggestions?.length ? `\n try: ${r.error.suggestions.join(", ")}` : ""; + fail(`${r.error.message}${hint}`); + } + if (r.notReady) { + blank(); + info(`Not ready (${r.notReady.reason}): ${r.notReady.action}`); + return; + } + if (!(r.columns && r.rows)) { + printJson(result); + return; + } + const rows = r.rows; + const header = r.columns.map((c) => c.label); + const widths = header.map((h, i) => + Math.max(h.length, ...rows.map((row) => String(row[i] ?? "").length)), + ); + const line = (cells: unknown[]) => + cells.map((c, i) => String(c ?? "").padEnd(widths[i])).join(" "); + blank(); + process.stdout.write(`${line(header)}\n`); + for (const row of rows) process.stdout.write(`${line(row)}\n`); + blank(); + const note = r.truncated ? " (truncated — narrow the range or lower the limit)" : ""; + info(`${r.rowCount ?? rows.length} rows${note}`); +} + +// ── datasets list ─────────────────────────────────────────────────── + +export async function datasetsListCommand(options: { json?: boolean }): Promise { + try { + const auth = requireAuth(); + const config = requireConfig(); + const { datasets } = await api.data.listDatasets(config.orgId, config.teamId, auth.token); + if (options.json) { + printJson(datasets); + return; + } + if (datasets.length === 0) { + blank(); + info("No datasets"); + return; + } + blank(); + for (const d of datasets) { + const tier = d.live ? "live" : "warehouse"; + const avail = d.availability.available + ? "ready" + : `unavailable — ${d.availability.reason ?? "not ready"}`; + process.stdout.write( + `${d.id.padEnd(28)} ${d.family.padEnd(14)} ${tier.padEnd(10)} ${avail}\n`, + ); + } + } catch (err) { + fail(err instanceof Error ? err.message : "Unknown error"); + } +} + +// ── datasets describe ─────────────────────────────────────────────── -export async function queryCommand( - queryString: string, +export async function datasetsDescribeCommand(id: string): Promise { + try { + const auth = requireAuth(); + const result = await api.data.describeDataset(id, auth.token); + printJson(result); + } catch (err) { + fail(err instanceof Error ? err.message : "Unknown error"); + } +} + +// ── datasets query ────────────────────────────────────────────────── + +export async function datasetsQueryCommand( + id: string, options: { - source: string; + measures?: string; + dimensions?: string; + filter?: string[]; from?: string; to?: string; + granularity?: string; + sort?: string[]; + limit?: string; + spec?: string; + json?: boolean; }, ): Promise { try { const auth = requireAuth(); const config = requireConfig(); + const query: SemanticQuery = options.spec + ? await readSpec(options.spec) + : { + measures: splitList(options.measures), + dimensions: splitList(options.dimensions), + filters: parseFilters(options.filter), + dateRange: + options.from && options.to + ? { from: options.from, to: options.to, granularity: options.granularity } + : undefined, + orderBy: parseSort(options.sort), + limit: options.limit ? Number(options.limit) : undefined, + }; + const result = await api.data.queryDataset(config.orgId, config.teamId, id, query, auth.token); + if (options.json) { + printJson(result); + return; + } + printResult(result); + } catch (err) { + fail(err instanceof Error ? err.message : "Unknown error"); + } +} - const dateRange = - options.from && options.to ? { from: options.from, to: options.to } : undefined; +// ── sql (warehouse escape hatch) ──────────────────────────────────── - const result = await api.data.query( - config.orgId, - config.teamId, - options.source, - queryString, - auth.token, - dateRange, - ); - process.stdout.write(JSON.stringify(result, null, 2)); - process.stdout.write("\n"); +export async function sqlCommand(sql: string, options: { json?: boolean }): Promise { + try { + const auth = requireAuth(); + const config = requireConfig(); + const result = await api.data.warehouseSql(config.orgId, config.teamId, sql, auth.token); + if (options.json) { + printJson(result); + return; + } + printResult(result); } catch (err) { fail(err instanceof Error ? err.message : "Unknown error"); } @@ -43,23 +222,30 @@ export async function queryCommand( // ── queries list ──────────────────────────────────────────────────── -export async function queriesListCommand(options: { source?: string }): Promise { +export async function queriesListCommand(options: { + dataset?: string; + json?: boolean; +}): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.data.listQueries( + const { queries } = await api.data.listQueries( config.orgId, config.teamId, auth.token, - options.source, + options.dataset, ); - if (result.queries.length === 0) { + if (options.json) { + printJson(queries); + return; + } + if (queries.length === 0) { blank(); info("No saved queries"); return; } - for (const q of result.queries) { - process.stdout.write(`${q.id} ${q.source} ${q.name}\n`); + for (const q of queries) { + process.stdout.write(`${q.id} ${(q.dataset ?? q.kind).padEnd(24)} ${q.name}\n`); } } catch (err) { fail(err instanceof Error ? err.message : "Unknown error"); @@ -70,25 +256,25 @@ export async function queriesListCommand(options: { source?: string }): Promise< export async function queriesCreateCommand( name: string, - options: { source: string; query: string; config?: string }, + options: { spec: string; chart?: string }, ): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const parsedConfig = options.config - ? (JSON.parse(options.config) as Record) - : undefined; + const query = await readSpec(options.spec); + const viz = options.chart ? { chart: options.chart } : undefined; const result = await api.data.createQuery( config.orgId, config.teamId, name, - options.source, - options.query, + query, auth.token, - parsedConfig, + viz, ); + const created = result as { id?: string; name?: string; error?: { message: string } }; + if (created.error) fail(created.error.message); blank(); - success(`Created query: ${result.name} (${result.id})`); + success(`Created query: ${created.name} (${created.id})`); } catch (err) { fail(err instanceof Error ? err.message : "Unknown error"); } @@ -96,13 +282,16 @@ export async function queriesCreateCommand( // ── queries run ───────────────────────────────────────────────────── -export async function queriesRunCommand(id: string): Promise { +export async function queriesRunCommand(id: string, options: { json?: boolean }): Promise { try { const auth = requireAuth(); const config = requireConfig(); const result = await api.data.runQuery(config.orgId, config.teamId, id, auth.token); - process.stdout.write(JSON.stringify(result, null, 2)); - process.stdout.write("\n"); + if (options.json) { + printJson(result); + return; + } + printResult(result); } catch (err) { fail(err instanceof Error ? err.message : "Unknown error"); } @@ -110,11 +299,15 @@ export async function queriesRunCommand(id: string): Promise { // ── dashboards list ───────────────────────────────────────────────── -export async function dashboardsListCommand(): Promise { +export async function dashboardsListCommand(options: { json?: boolean }): Promise { try { const auth = requireAuth(); const config = requireConfig(); const result = await api.data.listDashboards(config.orgId, config.teamId, auth.token); + if (options.json) { + printJson(result.dashboards); + return; + } if (result.dashboards.length === 0) { blank(); info("No dashboards"); @@ -147,8 +340,21 @@ export async function dashboardsRunCommand( auth.token, options.fresh, ); - process.stdout.write(JSON.stringify(result, null, 2)); - process.stdout.write("\n"); + printJson(result); + } catch (err) { + fail(err instanceof Error ? err.message : "Unknown error"); + } +} + +// ── dashboards add ────────────────────────────────────────────────── + +export async function dashboardsAddCommand(dashboardId: string, queryId: string): Promise { + try { + const auth = requireAuth(); + const config = requireConfig(); + await api.data.addDashboardPanel(config.orgId, config.teamId, dashboardId, queryId, auth.token); + blank(); + success(`Added query ${queryId} to dashboard ${dashboardId}`); } catch (err) { fail(err instanceof Error ? err.message : "Unknown error"); } @@ -161,8 +367,7 @@ export async function integrationCommand(provider: string): Promise { const auth = requireAuth(); const config = requireConfig(); const result = await api.data.getIntegration(config.orgId, config.teamId, provider, auth.token); - process.stdout.write(JSON.stringify(result, null, 2)); - process.stdout.write("\n"); + printJson(result); } catch (err) { fail(err instanceof Error ? err.message : "Unknown error"); } diff --git a/src/commands/file.ts b/src/commands/file.ts index c779cdb..4a848e8 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -40,7 +40,7 @@ export async function fileReadCommand(path: string): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.files.read(config.orgId, config.teamId, path, auth.token); + const result = await api.files.read(config.teamId, path, auth.token); if (!result.found) { fail(`File not found: ${path}`); } @@ -60,7 +60,7 @@ export async function fileWriteCommand( const auth = requireAuth(); const config = requireConfig(); const content = await resolveContent(options); - const result = await api.files.write(config.orgId, config.teamId, path, content, auth.token); + const result = await api.files.write(config.teamId, path, content, auth.token); blank(); success(`Wrote ${result.bytes} bytes → ${result.path}`); } catch (err) { @@ -78,7 +78,7 @@ export async function fileAppendCommand( const auth = requireAuth(); const config = requireConfig(); const content = await resolveContent(options); - const result = await api.files.append(config.orgId, config.teamId, path, content, auth.token); + const result = await api.files.append(config.teamId, path, content, auth.token); blank(); success(`Appended → ${result.path} (${result.bytes} bytes total)`); } catch (err) { @@ -92,7 +92,7 @@ export async function fileListCommand(directory?: string): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.files.list(config.orgId, config.teamId, auth.token, directory); + const result = await api.files.list(config.teamId, auth.token, directory); if (result.files.length === 0) { blank(); info(directory ? `No files in ${directory}` : "No files"); @@ -112,7 +112,7 @@ export async function fileGlobCommand(pattern: string): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.files.glob(config.orgId, config.teamId, pattern, auth.token); + const result = await api.files.glob(config.teamId, pattern, auth.token); if (result.files.length === 0) { blank(); info(`No files match ${pattern}`); @@ -135,13 +135,7 @@ export async function fileGrepCommand( try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.files.grep( - config.orgId, - config.teamId, - pattern, - auth.token, - options.directory, - ); + const result = await api.files.grep(config.teamId, pattern, auth.token, options.directory); if (result.matches.length === 0) { blank(); info("No matches"); @@ -162,7 +156,7 @@ export async function fileDeleteCommand(path: string): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.files.delete(config.orgId, config.teamId, path, auth.token); + const result = await api.files.delete(config.teamId, path, auth.token); blank(); success(`Deleted: ${result.path}`); } catch (err) { @@ -176,7 +170,7 @@ export async function fileMoveCommand(from: string, to: string): Promise { try { const auth = requireAuth(); const config = requireConfig(); - const result = await api.files.move(config.orgId, config.teamId, from, to, auth.token); + const result = await api.files.move(config.teamId, from, to, auth.token); blank(); success(`Moved: ${from} → ${result.path}`); } catch (err) { @@ -194,7 +188,6 @@ export async function fileReplaceCommand( const auth = requireAuth(); const config = requireConfig(); const result = await api.files.replace( - config.orgId, config.teamId, path, options.old, diff --git a/src/lib/api.ts b/src/lib/api.ts index a3f18a9..7df878b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -239,14 +239,10 @@ export const api = { }); }, - async generateToken( - orgId: string, - teamId: string, - token: string, - ): Promise { + async generateToken(teamId: string, token: string): Promise { if (isMockMode()) return mock.generateToken(); return request("POST", "/api/v1/tokens", { - body: { orgId, teamId }, + body: { teamId }, token, }); }, @@ -265,20 +261,14 @@ export const api = { }, files: { - async read( - orgId: string, - teamId: string, - path: string, - token: string, - ): Promise { + async read(teamId: string, path: string, token: string): Promise { return request("POST", `/api/v1/teams/${teamId}/files/read`, { - body: { orgId, path }, + body: { path }, token, }); }, async write( - orgId: string, teamId: string, path: string, content: string, @@ -286,63 +276,50 @@ export const api = { metadata?: Record, ): Promise { return request("POST", `/api/v1/teams/${teamId}/files/write`, { - body: { orgId, path, content, metadata }, + body: { path, content, metadata }, token, }); }, async append( - orgId: string, teamId: string, path: string, content: string, token: string, ): Promise { return request("POST", `/api/v1/teams/${teamId}/files/append`, { - body: { orgId, path, content }, + body: { path, content }, token, }); }, - async list( - orgId: string, - teamId: string, - token: string, - directory?: string, - ): Promise { + async list(teamId: string, token: string, directory?: string): Promise { return request("POST", `/api/v1/teams/${teamId}/files/list`, { - body: { orgId, directory }, + body: { directory }, token, }); }, - async glob( - orgId: string, - teamId: string, - pattern: string, - token: string, - ): Promise { + async glob(teamId: string, pattern: string, token: string): Promise { return request("POST", `/api/v1/teams/${teamId}/files/glob`, { - body: { orgId, pattern }, + body: { pattern }, token, }); }, async grep( - orgId: string, teamId: string, pattern: string, token: string, directory?: string, ): Promise { return request("POST", `/api/v1/teams/${teamId}/files/grep`, { - body: { orgId, pattern, directory }, + body: { pattern, directory }, token, }); }, async delete( - orgId: string, teamId: string, path: string, token: string, @@ -350,25 +327,18 @@ export const api = { return request<{ deleted: boolean; path: string }>( "POST", `/api/v1/teams/${teamId}/files/delete`, - { body: { orgId, path }, token }, + { body: { path }, token }, ); }, - async move( - orgId: string, - teamId: string, - from: string, - to: string, - token: string, - ): Promise<{ path: string }> { + async move(teamId: string, from: string, to: string, token: string): Promise<{ path: string }> { return request<{ path: string }>("POST", `/api/v1/teams/${teamId}/files/move`, { - body: { orgId, from, to }, + body: { from, to }, token, }); }, async replace( - orgId: string, teamId: string, path: string, oldString: string, @@ -377,7 +347,7 @@ export const api = { replaceAll?: boolean, ): Promise { return request("POST", `/api/v1/teams/${teamId}/files/replace`, { - body: { orgId, path, oldString, newString, replaceAll }, + body: { path, oldString, newString, replaceAll }, token, }); }, @@ -386,16 +356,36 @@ export const api = { // ── Data API ── data: { - async query( + async listDatasets(orgId: string, teamId: string, token: string): Promise { + const params = new URLSearchParams({ teamId, orgId }); + return request("GET", `/api/v1/datasets?${params}`, { token }); + }, + + async describeDataset(id: string, token: string): Promise { + return request("GET", `/api/v1/datasets/${encodeURIComponent(id)}`, { token }); + }, + + async queryDataset( orgId: string, teamId: string, - source: string, - query: string, + id: string, + query: SemanticQuery, token: string, - dateRange?: { from: string; to: string }, ): Promise { - return request("POST", "/api/v1/query", { - body: { orgId, teamId, source, query, dateRange }, + return request("POST", `/api/v1/datasets/${encodeURIComponent(id)}/query`, { + body: { orgId, teamId, ...query }, + token, + }); + }, + + async warehouseSql( + orgId: string, + teamId: string, + sql: string, + token: string, + ): Promise { + return request("POST", "/api/v1/warehouse/sql", { + body: { orgId, teamId, sql }, token, }); }, @@ -404,10 +394,10 @@ export const api = { orgId: string, teamId: string, token: string, - source?: string, + dataset?: string, ): Promise { const params = new URLSearchParams({ teamId, orgId }); - if (source) params.set("source", source); + if (dataset) params.set("dataset", dataset); return request("GET", `/api/v1/queries?${params}`, { token }); }, @@ -415,13 +405,12 @@ export const api = { orgId: string, teamId: string, name: string, - source: string, - query: string, + query: SemanticQuery, token: string, - config?: Record, + viz?: Record, ): Promise { return request("POST", "/api/v1/queries", { - body: { orgId, teamId, name, source, query, config }, + body: { orgId, teamId, name, query, viz }, token, }); }, @@ -431,6 +420,20 @@ export const api = { return request("POST", `/api/v1/queries/${id}/run?${params}`, { token }); }, + async addDashboardPanel( + orgId: string, + teamId: string, + dashboardId: string, + queryId: string, + token: string, + ): Promise { + return request( + "POST", + `/api/v1/dashboards/${encodeURIComponent(dashboardId)}/panels`, + { body: { orgId, teamId, queryId }, token }, + ); + }, + async listDashboards( orgId: string, teamId: string, @@ -472,13 +475,54 @@ export const api = { // ── Data API response types ── +/** + * A semantic query — dataset + field picks. Mirrors the app's SemanticQuery + * wire contract (specs/2026-07-10-agent-semantic-datasets/contracts). The + * server validates; these types are display-level only. + */ +export interface SemanticQuery { + dataset?: string; + measures?: string[]; + dimensions?: string[]; + filters?: Array<{ + field: string; + op: string; + value?: string | number | (string | number)[]; + valueEnd?: string | number; + negated?: boolean; + logic?: "and" | "or"; + }>; + dateRange?: { from: string; to: string; granularity?: string }; + orderBy?: Array<{ field: string; dir: "asc" | "desc" }>; + limit?: number; +} + +export interface DatasetSummary { + id: string; + name: string; + description: string; + family: string; + live: boolean; + availability: { + available: boolean; + reason?: string; + action?: string; + connectUrl?: string; + }; +} + +export interface DatasetListResponse { + datasets: DatasetSummary[]; +} + export interface SavedQueryResponse { id: string; name: string; - source: string; - query: string; - config?: Record; - createdAt?: string; + dataset: string | null; + kind: "structured" | "raw-sql"; + query: SemanticQuery | null; + uri: string; + updatedAt?: string; } export interface QueryListResponse {