@overscore/cli 0.9.14 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -47,7 +47,29 @@ function loadEnv() {
47
47
  };
48
48
  }
49
49
  }
50
- console.error(" Error: No credentials found. Run 'npx @overscore/cli auth login' to set up.");
50
+ // No creds. Tailor the message based on what kind of folder we're in so the
51
+ // user gets pointed at the right setup command.
52
+ const inAnalysisFolder = fs.existsSync(path.resolve(process.cwd(), ".overscore", "analysis.json"));
53
+ if (inAnalysisFolder) {
54
+ let analysisSlug = null;
55
+ try {
56
+ const meta = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".overscore", "analysis.json"), "utf-8"));
57
+ analysisSlug = typeof meta.slug === "string" ? meta.slug : null;
58
+ }
59
+ catch {
60
+ // Fall through to the generic message.
61
+ }
62
+ console.error("\n Error: No credentials found in this analysis folder.");
63
+ console.error(" This usually means the analysis hasn't been linked to your account yet.");
64
+ console.error(" Run:");
65
+ console.error(` npx @overscore/cli analysis pull ${analysisSlug || "<slug>"}`);
66
+ console.error(" to refresh credentials and link this folder, or:");
67
+ console.error(" npx @overscore/cli auth login");
68
+ console.error(" if you've never set up the CLI on this machine.\n");
69
+ }
70
+ else {
71
+ console.error("\n Error: No credentials found. Run 'npx @overscore/cli auth login' to set up.\n");
72
+ }
51
73
  process.exit(1);
52
74
  }
53
75
  async function apiRequest(method, urlPath, body) {
@@ -423,6 +445,14 @@ function saveQueryResult(sql, data) {
423
445
  .slice(0, 40);
424
446
  const filename = `${hash}_${snippet}.json`;
425
447
  const dataDir = path.resolve(process.cwd(), "data");
448
+ const fullPath = path.join(dataDir, filename);
449
+ const relPath = `data/${filename}`;
450
+ // Hash is content-derived from the SQL — if a file with this hash already
451
+ // exists, the exact same query was run before. Skip the rewrite so the
452
+ // data/ folder doesn't accumulate duplicates of identical queries.
453
+ if (fs.existsSync(fullPath)) {
454
+ return { path: relPath, cached: true };
455
+ }
426
456
  if (!fs.existsSync(dataDir)) {
427
457
  fs.mkdirSync(dataDir, { recursive: true });
428
458
  }
@@ -434,27 +464,53 @@ function saveQueryResult(sql, data) {
434
464
  columns: data.data.length > 0 ? Object.keys(data.data[0]) : [],
435
465
  data: data.data,
436
466
  };
437
- fs.writeFileSync(path.join(dataDir, filename), JSON.stringify(payload, null, 2));
438
- return `data/${filename}`;
467
+ fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2));
468
+ return { path: relPath, cached: false };
439
469
  }
440
470
  catch {
441
471
  return null;
442
472
  }
443
473
  }
