@malloy-publisher/create-malloy-package 0.0.2 → 0.0.3

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/README.md CHANGED
@@ -10,10 +10,33 @@ instructions, and the Malloy agent skills copied in), so you can go from nothing
10
10
 
11
11
  ```bash
12
12
  mkdir my-data && cd my-data
13
- npm create @malloy-publisher/malloy-package sales
13
+ npm create @malloy-publisher/malloy-package@latest sales
14
14
  npm start
15
15
  ```
16
16
 
17
+ Keep the `@latest`. `npm create` and `npx` both resolve through npm's npx cache, and an
18
+ unversioned name is satisfied by any copy already in it, so on a machine that has run
19
+ the command before npm reuses that copy instead of asking the registry. Without
20
+ `@latest` you can scaffold from an old scaffolder, which pins an old server, silently.
21
+
22
+ Because that is silent, the scaffolder also checks for itself. After it has finished
23
+ writing, it asks the npm registry which version is `latest` and prints a note if the one
24
+ you are running is older. That is the only network request this package makes: a plain
25
+ unauthenticated GET of a public package document, sending nothing about you or your
26
+ files. It is bounded at 1.5 seconds and fails open, so no network, a proxy, or a
27
+ registry outage costs you a moment of silence and nothing else.
28
+
29
+ The request is not made at all when `CI` or `NO_UPDATE_NOTIFIER` is set, so a build
30
+ never pays for advice nobody will read, and a machine that has already switched off
31
+ update notices stays switched off. Anywhere else, `CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK`
32
+ does the same thing:
33
+
34
+ ```bash
35
+ CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK=1 npm create @malloy-publisher/malloy-package@latest sales
36
+ ```
37
+
38
+ Set any of the three to a value other than `0` or `false`.
39
+
17
40
  The workspace files land in the current directory and the package in `./sales`, so you
18
41
  run `npm start` from where you created it (no need to `cd` into the package). The
19
42
  scaffolder's own last lines are the ones to keep: the app URL, the MCP endpoint, and a
@@ -48,6 +71,13 @@ whatever that file names, and the generated agent briefing spells out the URLs f
48
71
  this workspace. That briefing is `AGENTS.md`, or `AGENTS.malloy.md` in a directory
49
72
  that already had an `AGENTS.md` of its own; see "Running it again" below.
50
73
 
74
+ A directory nobody has trusted yet is a second gate, separate from connecting the MCP
75
+ server: Claude Code lists the `malloy_*` tools and then refuses every call, and a
76
+ `.claude/settings.json` allowlist is discarded rather than merged. Start Claude Code
77
+ interactively in the directory once and answer the trust prompt, which is asked once per
78
+ directory. A headless run is never asked, so it cannot clear the gate either. You will
79
+ know it worked when the agent's first Malloy query returns data.
80
+
51
81
  ## Query it
52
82
 
53
83
  The web UI at http://localhost:4000 is the quickest look. For a check you can script,
@@ -86,12 +116,13 @@ no route falls through to the web app and answers `200` with an HTML page, which
86
116
  as success until you look at the body.
87
117
 
88
118
  Agents should reach the same models through MCP rather than curl, which buys them
89
- schema discovery, compile checks and a reload that needs no restart. The generated
90
- briefing lists those tools.
119
+ schema discovery and compile checks. The generated briefing lists those tools. It also
120
+ gives the REST route that recompiles a package after an edit, for an agent with nobody
121
+ around to reconnect its MCP client.
91
122
 
92
123
  ## What it creates
93
124
 
94
- Running `npm create @malloy-publisher/malloy-package sales` in an empty directory produces:
125
+ Running `npm create @malloy-publisher/malloy-package@latest sales` in an empty directory produces:
95
126
 
96
127
  ```
97
128
  publisher.config.json the server config, with your package registered
@@ -282,7 +313,7 @@ and binds `0.0.0.0`. Write the flag and the address with a space between them.
282
313
  ## Options
283
314
 
284
315
  ```bash
285
- npm create @malloy-publisher/malloy-package [name] -- [options]
316
+ npm create @malloy-publisher/malloy-package@latest [name] -- [options]
286
317
  ```
287
318
 
288
319
  `npm create` parses the command line with npm's own config parser before handing
@@ -291,26 +322,26 @@ it npm swallows `--force` as one of its own settings and turns `--data mydata.cs
291
322
  into two stray positional arguments:
292
323
 
293
324
  ```bash
294
- npm create @malloy-publisher/malloy-package sales -- --data mydata.csv
295
- npm create @malloy-publisher/malloy-package sales -- --client cursor
296
- npm create @malloy-publisher/malloy-package sales -- --force
325
+ npm create @malloy-publisher/malloy-package@latest sales -- --data mydata.csv
326
+ npm create @malloy-publisher/malloy-package@latest sales -- --client cursor
327
+ npm create @malloy-publisher/malloy-package@latest sales -- --force
297
328
  ```
298
329
 
299
330
  Running the published bin directly takes the flags as-is, with no separator:
300
331
 
301
332
  ```bash
302
- npx @malloy-publisher/create-malloy-package sales --data mydata.csv
333
+ npx @malloy-publisher/create-malloy-package@latest sales --data mydata.csv
303
334
  ```
304
335
 
305
336
  - `name` (positional): the package name. Omit it to only set up the agent workspace in
306
337
  the current directory (write the MCP connection, agent instructions, and skills)
307
338
  without scaffolding a package.
308
- - `--data <file>`: seed the package from your own CSV, Parquet, or XLSX file instead of
309
- the built-in sample. The file is copied into the package and the starter model points
310
- at it. DuckDB reads all three formats in place; an Excel file is read as its first
311
- sheet. It seeds a new package, so it requires a package name: it cannot be combined
312
- with the setup-only mode above, and passing it without a name is an error rather than
313
- a silently ignored flag.
339
+ - `--data <file>`: seed the package from your own CSV, Parquet, JSON, NDJSON, or XLSX
340
+ file instead of the built-in sample. The file is copied into the package and the
341
+ starter model points at it. DuckDB reads all of them in place, so nothing needs
342
+ converting first; an Excel file is read as its first sheet. It seeds a new package,
343
+ so it requires a package name: it cannot be combined with the setup-only mode above,
344
+ and passing it without a name is an error rather than a silently ignored flag.
314
345
  - `--client <claude-code|cursor>`: which agent client to wire up. Defaults to
315
346
  `claude-code`. `AGENTS.md` and the skills in `.claude/skills/` are written for every
316
347
  client; the MCP config file (`.mcp.json` for Claude Code, `.cursor/mcp.json` for
package/dist/index.js CHANGED
@@ -246,6 +246,135 @@ function toMalloyIdentifier(name) {
246
246
  return identifier;
247
247
  }
248
248
 
249
+ // src/registry_check.ts
250
+ import * as http from "node:http";
251
+ import * as https from "node:https";
252
+ var LATEST_URL = "https://registry.npmjs.org/@malloy-publisher/create-malloy-package/latest";
253
+ var TIMEOUT_MS = 1500;
254
+ var MAX_BODY_BYTES = 1e6;
255
+ var OPT_OUT_ENV = [
256
+ "CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK",
257
+ "CI",
258
+ "NO_UPDATE_NOTIFIER"
259
+ ];
260
+ function updateCheckDisabled(env = process.env) {
261
+ return OPT_OUT_ENV.some((name) => {
262
+ const value = env[name]?.trim().toLowerCase();
263
+ return value !== undefined && value !== "" && value !== "0" && value !== "false";
264
+ });
265
+ }
266
+ function parseVersion(v) {
267
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
268
+ if (!m)
269
+ return;
270
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
271
+ }
272
+ function isOlder(a, b) {
273
+ const left = parseVersion(a);
274
+ const right = parseVersion(b);
275
+ if (!left || !right)
276
+ return;
277
+ for (let i = 0;i < 3; i++) {
278
+ if (left[i] < right[i])
279
+ return true;
280
+ if (left[i] > right[i])
281
+ return false;
282
+ }
283
+ return false;
284
+ }
285
+ function getBody(url, timeoutMs, onCancel) {
286
+ return new Promise((resolve) => {
287
+ let settled = false;
288
+ let deadline;
289
+ const finish = (value) => {
290
+ if (settled)
291
+ return;
292
+ settled = true;
293
+ if (deadline !== undefined)
294
+ clearTimeout(deadline);
295
+ resolve(value);
296
+ };
297
+ try {
298
+ const mod = url.startsWith("http:") ? http : https;
299
+ const req = mod.get(url, { timeout: timeoutMs, headers: { accept: "application/json" } }, (res) => {
300
+ if (res.statusCode !== 200) {
301
+ res.resume();
302
+ finish(undefined);
303
+ return;
304
+ }
305
+ let body = "";
306
+ let bytes = 0;
307
+ res.setEncoding("utf8");
308
+ res.on("data", (chunk) => {
309
+ body += chunk;
310
+ bytes += Buffer.byteLength(chunk, "utf8");
311
+ if (bytes > MAX_BODY_BYTES) {
312
+ req.destroy();
313
+ finish(undefined);
314
+ }
315
+ });
316
+ res.on("end", () => finish(body));
317
+ res.on("error", () => finish(undefined));
318
+ });
319
+ deadline = setTimeout(() => {
320
+ req.destroy();
321
+ finish(undefined);
322
+ }, timeoutMs);
323
+ req.on("timeout", () => {
324
+ req.destroy();
325
+ finish(undefined);
326
+ });
327
+ req.on("error", () => finish(undefined));
328
+ onCancel(() => {
329
+ req.destroy();
330
+ finish(undefined);
331
+ });
332
+ } catch {
333
+ finish(undefined);
334
+ }
335
+ });
336
+ }
337
+ async function fetchLatestVersion(url = LATEST_URL, timeoutMs = TIMEOUT_MS, onCancel = () => {}) {
338
+ if (updateCheckDisabled())
339
+ return;
340
+ const body = await getBody(url, timeoutMs, onCancel);
341
+ if (body === undefined)
342
+ return;
343
+ try {
344
+ const parsed = JSON.parse(body);
345
+ if (typeof parsed !== "object" || parsed === null)
346
+ return;
347
+ const version = parsed.version;
348
+ return typeof version === "string" ? version.trim() : undefined;
349
+ } catch {
350
+ return;
351
+ }
352
+ }
353
+ function startVersionCheck(url = LATEST_URL, timeoutMs = TIMEOUT_MS) {
354
+ let cancel = () => {};
355
+ const result = fetchLatestVersion(url, timeoutMs, (fn) => {
356
+ cancel = fn;
357
+ });
358
+ return { result, cancel: () => cancel() };
359
+ }
360
+ function staleScaffolderWarning(running, latest) {
361
+ if (!latest)
362
+ return;
363
+ if (isOlder(running, latest) !== true)
364
+ return;
365
+ return [
366
+ `This is create-malloy-package ${running}, and npm has ${preview(latest)}.`,
367
+ "",
368
+ "The workspace above was written by the older one, so the server version it",
369
+ "pins is whatever that release pinned. If you did not choose this version on",
370
+ "purpose, name it explicitly and scaffold again:",
371
+ " npm create @malloy-publisher/malloy-package@latest <name>",
372
+ "",
373
+ "(Set CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK=1 to skip this check entirely.)"
374
+ ].join(`
375
+ `);
376
+ }
377
+
249
378
  // src/scaffold.ts
250
379
  import * as fs4 from "node:fs";
251
380
  import * as path3 from "node:path";
@@ -614,7 +743,7 @@ function createPackage(options, result) {
614
743
  ` + forceDescription(name, modelFile, options.host) + `
615
744
 
616
745
  ` + `If you passed --force to \`npm create\`, npm read it as one of its ` + `own settings and it never reached this tool. Options need a \`--\` ` + `in front of them there:
617
- ` + ` npm create @malloy-publisher/malloy-package ${name} -- --force`);
746
+ ` + ` npm create @malloy-publisher/malloy-package@latest ${name} -- --force`);
618
747
  }
619
748
  const dataDir = path3.join(packageDir, "data");
620
749
  assertWithinWorkspace(dataDir, options.cwd, `${name}/data`);
@@ -630,7 +759,11 @@ function createPackage(options, result) {
630
759
  if (destBase !== sourceBase) {
631
760
  result.dataFileRenamedFrom = sourceBase;
632
761
  }
633
- writeFile(path3.join(packageDir, modelFile), renderTemplate("model.custom.malloy", { sourceName, dataPath }), options.cwd);
762
+ const siblings = findSiblingDataFiles(options.dataFile);
763
+ if (siblings.length > 0) {
764
+ result.siblingDataFiles = siblings;
765
+ }
766
+ writeFile(path3.join(packageDir, modelFile), renderTemplate(isSpreadsheet(dataPath) ? "model.custom.xlsx.malloy" : "model.custom.malloy", { sourceName, dataPath }), options.cwd);
634
767
  } else {
635
768
  copyFile(path3.join(templatesDir, "sales.csv"), path3.join(dataDir, "sales.csv"), options.cwd, `${name}/data/sales.csv`);
636
769
  dataPath = "data/sales.csv";
@@ -1160,6 +1293,36 @@ function describeMcpProblem(parses, parsed, config) {
1160
1293
  function renderAgentsFile(result, host, envPackages) {
1161
1294
  const mcpNote = !result.mcpWired ? `\`${result.mcpConfigPath}\` could not be updated automatically, so the \`malloy\` server may still need adding to it by hand.` : host === "cursor" ? "This workspace ships a `.cursor/mcp.json` that points Cursor at the endpoint." : "This workspace ships a `.mcp.json`, so Claude Code offers to connect on first run.";
1162
1295
  const reconnectNote = host === "cursor" ? "ask the user to reload the MCP servers from Cursor's settings (the `malloy` server, then Refresh), or to restart Cursor." : "ask the user to run `/mcp`, select the `malloy` server, and choose Reconnect, or to restart Claude Code. That panel reports `Auth: not authenticated` and offers `Authenticate` first, which is a red herring: this endpoint has no auth, and Reconnect is the one that works.";
1296
+ const registrationNote = host === "cursor" ? "" : [
1297
+ "",
1298
+ "If they cannot, ask them to register the server so the directory stops deciding:",
1299
+ "",
1300
+ "```bash",
1301
+ `claude mcp add --transport http malloy http://localhost:${result.mcpPort}/mcp -s user`,
1302
+ "```",
1303
+ "",
1304
+ "That registration is stored per user rather than per project, so the tools follow the",
1305
+ "agent everywhere. Skills need no such escape hatch: they are rescanned as the working",
1306
+ "directory changes, so a session started further up picks them up once work moves into",
1307
+ "this directory.",
1308
+ ""
1309
+ ].join(`
1310
+ `);
1311
+ const trustNote = host === "cursor" ? "" : [
1312
+ "",
1313
+ "One gate can void the reconnect fix below, and you cannot clear it yourself: if the",
1314
+ "`malloy_*` tools are listed but every call is refused, the usual cause is that this",
1315
+ "workspace has not been trusted, which also discards a `.claude/settings.json` allowlist.",
1316
+ "A human has to start Claude Code interactively in this directory once and answer the",
1317
+ "trust prompt; a non-interactive run is never asked, so it cannot clear the gate either.",
1318
+ "Tell the user and leave the choice to them. You will know the prompt was answered when a",
1319
+ "`malloy_*` call returns data instead of being refused. If that does not change it, the",
1320
+ "other cause is a `malloy` server whose own approval was never given, which the user",
1321
+ "clears in the same `/mcp` panel named below. If the tools are not listed at all, this",
1322
+ "gate is not the cause, so read on.",
1323
+ ""
1324
+ ].join(`
1325
+ `);
1163
1326
  const skillsCount = String(result.skillsInstalled);
1164
1327
  const skillsNote = result.skillsInstalled === 0 ? "This run installed no skills into `.claude/skills/` (the scaffolder's output says why), so there are none here to load. Pull the same guidance as MCP prompts from the endpoint above instead." : host === "cursor" ? `\`.claude/skills/\` holds ${skillsCount} Malloy agent skills as real files. Cursor reads \`AGENTS.md\`; these skills are the same guidance broken out by task, and any MCP client can also pull them as prompts from the endpoint above.` : `\`.claude/skills/\` holds ${skillsCount} Malloy agent skills as real files, so Claude Code auto-discovers them. Hosts that read \`AGENTS.md\` rather than Anthropic Agent Skills can pull the same guidance as MCP prompts from the endpoint above.`;
1165
1328
  const startCommand = result.hasStartScript ? "npm start" : result.startCommand;
@@ -1176,6 +1339,8 @@ function renderAgentsFile(result, host, envPackages) {
1176
1339
  packageSection: packageSection(result, envPackages),
1177
1340
  mcpNote,
1178
1341
  reconnectNote,
1342
+ registrationNote,
1343
+ trustNote,
1179
1344
  skillsNote
1180
1345
  });