444
474
  async function queryTest(sql) {
445
- if (!sql) {
446
- console.error('\n Usage: npx @overscore/cli query test "<sql>"\n');
447
- console.error(" Example:");
448
- console.error(' npx @overscore/cli query test "SELECT * FROM dataset.table LIMIT 10"\n');
475
+ const resolvedSql = resolveSql(sql);
476
+ if (!resolvedSql) {
477
+ console.error('\n Usage: npx @overscore/cli query run "<sql>"\n');
478
+ console.error(" Or with a file:");
479
+ console.error(" npx @overscore/cli query run --file queries/foo.sql\n");
449
480
  process.exit(1);
450
481
  }
451
482
  const { projectSlug } = loadEnv();
452
- const data = (await apiRequest("POST", `/api/projects/${projectSlug}/test-query`, { sql }));
453
- const savedPath = saveQueryResult(sql, data);
483
+ if (!projectSlug) {
484
+ const analysisMetaPath = path.resolve(process.cwd(), ".overscore", "analysis.json");
485
+ if (fs.existsSync(analysisMetaPath)) {
486
+ let slug = "<slug>";
487
+ try {
488
+ const meta = JSON.parse(fs.readFileSync(analysisMetaPath, "utf-8"));
489
+ if (typeof meta.slug === "string")
490
+ slug = meta.slug;
491
+ }
492
+ catch {
493
+ // ignore
494
+ }
495
+ console.error("\n Error: This analysis folder isn't linked to a project yet.");
496
+ console.error(` Run: npx @overscore/cli analysis pull ${slug}\n`);
497
+ }
498
+ else {
499
+ console.error("\n Error: No project slug configured. Run 'npx @overscore/cli auth login' to set one up.\n");
500
+ }
501
+ process.exit(1);
502
+ }
503
+ const data = (await apiRequest("POST", `/api/projects/${projectSlug}/test-query`, { sql: resolvedSql }));
504
+ const saved = saveQueryResult(resolvedSql, data);
505
+ const savedLine = saved
506
+ ? saved.cached
507
+ ? ` Cached (already saved earlier): ${saved.path}`
508
+ : ` Saved to ${saved.path}`
509
+ : null;
454
510
  console.log(`\n ${data.row_count} rows returned${data.truncated ? " (showing first 200)" : ""}\n`);
455
511
  if (data.data.length === 0) {
456
- if (savedPath)
457
- console.log(` Saved to ${savedPath}\n`);
512
+ if (savedLine)
513
+ console.log(`${savedLine}\n`);
458
514
  console.log(" No rows.\n");
459
515
  return;
460
516
  }
@@ -481,8 +537,8 @@ async function queryTest(sql) {
481
537
  if (data.data.length > 10) {
482
538
  console.log(`\n ... and ${data.data.length - 10} more rows`);
483
539
  }
484
- if (savedPath)
485
- console.log(`\n Saved to ${savedPath}`);
540
+ if (savedLine)
541
+ console.log(`\n${savedLine}`);
486
542
  console.log();
487
543
  }
488
544
  function formatCell(value) {
@@ -913,18 +969,22 @@ function scaffoldFromTemplate(templateDir, targetDir, substitutions) {
913
969
  }
914
970
  return out;
915
971
  }
972
+ const TEMPLATE_RENAMES = {
973
+ _gitignore: ".gitignore",
974
+ _overscoreignore: ".overscoreignore",
975
+ };
916
976
  function walk(srcDir, destDir) {
917
977
  fs.mkdirSync(destDir, { recursive: true });
918
978
  for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
919
979
  const srcPath = path.join(srcDir, entry.name);
920
- const destName = entry.name === "_gitignore" ? ".gitignore" : entry.name;
980
+ const destName = TEMPLATE_RENAMES[entry.name] || entry.name;
921
981
  const destPath = path.join(destDir, destName);
922
982
  if (entry.isDirectory()) {
923
983
  walk(srcPath, destPath);
924
984
  }
925
985
  else if (entry.isFile()) {
926
986
  const ext = path.extname(entry.name).toLowerCase();
927
- if (TEXT_EXTENSIONS.has(ext) || entry.name === "_gitignore") {
987
+ if (TEXT_EXTENSIONS.has(ext) || TEMPLATE_RENAMES[entry.name]) {
928
988
  const content = fs.readFileSync(srcPath, "utf8");
929
989
  fs.writeFileSync(destPath, substitute(content));
930
990
  }
@@ -1011,23 +1071,77 @@ function loadAnalysisMetadata() {
1011
1071
  }
1012
1072
  return JSON.parse(fs.readFileSync(metaPath, "utf-8"));
1013
1073
  }
1014
- const ANALYSIS_PUBLISH_EXCLUDE = new Set([
1074
+ // Always-excluded names these can never be published regardless of
1075
+ // .overscoreignore, so secrets and machine-state can't accidentally ship.
1076
+ const ANALYSIS_PUBLISH_BASELINE_EXCLUDE = new Set([
1015
1077
  "node_modules",
1016
1078
  ".git",
1017
1079
  ".DS_Store",
1018
- ".env",
1019
- "data",
1080
+ ".overscore",
1020
1081
  ]);
1021
- function collectAnalysisFiles(dir, prefix) {
1082
+ function isBaselineExcluded(name) {
1083
+ if (ANALYSIS_PUBLISH_BASELINE_EXCLUDE.has(name))
1084
+ return true;
1085
+ if (name.startsWith(".env"))
1086
+ return true;
1087
+ return false;
1088
+ }
1089
+ function compileIgnorePattern(raw) {
1090
+ let pattern = raw.trim();
1091
+ if (!pattern || pattern.startsWith("#"))
1092
+ return null;
1093
+ const dirOnly = pattern.endsWith("/");
1094
+ if (dirOnly)
1095
+ pattern = pattern.slice(0, -1);
1096
+ // Escape regex specials except for `*`, then turn `*` into `[^/]*`.
1097
+ const escaped = pattern
1098
+ .split("*")
1099
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
1100
+ .join("[^/]*");
1101
+ // Anchor to either a full path match or a path that has the pattern as a
1102
+ // path segment somewhere (so `data/` matches `data/foo.json` and `*.email.html`
1103
+ // matches `emails/x.email.html`).
1104
+ const regex = new RegExp(`(^|/)${escaped}(/|$)`);
1105
+ return { raw, regex, dirOnly };
1106
+ }
1107
+ function loadOverscoreIgnore(dir) {
1108
+ const ignorePath = path.join(dir, ".overscoreignore");
1109
+ if (!fs.existsSync(ignorePath))
1110
+ return [];
1111
+ const content = fs.readFileSync(ignorePath, "utf-8");
1112
+ const patterns = [];
1113
+ for (const line of content.split("\n")) {
1114
+ const compiled = compileIgnorePattern(line);
1115
+ if (compiled)
1116
+ patterns.push(compiled);
1117
+ }
1118
+ return patterns;
1119
+ }
1120
+ function isIgnored(relativePath, isDirectory, patterns) {
1121
+ for (const p of patterns) {
1122
+ if (p.dirOnly && !isDirectory) {
1123
+ // Directory-only pattern can still match a file *inside* the directory.
1124
+ // The regex handles that case for paths like `data/foo.json`.
1125
+ if (p.regex.test(relativePath))
1126
+ return true;
1127
+ }
1128
+ else if (p.regex.test(relativePath)) {
1129
+ return true;
1130
+ }
1131
+ }
1132
+ return false;
1133
+ }
1134
+ function collectAnalysisFiles(dir, prefix, patterns) {
1135
+ const userPatterns = patterns ?? loadOverscoreIgnore(dir);
1022
1136
  const files = [];
1023
1137
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1024
- if (ANALYSIS_PUBLISH_EXCLUDE.has(entry.name))
1025
- continue;
1026
- if (entry.name.startsWith(".env"))
1138
+ if (isBaselineExcluded(entry.name))
1027
1139
  continue;
1028
1140
  const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
1141
+ if (isIgnored(relativePath, entry.isDirectory(), userPatterns))
1142
+ continue;
1029
1143
  if (entry.isDirectory()) {
1030
- files.push(...collectAnalysisFiles(path.join(dir, entry.name), relativePath));
1144
+ files.push(...collectAnalysisFiles(path.join(dir, entry.name), relativePath, userPatterns));
1031
1145
  }
1032
1146
  else {
1033
1147
  files.push(relativePath);
@@ -1035,7 +1149,61 @@ function collectAnalysisFiles(dir, prefix) {
1035
1149
  }
1036
1150
  return files;
1037
1151
  }
1152
+ function formatBytes(bytes) {
1153
+ if (bytes < 1024)
1154
+ return `${bytes} B`;
1155
+ if (bytes < 1024 * 1024)
1156
+ return `${(bytes / 1024).toFixed(1)} KB`;
1157
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
1158
+ }
1159
+ function summarizeFiles(files, baseDir) {
1160
+ const groupMap = new Map();
1161
+ let totalBytes = 0;
1162
+ for (const rel of files) {
1163
+ const fullPath = path.join(baseDir, rel);
1164
+ let size = 0;
1165
+ try {
1166
+ size = fs.statSync(fullPath).size;
1167
+ }
1168
+ catch {
1169
+ // skip stat failures, count as 0
1170
+ }
1171
+ totalBytes += size;
1172
+ const segments = rel.split("/");
1173
+ const group = segments.length > 1 ? segments[0] + "/" : "(root)";
1174
+ let entry = groupMap.get(group);
1175
+ if (!entry) {
1176
+ entry = { group, files: [], totalBytes: 0 };
1177
+ groupMap.set(group, entry);
1178
+ }
1179
+ entry.files.push({ path: rel, size });
1180
+ entry.totalBytes += size;
1181
+ }
1182
+ // Sort groups: (root) first, then alphabetical
1183
+ const groups = Array.from(groupMap.values()).sort((a, b) => {
1184
+ if (a.group === "(root)")
1185
+ return -1;
1186
+ if (b.group === "(root)")
1187
+ return 1;
1188
+ return a.group.localeCompare(b.group);
1189
+ });
1190
+ return { groups, totalFiles: files.length, totalBytes };
1191
+ }
1192
+ function printBundleSummary(files, baseDir, opts = {}) {
1193
+ const { groups, totalFiles, totalBytes } = summarizeFiles(files, baseDir);
1194
+ for (const group of groups) {
1195
+ const fileWord = group.files.length === 1 ? "file" : "files";
1196
+ console.log(` ${group.group} ${group.files.length} ${fileWord}, ${formatBytes(group.totalBytes)}`);
1197
+ if (opts.verbose) {
1198
+ for (const f of group.files) {
1199
+ console.log(` ${f.path} (${formatBytes(f.size)})`);
1200
+ }
1201
+ }
1202
+ }
1203
+ console.log(` ${"─".repeat(20)}\n Total: ${totalFiles} files, ${formatBytes(totalBytes)}`);
1204
+ }
1038
1205
  async function analysisPublish() {
1206
+ const dryRun = process.argv.includes("--dry-run") || process.argv.includes("--preview");
1039
1207
  const meta = loadAnalysisMetadata();
1040
1208
  if (!meta.project_slug) {
1041
1209
  console.error("\n Error: analysis.json has no project_slug. Check .overscore/analysis.json.\n");
@@ -1045,6 +1213,27 @@ async function analysisPublish() {
1045
1213
  console.error("\n Error: analysis.json has no slug. Check .overscore/analysis.json.\n");
1046
1214
  process.exit(1);
1047
1215
  }
1216
+ const reportPath = path.resolve(process.cwd(), "report.html");
1217
+ if (!fs.existsSync(reportPath)) {
1218
+ console.error("\n Error: report.html not found in this folder. Generate it before publishing.\n");
1219
+ process.exit(1);
1220
+ }
1221
+ const reportContent = fs.readFileSync(reportPath, "utf-8");
1222
+ if (reportContent.includes("This analysis is empty")) {
1223
+ console.error("\n Error: report.html still has placeholder content. Generate the report before publishing.\n");
1224
+ process.exit(1);
1225
+ }
1226
+ // Dry-run: print what would be uploaded, then exit. No network, no auth.
1227
+ if (dryRun) {
1228
+ const files = collectAnalysisFiles(process.cwd(), "");
1229
+ console.log(`\n Dry run — these files would be uploaded for "${meta.title}":\n`);
1230
+ printBundleSummary(files, process.cwd(), { verbose: true });
1231
+ console.log(`
1232
+ Excluded by .overscoreignore + baseline (.git, node_modules, .env*, .overscore/, .DS_Store).
1233
+ No upload made. Re-run without --dry-run to publish.
1234
+ `);
1235
+ return;
1236
+ }
1048
1237
  const cfg = await loadAnalysisConfig();
1049
1238
  // Resolve missing id from the Hub before giving up
1050
1239
  if (!meta.id) {
@@ -1058,16 +1247,6 @@ async function analysisPublish() {
1058
1247
  }
1059
1248
  meta.id = refreshed.id;
1060
1249
  }
1061
- const reportPath = path.resolve(process.cwd(), "report.html");
1062
- if (!fs.existsSync(reportPath)) {
1063
- console.error("\n Error: report.html not found in this folder. Generate it before publishing.\n");
1064
- process.exit(1);
1065
- }
1066
- const reportContent = fs.readFileSync(reportPath, "utf-8");
1067
- if (reportContent.includes("This analysis is empty")) {
1068
- console.error("\n Error: report.html still has placeholder content. Generate the report before publishing.\n");
1069
- process.exit(1);
1070
- }
1071
1250
  console.log(`\n Publishing analysis: ${meta.title}\n`);
1072
1251
  const files = collectAnalysisFiles(process.cwd(), "");
1073
1252
  console.log(` Bundling ${files.length} files...`);
@@ -1091,12 +1270,9 @@ async function analysisPublish() {
1091
1270
  console.error(`\n Publish failed: ${data.error}\n`);
1092
1271
  process.exit(1);
1093
1272
  }
1094
- console.log(`
1095
- Published successfully!
1096
-
1097
- ${data.file_count} files uploaded
1098
- URL: ${data.url}
1099
- `);
1273
+ console.log(`\n Published successfully!\n`);
1274
+ printBundleSummary(files, process.cwd());
1275
+ console.log(`\n URL: ${data.url}\n`);
1100
1276
  }
1101
1277
  /**
1102
1278
  * Ensure analysis.json has a valid id by fetching from the Hub.
@@ -1350,8 +1526,30 @@ async function analysisPull(slug) {
1350
1526
  await ensureAnalysisId(targetDir, cfg, analysisSlug);
1351
1527
  }
1352
1528
  if (!inPlace) {
1529
+ // Summarize what landed on disk so the user knows what they got.
1530
+ let pulledFileCount = 0;
1531
+ let pulledBytes = 0;
1532
+ try {
1533
+ const pulledFiles = collectAnalysisFiles(targetDir, "");
1534
+ pulledFileCount = pulledFiles.length;
1535
+ for (const f of pulledFiles) {
1536
+ try {
1537
+ pulledBytes += fs.statSync(path.join(targetDir, f)).size;
1538
+ }
1539
+ catch {
1540
+ // ignore
1541
+ }
1542
+ }
1543
+ }
1544
+ catch {
1545
+ // collection failed — fall back to no summary
1546
+ }
1547
+ const sizeSummary = pulledFileCount > 0
1548
+ ? ` Pulled into ./${analysisSlug} (${pulledFileCount} files, ${formatBytes(pulledBytes)})`
1549
+ : ` Pulled into ./${analysisSlug}`;
1353
1550
  console.log(`
1354
1551
  Pulled "${analysisTitle}"
1552
+ ${sizeSummary}
1355
1553
 
1356
1554
  Next steps:
1357
1555
 
@@ -1367,6 +1565,28 @@ async function analysisPull(slug) {
1367
1565
  `);
1368
1566
  }
1369
1567
  }
1568
+ function analysisPreview() {
1569
+ const reportPath = path.resolve(process.cwd(), "report.html");
1570
+ if (!fs.existsSync(reportPath)) {
1571
+ console.error("\n Error: report.html not found in this folder. Generate it before previewing.\n");
1572
+ process.exit(1);
1573
+ }
1574
+ // Windows `start` needs an empty quoted title before the path arg
1575
+ // (`start "" "C:\path\report.html"`), which is why we prefix it here.
1576
+ const opener = process.platform === "darwin"
1577
+ ? "open"
1578
+ : process.platform === "win32"
1579
+ ? 'start ""'
1580
+ : "xdg-open";
1581
+ try {
1582
+ execSync(`${opener} "${reportPath}"`, { stdio: "inherit" });
1583
+ console.log(`\n Opened ${path.basename(reportPath)} in your default browser.\n`);
1584
+ }
1585
+ catch {
1586
+ console.error(`\n Error: couldn't run "${opener}". Open ${reportPath} manually.\n`);
1587
+ process.exit(1);
1588
+ }
1589
+ }
1370
1590
  // ── auth ────────────────────────────────────────────────────────────
1371
1591
  async function authStatus() {
1372
1592
  const configDir = path.join(process.env.HOME || "", ".overscore");
@@ -1575,19 +1795,26 @@ else if (command === "analysis") {
1575
1795
  else if (subcommand === "pull") {
1576
1796
  analysisPull(process.argv[4]);
1577
1797
  }
1798
+ else if (subcommand === "preview") {
1799
+ analysisPreview();
1800
+ }
1578
1801
  else {
1579
1802
  console.log(`
1580
1803
  overscore analysis — Manage Overscore Analyses (frozen investigations)
1581
1804
 
1582
1805
  Commands:
1583
- new "<title>" Create a new analysis in the current directory
1584
- publish Upload report.html + bundle from the current analysis folder
1585
- pull <slug> Re-download an existing analysis bundle to continue working
1806
+ new "<title>" Create a new analysis in the current directory
1807
+ publish Upload report.html + bundle from the current analysis folder
1808
+ publish --dry-run Print what would be uploaded without sending it
1809
+ pull <slug> Re-download an existing analysis bundle to continue working
1810
+ preview Open report.html in your default browser
1586
1811
 
1587
1812
  Examples:
1588
1813
  npx @overscore/cli analysis new "Why did Q1 churn spike"
1589
1814
  npx @overscore/cli analysis publish
1815
+ npx @overscore/cli analysis publish --dry-run
1590
1816
  npx @overscore/cli analysis pull why-did-q1-churn-spike
1817
+ npx @overscore/cli analysis preview
1591
1818
  `);
1592
1819
  }
1593
1820
  }
@@ -1615,21 +1842,22 @@ else if (command === "query") {
1615
1842
  overscore query — Manage dashboard queries
1616
1843
 
1617
1844
  Commands:
1618
- list List all queries
1619
- show <name> Show a query's SQL and sample data
1620
- add <name> "<sql>" Add a new query
1621
- update <name> "<sql>" Update a query's SQL
1622
- remove <name> Delete a query
1623
- run "<sql>" Run SQL against BigQuery
1624
- test "<sql>" Alias for run
1845
+ list List all queries
1846
+ show <name> Show a query's SQL and sample data
1847
+ add <name> "<sql>" Add a new query
1848
+ update <name> "<sql>" Update a query's SQL
1849
+ remove <name> Delete a query
1850
+ run "<sql>" Run SQL against BigQuery
1851
+ run --file <path> Run SQL from a file (e.g. queries/foo.sql)
1852
+ test Alias for run
1625
1853
 
1626
1854
  Examples:
1627
1855
  npx @overscore/cli query list
1628
1856
  npx @overscore/cli query show monthly_revenue
1629
1857
  npx @overscore/cli query add monthly_revenue --file queries/revenue.sql
1630
- npx @overscore/cli query add monthly_revenue "SELECT ..."
1631
1858
  npx @overscore/cli query update monthly_revenue --file queries/revenue.sql
1632
1859
  npx @overscore/cli query run "SELECT * FROM dataset.table LIMIT 10"
1860
+ npx @overscore/cli query run --file queries/q3-cohort-halo.sql
1633
1861
  `);
1634
1862
  }
1635
1863
  }
@@ -1660,9 +1888,12 @@ else {
1660
1888
  npx @overscore/cli query update <name> "<sql>"
1661
1889
  npx @overscore/cli query remove <name>
1662
1890
  npx @overscore/cli query run "<sql>"
1891
+ npx @overscore/cli query run --file queries/foo.sql
1663
1892
  npx @overscore/cli analysis new "<title>"
1664
1893
  npx @overscore/cli analysis publish
1894
+ npx @overscore/cli analysis publish --dry-run
1665
1895
  npx @overscore/cli analysis pull <slug>
1896
+ npx @overscore/cli analysis preview
1666
1897
  npx @overscore/cli install-skills
1667
1898
  `);
1668
1899
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overscore/cli",
3
- "version": "0.9.14",
3
+ "version": "0.10.0",
4
4
  "description": "CLI for deploying Overscore dashboards and publishing analyses",
5
5
  "bin": {
6
6
  "overscore": "dist/index.js"
@@ -31,6 +31,12 @@ Use the Overscore CLI to run SQL against the project's BigQuery connection:
31
31
  npx @overscore/cli query run "SELECT col1, COUNT(*) FROM \`project.dataset.table\` WHERE date >= '2024-01-01' GROUP BY 1 LIMIT 100"
32
32
  ```
33
33
 
34
+ For longer queries, write the SQL to a file in `queries/` and pass `--file`:
35
+
36
+ ```bash
37
+ npx @overscore/cli query run --file queries/q3-cohort-halo.sql
38
+ ```
39
+
34
40
  The CLI authenticates via `~/.overscore/config` (set up on first use). Credentials are stored in the Hub — never hardcode them locally.
35
41
 
36
42
  **Rules for exploratory queries:**
@@ -39,7 +45,11 @@ The CLI authenticates via `~/.overscore/config` (set up on first use). Credentia
39
45
  - Pre-aggregate before pulling wide result sets
40
46
  - Do NOT use `query list`, `query add`, or `query show` — those are for dashboard registered queries, not analysis exploration
41
47
 
42
- **Previous query results are saved automatically.** Every `query run` saves the full result set to `data/` as a JSON file. Before re-running a query, check `data/` — the results may already be there from a previous session. Read them with standard file tools instead of re-querying BigQuery.
48
+ **Previous query results are saved automatically.** Every `query run` saves the full result set to `data/` as a JSON file, keyed by a hash of the SQL. Re-running an identical query is a no-op — the CLI prints `Cached (already saved earlier): data/...` instead of writing a duplicate. Before re-running a query, check `data/` — the results may already be there from a previous session. Read them with standard file tools instead of re-querying BigQuery.
49
+
50
+ ## Saving final queries
51
+
52
+ The `queries/` directory is for SQL files you want to keep around — the canonical version of each Q-numbered query in `analysis-log.md`, queries the user might want to re-run later, or anything you've iterated on enough to deserve a name. File names like `q3-cohort-halo.sql` cross-reference the Query Log. Exploratory one-offs don't need to live here; they're already saved as JSON in `data/`.
43
53
 
44
54
  ## Where data context lives
45
55
 
@@ -50,13 +60,20 @@ The CLI authenticates via `~/.overscore/config` (set up on first use). Credentia
50
60
  To preview the report at any point, open it in the browser:
51
61
 
52
62
  ```bash
53
- open report.html # macOS
54
- start report.html # Windows
55
- xdg-open report.html # Linux
63
+ npx @overscore/cli analysis preview
56
64
  ```
57
65
 
66
+ (Or `open report.html` / `start report.html` / `xdg-open report.html` if the CLI isn't handy.)
67
+
58
68
  Preview early and often — don't write the entire report before checking the layout. Build section by section, previewing after each major addition.
59
69
 
70
+ ## If the user wants a share email or Slack message
71
+
72
+ If the user asks for a copy-paste email or Slack summary of the analysis, write it to a file like `update-YYYY-MM-DD.email.html` in the analysis root. Two things to know:
73
+
74
+ - **Email-safe HTML only.** Gmail and most clients strip `<style>` blocks — use inline styles on every element. No `<script>` (most clients block it). Keep layouts simple; complex things (multi-column) want `<table>` for cross-client compatibility.
75
+ - **The `.email.html` suffix keeps it out of the published bundle.** It's matched by `.overscoreignore`, so iterating on email drafts won't pollute the public report.
76
+
60
77
  ## Publishing
61
78
 
62
79
  When the analysis is ready, **tell the user to run** (do not run it yourself without asking):
@@ -77,4 +77,8 @@ When starting `report.html`, read only the **Key Findings** section of `analysis
77
77
 
78
78
  ## Cached query results
79
79
 
80
- Every `query run` command saves its result to `data/{hash}_{snippet}.json`. At the start of a session, check `data/` for existing results before re-running queries — especially after a context reset or `analysis pull`. The JSON files contain the SQL, timestamp, columns, and full row data. Read them directly instead of re-querying BigQuery.
80
+ Every `query run` command saves its result to `data/{hash}_{snippet}.json`. The hash is derived from the SQL string, so re-running an identical query is a no-op — the CLI prints `Cached (already saved earlier): data/...` instead of writing a duplicate. At the start of a session, check `data/` for existing results before re-running queries — especially after a context reset or `analysis pull`. The JSON files contain the SQL, timestamp, columns, and full row data. Read them directly instead of re-querying BigQuery.
81
+
82
+ ## Saving queries to disk
83
+
84
+ For any query worth referencing later (each Q-numbered query in this log, queries the user might re-run, longer queries you've iterated on), save the SQL to `queries/qN-short-name.sql` and run it via `npx @overscore/cli query run --file queries/qN-short-name.sql`. The Query Log entry then just needs the file reference instead of a full SQL paste — it keeps the log readable and gives you a single source of truth to edit. Throwaway one-shot exploratory queries don't need a file; their JSON in `data/` is enough.
@@ -0,0 +1,11 @@
1
+ # Files matching these patterns are excluded from `analysis publish`.
2
+ # Syntax: one pattern per line. `*` is a glob within a single path segment.
3
+ # Trailing `/` matches a directory and everything inside it.
4
+ # Lines starting with `#` are comments.
5
+ #
6
+ # A baseline set is always excluded regardless of this file:
7
+ # .git/, node_modules/, .DS_Store, .env*, .overscore/
8
+
9
+ data/
10
+ *.draft.html
11
+ *.email.html
File without changes