1181
1346
  }
@@ -1299,14 +1464,36 @@ function claudeFile(agentsFile) {
1299
1464
  This directory already had an AGENTS.md when the Malloy workspace was ` + `scaffolded, so that file was left exactly as it was and carries none of ` + `the above. ${agentsFile} is the generated briefing; read that one for ` + `anything about Publisher, Malloy, or the MCP endpoint.
1300
1465
  `;
1301
1466
  }
1467
+ var LOADABLE_DATA_EXTENSIONS = [
1468
+ ".csv",
1469
+ ".parquet",
1470
+ ".json",
1471
+ ".ndjson",
1472
+ ".xlsx"
1473
+ ];
1474
+ function isLoadableDataFile(file) {
1475
+ return LOADABLE_DATA_EXTENSIONS.includes(path3.extname(file).toLowerCase());
1476
+ }
1477
+ function isSpreadsheet(dataPath) {
1478
+ return path3.extname(dataPath).toLowerCase() === ".xlsx";
1479
+ }
1302
1480
  function validateDataFile(dataFile) {
1303
1481
  if (!fs4.existsSync(dataFile) || !fs4.statSync(dataFile).isFile()) {
1304
1482
  throw new ScaffoldError(`--data file not found: ${printable(dataFile)}`);
1305
1483
  }
1306
- const ext = path3.extname(dataFile).toLowerCase();
1307
- if (ext !== ".csv" && ext !== ".parquet" && ext !== ".xlsx") {
1308
- throw new ScaffoldError(`--data must be a .csv, .parquet, or .xlsx file (got "${printable(path3.basename(dataFile))}").`);
1484
+ if (!isLoadableDataFile(dataFile)) {
1485
+ throw new ScaffoldError(`--data must be one of ${LOADABLE_DATA_EXTENSIONS.join(", ")} (got "${printable(path3.basename(dataFile))}").`);
1486
+ }
1487
+ }
1488
+ function findSiblingDataFiles(dataFile) {
1489
+ let entries;
1490
+ try {
1491
+ entries = fs4.readdirSync(path3.dirname(dataFile));
1492
+ } catch {
1493
+ return [];
1309
1494
  }
1495
+ const chosen = path3.basename(dataFile);
1496
+ return entries.filter((e) => e !== chosen && isLoadableDataFile(e)).sort().map(printable);
1310
1497
  }
1311
1498
  function toNpmName(base) {
1312
1499
  const cleaned = base.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^[._]+/, "");
@@ -1357,12 +1544,13 @@ function resolveDataFile(cwd, data) {
1357
1544
  return;
1358
1545
  }
1359
1546
  if (data.trim() === "") {
1360
- throw new ScaffoldError(`--data was given an empty filename. Pass the CSV, Parquet, or XLSX ` + `file to seed the package from, or leave --data off to use the ` + `sample data.`);
1547
+ throw new ScaffoldError(`--data was given an empty filename. Pass the CSV, Parquet, JSON, NDJSON, or XLSX ` + `file to seed the package from, or leave --data off to use the ` + `sample data.`);
1361
1548
  }
1362
1549
  return path4.resolve(cwd, data);
1363
1550
  }
1364
- function run(name, options) {
1551
+ async function run(name, options) {
1365
1552
  const cwd = process.cwd();
1553
+ const versionCheck = startVersionCheck();
1366
1554
  const before = snapshotArtifacts(cwd, name);
1367
1555
  try {
1368
1556
  const result = scaffold({
@@ -1373,7 +1561,14 @@ function run(name, options) {
1373
1561
  force: Boolean(options.force)
1374
1562
  });
1375
1563
  process.stdout.write(formatSuccess(result));
1564
+ const warning = staleScaffolderWarning(version(), await versionCheck.result);
1565
+ if (warning) {
1566
+ process.stdout.write(`
1567
+ ${yellow(warning)}
1568
+ `);
1569
+ }
1376
1570
  } catch (err) {
1571
+ versionCheck.cancel();
1377
1572
  process.stderr.write(formatFailure(err, changesSince(cwd, name, before), Boolean(options.force)));
1378
1573
  process.exitCode = 1;
1379
1574
  }
@@ -1664,6 +1859,11 @@ function formatSuccess(result) {
1664
1859
  if (result.dataFileRenamedFrom) {
1665
1860
  lines.push(dim(` ${result.dataFileRenamedFrom} was copied in as ${result.dataPath}, ` + `renamed to the characters a Malloy table path can carry.`));
1666
1861
  }
1862
+ if (result.siblingDataFiles) {
1863
+ const shown2 = result.siblingDataFiles.slice(0, 5);
1864
+ const rest = result.siblingDataFiles.length - shown2.length;
1865
+ lines.push(dim(` Not included: ${shown2.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}. --data takes one file; copy any others into ` + `${result.packageName}/data/ and add a source for each.`));
1866
+ }
1667
1867
  if (result.configExtended) {
1668
1868
  lines.push(`${green("✓")} Registered ${bold(result.packageName)} in the existing publisher.config.json`);
1669
1869
  }
@@ -1699,12 +1899,20 @@ function formatSuccess(result) {
1699
1899
  lines.push(` ${printable(item)}`);
1700
1900
  }
1701
1901
  }
1902
+ if (result.dataPath !== undefined && isSpreadsheet(result.dataPath)) {
1903
+ lines.push("");
1904
+ lines.push(yellow("Spreadsheets need one check first:"));
1905
+ lines.push(` Run the ${cyan("overview")} view and compare its row count against what you`);
1906
+ lines.push(` know is in ${printable(result.dataPath)}. If it comes back far too small, the header`);
1907
+ lines.push(" is not on the first row and the model is reading a title or a banner");
1908
+ lines.push(` instead of your data. ${printable(result.modelFile ?? "the model file")} carries the fix inline.`);
1909
+ }
1702
1910
  const url = `http://localhost:${result.publisherPort}`;
1703
1911
  const environmentIsEmpty = result.envPackageCount === 0;
1704
1912
  lines.push("");
1705
1913
  lines.push(bold("Next steps:"));
1706
1914
  if (environmentIsEmpty) {
1707
- lines.push(` ${cyan("npx @malloy-publisher/create-malloy-package <name>")} ${dim("add a package; the server has nothing to serve without one")}`);
1915
+ lines.push(` ${cyan("npx @malloy-publisher/create-malloy-package@latest <name>")} ${dim("add a package; the server has nothing to serve without one")}`);
1708
1916
  }
1709
1917
  if (result.needsReset) {
1710
1918
  const served = `re-read the config, so ${result.packageName ?? "the new package"} is served`;
@@ -1746,6 +1954,9 @@ function formatSuccess(result) {
1746
1954
  lines.push(` ${cyan(url)} ${dim("explore in the browser")}`);
1747
1955
  lines.push("");
1748
1956
  lines.push(bold("Check it is ready:"));
1957
+ lines.push(dim(` In a second terminal: the boot command above keeps running until you
1958
+ ` + ` stop it. Until the server is up these print nothing at all, not an
1959
+ ` + " error, because -s silences curl's connection failure."));
1749
1960
  lines.push(` curl -s ${url}/api/v0/status`);
1750
1961
  const packagesUrl = `${url}/api/v0/environments/${result.envName}/packages`;
1751
1962
  if (environmentIsEmpty) {
@@ -1786,8 +1997,28 @@ function formatSuccess(result) {
1786
1997
  }
1787
1998
  }
1788
1999
  }
1789
- lines.push(dim(` An agent that starts the server itself can't reconnect MCP in that
1790
- ` + ` session; ask the user to reconnect it. See ${result.agentsFile}.`));
2000
+ const trustNamed = result.host !== "cursor";
2001
+ if (trustNamed) {
2002
+ lines.push(yellow(` Trust this workspace: a human has to open Claude Code here interactively
2003
+ ` + ` once and answer the trust prompt. Until then the malloy tools can report
2004
+ ` + ` connected and still refuse every call, and a permissions allowlist in
2005
+ ` + ` .claude/settings.json is ignored. You will know the prompt was answered
2006
+ ` + " when a malloy tool returns data instead of refusing."));
2007
+ lines.push("");
2008
+ }
2009
+ lines.push(dim((trustNamed ? ` A different problem: an agent that starts the server itself can't
2010
+ reconnect MCP in that session` : ` An agent that starts the server itself can't reconnect MCP in that
2011
+ session`) + `; ask the user to reconnect it. See ${result.agentsFile}.`));
2012
+ if (trustNamed) {
2013
+ lines.push(dim(` ${result.mcpConfigPath} is only picked up by an agent session that
2014
+ ` + ` STARTED in this directory. Launch your agent from here. If you cannot,
2015
+ ` + ` register the server so the directory stops mattering (.claude/skills
2016
+ ` + " needs nothing: it is rescanned as the working directory changes):"));
2017
+ lines.push(` ${cyan(`claude mcp add --transport http malloy http://localhost:${result.mcpPort}/mcp -s user`)}`);
2018
+ } else {
2019
+ lines.push(dim(` ${result.mcpConfigPath} is only picked up by an agent session that
2020
+ ` + " STARTED in this directory. Launch your agent from here."));
2021
+ }
1791
2022
  lines.push("");
1792
2023
  return lines.join(`
1793
2024
  `) + `
@@ -1806,7 +2037,7 @@ function startNote(result, environmentIsEmpty) {
1806
2037
  return "start Publisher (web UI + MCP) with this workspace's packages in watch mode";
1807
2038
  }
1808
2039
  function excessArgumentsHint() {
1809
- const npmCreateFix = ` ${cyan("npm create @malloy-publisher/malloy-package sales -- --data mydata.csv")}`;
2040
+ const npmCreateFix = ` ${cyan("npm create @malloy-publisher/malloy-package@latest sales -- --data mydata.csv")}`;
1810
2041
  if (process.env.npm_command === "init") {
1811
2042
  return [
1812
2043
  "",
@@ -1826,13 +2057,13 @@ function excessArgumentsHint() {
1826
2057
  "",
1827
2058
  "With npx there is no separator: it forwards the flags as they are, and a",
1828
2059
  "-- would be passed through and counted as another argument.",
1829
- ` ${cyan("npx @malloy-publisher/create-malloy-package sales --data mydata.csv")}`,
2060
+ ` ${cyan("npx @malloy-publisher/create-malloy-package@latest sales --data mydata.csv")}`,
1830
2061
  ""
1831
2062
  ].join(`
1832
2063
  `);
1833
2064
  }
1834
2065
  var program = new Command;
1835
- program.name("create-malloy-package").description("Scaffold a Malloy Publisher package and a local agent workspace.").version(version(), "-v, --version").argument("[name]", "package name to create; omit to only wire the agent workspace into the current directory").option("--data <file>", "seed the package from a CSV, Parquet, or XLSX file").addOption(new Option("--client <client>", "agent client to wire up").choices(CLIENTS).default("claude-code")).option("--force", "overwrite existing workspace files instead of keeping them").allowExcessArguments(false).exitOverride((err) => {
2066
+ program.name("create-malloy-package").description("Scaffold a Malloy Publisher package and a local agent workspace.").version(version(), "-v, --version").argument("[name]", "package name to create; omit to only wire the agent workspace into the current directory").option("--data <file>", "seed the package from a CSV, Parquet, JSON, NDJSON, or XLSX file").addOption(new Option("--client <client>", "agent client to wire up").choices(CLIENTS).default("claude-code")).option("--force", "overwrite existing workspace files instead of keeping them").allowExcessArguments(false).exitOverride((err) => {
1836
2067
  if (err.code === "commander.excessArguments") {
1837
2068
  process.stderr.write(excessArgumentsHint());
1838
2069
  }
@@ -1844,8 +2075,8 @@ The flag that picks the agent client is ${cyan("--client")} (claude-code or curs
1844
2075
  `);
1845
2076
  }
1846
2077
  process.exit(err.exitCode);
1847
- }).action((name, options) => {
1848
- run(name, options);
2078
+ }).action(async (name, options) => {
2079
+ await run(name, options);
1849
2080
  });
1850
2081
  function startedAsProgram() {
1851
2082
  const entry = process.argv[1];
@@ -1859,7 +2090,10 @@ function startedAsProgram() {
1859
2090
  }
1860
2091
  }
1861
2092
  if (startedAsProgram()) {
1862
- program.parse();
2093
+ program.parseAsync().catch((err) => {
2094
+ process.stderr.write(formatFailure(err, { created: [], changed: [] }, false));
2095
+ process.exitCode = 1;
2096
+ });
1863
2097
  }
1864
2098
  export {
1865
2099
  snapshotArtifacts,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/create-malloy-package",
3
3
  "description": "Scaffold a Malloy Publisher package and a local agent workspace, so one command takes you from nothing to an agent that can query your data.",
4
- "version": "0.0.2",
4
+ "version": "0.0.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
@@ -45,7 +45,7 @@
45
45
  "test:e2e": "bun test --timeout 180000 tests/e2e"
46
46
  },
47
47
  "dependencies": {
48
- "@malloy-publisher/skills": "^0.1.1",
48
+ "@malloy-publisher/skills": "^0.1.2",
49
49
  "commander": "^12.1.0"
50
50
  },
51
51
  "devDependencies": {
@@ -13,15 +13,24 @@ Everything below talks to a running server, so start it before anything else:
13
13
  {{startCommand}}
14
14
  ```
15
15
 
16
- That is meant to run Publisher on http://localhost:{{port}} (web UI and REST) with the
17
- MCP endpoint on http://localhost:{{mcpPort}}/mcp, and to mount this workspace's
18
- packages in watch mode so model edits recompile. All three of those come from flags in
19
- the command above (`--port`, `--mcp_port`, `--watch-env`), and where that command is an
20
- `npm` script, from the script `package.json` runs for it. Read them there before you
21
- trust anything below: different ports move every URL in this file, a `--server_root` or
22
- `--config` pointing outside this directory serves a different workspace entirely, and
23
- no `--watch-env` means an edit on disk is not picked up at all until you reload the
24
- package. The server settles all of it on boot, printing
16
+ That is *meant* to run Publisher on http://localhost:{{port}} (web UI and REST) with
17
+ the MCP endpoint on http://localhost:{{mcpPort}}/mcp, and to mount this workspace's
18
+ packages in watch mode so model edits recompile. Meant to, because only some of that
19
+ is written in the command, and this paragraph cannot see what yours says. Go and
20
+ read it, and where it is an `npm` script, read the script `package.json` runs for it.
21
+ Five flags decide whether the rest of this file is true:
22
+
23
+ - `--port` and `--mcp_port` are absent from a command that uses the defaults above,
24
+ which is the usual case. Where either one is present, it moves every URL here.
25
+ - `--watch-env` is not a default, so watch mode is on only if you can see it. Without
26
+ it, an edit on disk is not picked up at all until you reload the package.
27
+ - `--server_root` and `--config` say which workspace is being served. Pointing either
28
+ one outside this directory serves a different workspace entirely, and then nothing
29
+ below describes what you are talking to.
30
+ - `--host` decides whether the endpoint is reachable from off this machine. That one
31
+ has a section of its own further down; read it before you assume this is private.
32
+
33
+ The server settles all of it on boot, printing
25
34
  `Publisher server listening at http://<address>:<port>` and a
26
35
  `MCP server listening at ...` line beside it, so read those two lines rather than
27
36
  taking this paragraph's word for it.
@@ -32,9 +41,9 @@ returning the old model's rows; it may instead make the source vanish, so every
32
41
  fails with `Reference to undefined object '<source>'`. Either way the failure goes only
33
42
  to the server's stdout, which you are not reading if you started it in the background.
34
43
  So a query that succeeds after an edit is not proof the edit compiled, and the rows you
35
- get back may be the old model's. Compile-check the edit with `malloy_compile`, or
36
- reload with `malloy_reloadPackage` afterwards (both described below); either one
37
- reports the failure that watch mode swallowed.
44
+ get back may be the old model's. Compile-check the edit with `malloy_compile`, or reload with
45
+ `malloy_reloadPackage` afterwards (both described below, and reload has a REST form for a
46
+ script or an unattended run); either one reports the failure that watch mode swallowed.
38
47
 
39
48
  Poll until it reports serving rather than assuming a fixed wait; the first run
40
49
  downloads the server, so it can take a minute:
@@ -136,9 +145,13 @@ the model file's own errors too),
136
145
  watch-mode recompile that failed), and
137
146
  `malloy_searchDocs`. {{mcpNote}}
138
147
 
139
- REST, for a script or a check that does not need an agent: every model is queryable
140
- at `POST /api/v0/environments/<env>/packages/<package>/models/<model>/query`. This
141
- lists what the server has loaded:
148
+ REST, for a script or a check that does not need an agent: every model is queryable at
149
+ `POST /api/v0/environments/<env>/packages/<package>/models/<model>/query`, and after a
150
+ model-file edit `GET /api/v0/environments/<env>/packages/<package>?reload=true` recompiles
151
+ the package and comes back 424 with the compile errors when it does not compile. Any other
152
+ non-2xx is a failed check too, and its `message` says what went wrong: a 404 for a package
153
+ name this server does not serve, a 503 for a package it could not load at all. This lists
154
+ what the server has loaded:
142
155
 
143
156
  ```bash
144
157
  curl -s http://localhost:{{port}}/api/v0/environments/{{envName}}/packages
@@ -149,16 +162,23 @@ package, the one the scaffolder created on the run that wrote this file. A works
149
162
  scaffolded more than once serves more than that, so take the packages, sources and
150
163
  views you do not see here from the list above and from `malloy_getContext`, never
151
164
  from their absence here.
152
-
165
+ {{trustNote}}
153
166
  If you started this server yourself in this session, your `malloy_*` tools will not
154
167
  appear however long you wait: an MCP client fixes its tool list when it connects, so
155
168
  it never saw a server that did not yet exist. You cannot reconnect yourself. Say so,
156
169
  and {{reconnectNote}} Do not quietly switch to curl instead and call it done: it
157
170
  looks like it is working while hiding a problem the user can clear in seconds, and
158
- it gives up the grounded discovery, compile checks, and reload the tools exist to
159
- provide. If the user would rather keep going without reconnecting, the REST endpoint
160
- above runs the same models.
161
-
171
+ it gives up the grounded discovery and compile checks the tools exist to provide. With
172
+ nobody there to reconnect you, REST is the supported interface rather than a workaround,
173
+ and the reload form above is the same check.
174
+
175
+ There is a second reason the tools can be missing, and it looks identical from here:
176
+ this workspace's MCP config is only discovered by an agent session that **started in
177
+ this directory**. A session rooted anywhere else, a parent directory included, never
178
+ sees the server, and no message anywhere reports it. The two causes need different
179
+ fixes, so tell them apart before acting. If the server is running and the tools were
180
+ never offered at all, ask the user to relaunch the agent from this directory.
181
+ {{registrationNote}}
162
182
  ## Skills ({{skillsCount}} installed)
163
183
 
164
184
  {{skillsNote}}
@@ -0,0 +1,147 @@
1
+ // {{sourceName}}: a starter Malloy model over {{dataPath}}, served by Publisher.
2
+ //
3
+ // Check the row count before you trust this. A spreadsheet is only a table when
4
+ // its first row is the header and every row under it is data. Plenty are not: a
5
+ // report title, a "generated on" banner, a merged group header, or a blank line
6
+ // above the header are all ordinary in an export, and none of them is visible
7
+ // from here. DuckDB takes the first row it finds as the column names and stops
8
+ // at the first blank row, so a workbook with thousands of rows can load as a
9
+ // handful, or as one, and nothing anywhere reports a problem: the package loads,
10
+ // the server says it is serving, and the query returns 200 with the wrong number.
11
+ //
12
+ // So run `overview` first and compare `record_count` against what you know is in
13
+ // the file. If it is short, the read is wrong. Fixing it takes two steps.
14
+ //
15
+ // STEP 1: look at the sheet, because the fix needs three things this file cannot
16
+ // tell you: which sheet, which row the header is on, and which column marks a
17
+ // real row. Add this to the bottom of THIS FILE, reload the package, then run
18
+ // `{{sourceName}}_probe -> peek`. Delete it once you have what you need.
19
+ //
20
+ // source: {{sourceName}}_probe is duckdb.sql("""
21
+ // SELECT * FROM read_xlsx(
22
+ // '{{dataPath}}',
23
+ // header = false, range = 'A1:Z25', all_varchar = true
24
+ // )
25
+ // """) extend {
26
+ // view: peek is { select: *; limit: 25 }
27
+ // }
28
+ //
29
+ // It has to go in the file rather than being sent as a one-off query: Publisher
30
+ // refuses raw SQL in an ad-hoc query ("`duckdb.sql(...)` cannot be used in a
31
+ // restricted query"), and only allows it where it is part of a model.
32
+ //
33
+ // `peek` prints the first 25 rows exactly as they sit in the sheet, banner rows
34
+ // and all, so you can count down to the real header row and see which column
35
+ // carries an id.
36
+ //
37
+ // It does NOT tell you which sheet you are looking at. With no `sheet =`,
38
+ // read_xlsx reads the first one and says nothing about it, so a workbook whose
39
+ // first sheet is a cover page or a notes tab hands you a plausible-looking
40
+ // answer about the wrong sheet. There is no function that lists the sheets, so
41
+ // if the workbook has more than one tab, take the name from the tabs along the
42
+ // bottom in Excel and pass `sheet = '...'`.
43
+ //
44
+ // A name that does not exist fails loudly, which is safe, but do not use it to
45
+ // go fishing: the error suggests at most one sheet and it picks by spelling, so
46
+ // it can name the tab nearest your typo rather than the one holding your data.
47
+ //
48
+ // STEP 2: read it properly. Name the sheet and the range so the header is found,
49
+ // AND filter the decoration back out, because an explicit range reads every row
50
+ // in it: the blank spacers, the subtotal rows, the total, and the footnotes
51
+ // underneath. Replace the source below, keeping the `extend` block so `overview`
52
+ // still exists to re-run:
53
+ //
54
+ // source: {{sourceName}} is duckdb.sql("""
55
+ // SELECT * FROM read_xlsx(
56
+ // '{{dataPath}}',
57
+ // sheet = 'Sheet1', -- EDIT: the sheet from step 1 (or drop this line
58
+ // -- entirely if the workbook has only one)
59
+ // header = true,
60
+ // range = 'A5:Z100000', -- EDIT BOTH BOUNDS: A5 is where the real header
61
+ // -- is; Z and 100000 are a cap, not a guess at your
62
+ // -- size. Columns past Z and rows past 100000 are
63
+ // -- dropped silently, which is the failure above
64
+ // -- all over again.
65
+ // all_varchar = true -- see the mixed-column note below
66
+ // )
67
+ // WHERE "Order ID" LIKE 'SO-%' -- EDIT: a column every real row has and the
68
+ // -- decoration does not, and what a real value
69
+ // -- looks like. An id is the usual choice.
70
+ // """) extend {
71
+ // measure: record_count is count()
72
+ // view: overview is { aggregate: record_count }
73
+ // }
74
+ //
75
+ // Then reload the package, check the reload itself came back OK, and only then
76
+ // run `overview` again. A reload that fails to compile leaves the previously
77
+ // compiled model serving, so `overview` answers 200 with the OLD number and it
78
+ // looks as though your edit did nothing. The reason is in the reload response,
79
+ // not in the query.
80
+ //
81
+ // If no column is unique to real rows, exclude the labels instead of matching
82
+ // them, and check the count both ways rather than trusting either:
83
+ //
84
+ // WHERE "Region" IS NOT NULL AND "Region" NOT IN ('Subtotal', 'Total')
85
+ //
86
+ // Getting this wrong is quiet: a filter that also admits the subtotal rows raises
87
+ // `record_count` to something plausible and double-counts every measure built on
88
+ // it. That is the same failure this whole comment is about, so re-run `overview`
89
+ // and check the number, do not assume.
90
+ //
91
+ // Both halves matter. Without the range you get the title row as your header.
92
+ // Without the filter you get the whole range, padded with empty rows.
93
+ // Do not reach for `stop_at_empty`: it stops at the first blank spacer, which is
94
+ // usually somewhere in the middle of the data.
95
+ //
96
+ // `duckdb.table()` takes a plain path and nothing else, which is why every option
97
+ // above needs the SQL form. Other things a real spreadsheet does: a column mixing
98
+ // numbers and text fails at query time rather than at load, which is what
99
+ // `all_varchar = true` avoids; a cell someone typed as text, `$1,234` or `12%`,
100
+ // stays text even where the column looks numeric; dates may be text or Excel
101
+ // serial numbers; and every number is a double. `skill:malloy-gotchas-modeling`
102
+ // has the full list.
103
+ //
104
+ // Note that `all_varchar = true` hands you every column as a string, so a measure
105
+ // cannot just sum it. Do the conversion in the SQL above, where you already are,
106
+ // rather than in the measure:
107
+ //
108
+ // SELECT
109
+ // *,
110
+ // try_cast(regexp_replace("Units Sold", '[^0-9.-]', '', 'g') AS DOUBLE) AS units_sold_num
111
+ // FROM read_xlsx(...)
112
+ //
113
+ // then `measure: total_units is units_sold_num.sum()` in Malloy. `try_cast` is
114
+ // what makes it survive: a real export has cells reading `N/A` or `12 (est)`, and
115
+ // a plain cast throws on the first one at query time. Note the regex keeps the
116
+ // digits out of `12 (est)`, so those rows count as 12; strip the whole cell
117
+ // instead if an estimate should not count.
118
+ //
119
+ // Keep the `-` in that character class. Without it every negative comes back
120
+ // POSITIVE: a refund of `-500` sums as `500`, and a revenue total that quietly
121
+ // adds its credits back is the same class of silent wrongness as the bad read
122
+ // above. Accounting parentheses need a step of their own, because stripping them
123
+ // also leaves a positive:
124
+ //
125
+ // (CASE WHEN "Amount" LIKE '(%)' THEN -1 ELSE 1 END)
126
+ // * try_cast(regexp_replace("Amount", '[^0-9.]', '', 'g') AS DOUBLE) AS amount_num
127
+ //
128
+ // The Malloy-side form is `sum(amount::number)`, not `amount::number.sum()`,
129
+ // which does not parse. It is fine on a clean column and throws on a dirty one,
130
+ // which is why the SQL layer is the better place.
131
+ // (`ignore_errors = true` on read_xlsx is the other way: it keeps real types and
132
+ // nulls the cells that do not fit.)
133
+ //
134
+ // Beyond that this is the same starting point as any seeded package: the
135
+ // scaffolder does not read your columns, so it begins with a row count. Build it
136
+ // out for your columns: group_by a column to break the count down, and define
137
+ // measures over the numeric ones. Validate edits with malloy_compile and run new
138
+ // views by name with malloy_reloadPackage. The malloy-modeling skill walks
139
+ // through turning a raw table into a semantic model.
140
+
141
+ source: {{sourceName}} is duckdb.table('{{dataPath}}') extend {
142
+ measure: record_count is count()
143
+
144
+ view: overview is {
145
+ aggregate: record_count
146
+ }
147
+ }