@malloy-publisher/skills 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +27 -3
  2. package/dist/cli.d.ts +27 -0
  3. package/dist/cli.js +188 -0
  4. package/dist/index.d.ts +7 -8
  5. package/dist/index.js +8 -9
  6. package/dist/install.d.ts +68 -0
  7. package/dist/install.js +182 -0
  8. package/dist/payload.d.ts +8 -0
  9. package/dist/payload.js +19 -0
  10. package/package.json +4 -1
  11. package/skills/eval-answer/SKILL.md +2 -3
  12. package/skills/eval-answer/reference/ledger-schema.md +8 -5
  13. package/skills/eval-answer/scripts/check_findable.py +121 -53
  14. package/skills/eval-answer/scripts/check_findable_test.py +153 -0
  15. package/skills/eval-answer/scripts/golden_rows.py +164 -0
  16. package/skills/eval-answer/scripts/golden_rows_test.py +178 -0
  17. package/skills/eval-answer/scripts/init_truth_package.py +7 -0
  18. package/skills/eval-answer/scripts/init_truth_package_test.py +20 -0
  19. package/skills/eval-answer/scripts/ledger.py +10 -5
  20. package/skills/eval-answer/scripts/ledger_test.py +11 -0
  21. package/skills/eval-answer/scripts/mcp_payload.py +13 -0
  22. package/skills/eval-answer/scripts/publisher_rest.py +15 -5
  23. package/skills/eval-answer/scripts/publisher_rest_test.py +20 -0
  24. package/skills/eval-answer/scripts/verify_goldens.py +39 -5
  25. package/skills/eval-answer/scripts/verify_goldens_test.py +102 -0
  26. package/skills/eval-diagnose/SKILL.md +2 -2
  27. package/skills/eval-diagnose/scripts/diagnose.py +16 -5
  28. package/skills/eval-diagnose/scripts/diagnose_test.py +19 -0
  29. package/skills/eval-import/SKILL.md +1 -1
  30. package/skills/eval-improve/SKILL.md +1 -1
  31. package/skills/eval-improve/scripts/improve.py +18 -4
  32. package/skills/eval-improve/scripts/improve_test.py +19 -0
  33. package/skills/eval-judge/SKILL.md +1 -1
  34. package/skills/eval-loop/SKILL.md +6 -4
  35. package/skills/eval-loop/reference/measurement.md +10 -6
  36. package/skills/eval-loop/reference/running-a-run.md +6 -6
  37. package/skills/eval-loop/scripts/agent_harness.py +1 -1
  38. package/skills/eval-loop/scripts/agent_harness_test.py +73 -17
  39. package/skills/eval-loop/scripts/build_run_package.py +21 -6
  40. package/skills/eval-loop/scripts/build_run_package_test.py +38 -1
  41. package/skills/eval-loop/scripts/check_judge.py +16 -3
  42. package/skills/eval-loop/scripts/check_judge_test.py +15 -0
  43. package/skills/eval-loop/scripts/flip_table.py +34 -6
  44. package/skills/eval-loop/scripts/flip_table_test.py +27 -1
  45. package/skills/eval-loop/scripts/run_baseline.py +326 -88
  46. package/skills/eval-loop/scripts/run_baseline_test.py +386 -1
  47. package/skills/eval-loop/scripts/serve.py +63 -40
  48. package/skills/eval-loop/scripts/serve_test.py +62 -4
  49. package/skills/eval-loop/templates/eval-run-package/eval_run.malloy +3 -0
  50. package/skills/eval-loop/templates/eval-run-package/public/app.js +2 -2
  51. package/skills/eval-report/SKILL.md +1 -1
  52. package/skills/malloy-analysis/SKILL.md +38 -34
  53. package/skills/malloy-analysis-report/SKILL.md +4 -4
  54. package/skills/malloy-charts/SKILL.md +33 -11
  55. package/skills/malloy-dashboards/SKILL.md +92 -122
  56. package/skills/malloy-define/SKILL.md +24 -6
  57. package/skills/malloy-discover/SKILL.md +23 -11
  58. package/skills/malloy-document/SKILL.md +2 -2
  59. package/skills/malloy-getting-started/SKILL.md +15 -2
  60. package/skills/malloy-gotchas-modeling/SKILL.md +34 -8
  61. package/skills/malloy-html-data-apps/SKILL.md +12 -12
  62. package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/archetypes.md +1 -1
  63. package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/depth-patterns.md +1 -1
  64. package/skills/{malloy-data-app-design/SKILL.md → malloy-html-data-apps/reference/design.md} +0 -5
  65. package/skills/{malloy-html-data-app-embedding/SKILL.md → malloy-html-data-apps/reference/embedding.md} +0 -5
  66. package/skills/{malloy-html-data-app-runtime/SKILL.md → malloy-html-data-apps/reference/runtime.md} +0 -5
  67. package/skills/malloy-html-data-apps/reference/verification-harness.md +2 -2
  68. package/skills/malloy-lookml-review/SKILL.md +1 -1
  69. package/skills/malloy-lookml-review/reference/propose-fields.md +1 -1
  70. package/skills/malloy-materialization/SKILL.md +4 -2
  71. package/skills/{malloy-materialization-tuning/SKILL.md → malloy-materialization/reference/tuning.md} +1 -7
  72. package/skills/malloy-model/SKILL.md +3 -4
  73. package/skills/malloy-model/reference/analysis-to-model.md +1 -1
  74. package/skills/malloy-model/reference/bridge-tables.md +13 -4
  75. package/skills/malloy-model-as-you-go/SKILL.md +5 -5
  76. package/skills/malloy-modeling/SKILL.md +14 -14
  77. package/skills/malloy-notebooks/SKILL.md +6 -4
  78. package/skills/malloy-phrase-detection/SKILL.md +2 -2
  79. package/skills/malloy-powerbi-review/SKILL.md +1 -1
  80. package/skills/malloy-publish/SKILL.md +5 -5
  81. package/skills/malloy-queries/SKILL.md +50 -4
  82. package/skills/malloy-review/SKILL.md +1 -1
  83. package/skills/malloy-review/reference/rubric-correctness.md +6 -6
  84. package/skills/malloy-review/reference/rubric-documentation.md +1 -1
  85. package/skills/malloy-review/reference/rubric-queries.md +3 -3
  86. package/skills/malloy-review/reference/rubric-rendering.md +6 -6
  87. package/skills/malloy-review/reference/severity-taxonomy.md +1 -1
  88. package/skills/malloy-source-unreachable/SKILL.md +4 -4
  89. package/skills/malloy/SKILL.md +0 -104
  90. package/skills/malloy-analysis-pitfalls/SKILL.md +0 -89
  91. package/skills/malloy-analyze/SKILL.md +0 -271
  92. package/skills/malloy-debug/SKILL.md +0 -129
  93. package/skills/malloy-gotchas-queries/SKILL.md +0 -211
  94. package/skills/malloy-gotchas-rendering/SKILL.md +0 -129
  95. package/skills/malloy-notebook-chat/SKILL.md +0 -25
  96. package/skills/malloy-patterns/SKILL.md +0 -122
  97. package/skills/malloy-scope/SKILL.md +0 -112
  98. /package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/token-contract.md +0 -0
  99. /package/skills/{malloy-data-app-design → malloy-html-data-apps}/reference/visual-craft.md +0 -0
package/README.md CHANGED
@@ -5,6 +5,31 @@ SPDX-License-Identifier: MIT
5
5
 
6
6
  # @malloy-publisher/skills
7
7
 
8
+ ## Start here
9
+
10
+ 1. **Learn.** Read the getting-started skill. Nothing to install, no server:
11
+
12
+ <https://unpkg.com/@malloy-publisher/skills@latest/skills/malloy-getting-started/SKILL.md>
13
+
14
+ Every skill reads the same way. Put its name in place of `malloy-getting-started`.
15
+
16
+ 2. **Install.** If your agent has a shell, copy every skill to where it loads them:
17
+
18
+ ```bash
19
+ npx -y @malloy-publisher/skills@latest install # into this project
20
+ npx -y @malloy-publisher/skills@latest install --global # into your home directory
21
+ npx -y @malloy-publisher/skills@latest list # every skill, with a description
22
+ ```
23
+
24
+ Keep the `@latest`. Without it, npx may reuse an old cached copy and install old skills.
25
+
26
+ `install` picks the agent from what it finds. A `CLAUDE.md` means `.claude/skills/`. An
27
+ `AGENTS.md` or `.cursor/` means `.agents/skills/`. With `--global` it looks for `~/.claude/`,
28
+ `~/.agents/`, or `~/.cursor/` instead. If it finds none, it stops and asks you to name one:
29
+ `install claude` or `install agents`.
30
+
31
+ ## What this package is
32
+
8
33
  The agent skills that ship with [Malloy Publisher](https://github.com/malloydata/publisher), as
9
34
  files you can install. They are task-specific guides for writing Malloy, building and reviewing
10
35
  models, exploring data, and authoring HTML data apps.
@@ -14,7 +39,7 @@ files. This package exists for the cases MCP does not cover: a host that reads s
14
39
  (Claude Code and the Publisher plugin), a scaffolder that installs them into a new project, or any
15
40
  tool that wants the files themselves rather than prompt text.
16
41
 
17
- ## Install
42
+ ## Use the files from code
18
43
 
19
44
  ```bash
20
45
  npm install @malloy-publisher/skills
@@ -52,8 +77,7 @@ skill points to for detail it does not inline. Start with `malloy-getting-starte
52
77
  `malloy-modeling` to build or change a model, `malloy-analysis` to explore and answer questions, and
53
78
  `malloy-review` to check Malloy for correctness.
54
79
 
55
- A few skills (`malloy-modeling`, `malloy-publish`, `malloy-document`, `malloy-getting-started`, and
56
- the `malloy` index) are written for a Publisher host and name Publisher's MCP tools
80
+ A few skills (`malloy-modeling`, `malloy-publish`, `malloy-document`, and `malloy-getting-started`) are written for a Publisher host and name Publisher's MCP tools
57
81
  directly. The rest describe Malloy itself and refer to tools by bare name (`get_context`,
58
82
  `execute_query`, `search_malloy_docs`), because the prefix depends on the
59
83
  host. `search_database_schema` maps the same way if a shared skill starts using it. On Publisher those are `get_context`, `execute_query`, `search_malloy_docs`, and
package/dist/cli.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /** Where each host looks for skills, relative to a project or to the home dir. */
3
+ declare const TARGETS: Record<string, string>;
4
+ /**
5
+ * What says a host is set up, per scope. The two differ because the hosts keep
6
+ * their user-level config in a directory rather than a top-level file: Claude
7
+ * Code reads `~/.claude/CLAUDE.md`, never `~/CLAUDE.md`, so the project markers
8
+ * would miss almost everyone who has Claude set up.
9
+ */
10
+ declare const MARKERS: Record<"project" | "global", Record<string, string[]>>;
11
+ /**
12
+ * Where a skill can be read with nothing installed. `@latest` rather than this
13
+ * package's own version, so a link copied from an old cached copy of this CLI
14
+ * still serves the current skill.
15
+ */
16
+ declare function skillUrl(name: string): string;
17
+ /**
18
+ * Which hosts this directory is set up for.
19
+ *
20
+ * Detection only ever chooses between hosts the user already has; it never
21
+ * decides *whether* to write. When it finds nothing, install asks rather than
22
+ * picking one, because a skills directory in the wrong place is invisible: the
23
+ * agent simply never loads them and nothing says why.
24
+ */
25
+ declare function detectTargets(root: string, scope: keyof typeof MARKERS): string[];
26
+ declare function main(argv: string[]): void;
27
+ export { TARGETS, detectTargets, main, skillUrl };
package/dist/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) Credible Data Inc.
3
+ // SPDX-License-Identifier: MIT
4
+ /**
5
+ * `malloy-skills` -- list the agent skills, and install them, with nothing
6
+ * running.
7
+ *
8
+ * This exists so an agent meeting Publisher for the first time can get the
9
+ * skills before it installs or starts anything. Every other route to them is
10
+ * gated: the MCP prompt channel needs the server up, and the server is a large
11
+ * install that clones its example packages over the network.
12
+ *
13
+ * Reading one skill needs no command at all: the package ships the files, so
14
+ * unpkg serves each SKILL.md at a stable URL (`skillUrl`). That works in a chat
15
+ * with no shell, which `npx` cannot. This CLI covers what a URL cannot: listing
16
+ * what ships, and copying the whole tree, `reference/` files included, onto
17
+ * disk through the hardened copy in install.ts.
18
+ *
19
+ * Which is why this lives in @malloy-publisher/skills and not in the server or a
20
+ * new package: this one already ships the skill files, has no runtime
21
+ * dependencies and no native code, so `npx` is fast and cannot fail on a build
22
+ * step or a platform binary. Adding a CLI framework here would cost that, for
23
+ * two commands, so the argument parsing below is by hand.
24
+ */
25
+ import * as fs from "node:fs";
26
+ import * as os from "node:os";
27
+ import * as path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { listSkills } from "./index.js";
30
+ import { installSkills } from "./install.js";
31
+ import { skillsDir } from "./payload.js";
32
+ /** Where each host looks for skills, relative to a project or to the home dir. */
33
+ const TARGETS = {
34
+ claude: path.join(".claude", "skills"),
35
+ agents: path.join(".agents", "skills"),
36
+ };
37
+ /**
38
+ * What says a host is set up, per scope. The two differ because the hosts keep
39
+ * their user-level config in a directory rather than a top-level file: Claude
40
+ * Code reads `~/.claude/CLAUDE.md`, never `~/CLAUDE.md`, so the project markers
41
+ * would miss almost everyone who has Claude set up.
42
+ */
43
+ const MARKERS = {
44
+ project: { claude: ["CLAUDE.md"], agents: ["AGENTS.md", ".cursor"] },
45
+ global: { claude: [".claude"], agents: [".agents", ".cursor"] },
46
+ };
47
+ /**
48
+ * Where a skill can be read with nothing installed. `@latest` rather than this
49
+ * package's own version, so a link copied from an old cached copy of this CLI
50
+ * still serves the current skill.
51
+ */
52
+ function skillUrl(name) {
53
+ return `https://unpkg.com/@malloy-publisher/skills@latest/skills/${name}/SKILL.md`;
54
+ }
55
+ const USAGE = `malloy-skills - list and install the Malloy Publisher agent skills
56
+
57
+ Usage:
58
+ npx -y @malloy-publisher/skills@latest [list] List every skill.
59
+ npx -y @malloy-publisher/skills@latest install [host] Copy the skills onto disk.
60
+
61
+ Keep the @latest: without it npx may reuse an old cached copy.
62
+
63
+ Install targets:
64
+ claude .claude/skills/ (detected from a CLAUDE.md)
65
+ agents .agents/skills/ (detected from an AGENTS.md or .cursor/)
66
+ --global install into your home directory instead of this project
67
+ (detected from ~/.claude/, ~/.agents/, or ~/.cursor/)
68
+
69
+ Read one skill without installing anything:
70
+ ${skillUrl("<name>")}
71
+
72
+ Nothing here needs a running server or an account.`;
73
+ function fail(message) {
74
+ console.error(message);
75
+ process.exit(1);
76
+ }
77
+ /** Print the name and description of every shipped skill. */
78
+ function list() {
79
+ const skills = listSkills();
80
+ if (skills.length === 0) {
81
+ fail(`No skills found in ${skillsDir}. This install of ` +
82
+ `@malloy-publisher/skills is incomplete; reinstall and try again.`);
83
+ }
84
+ for (const skill of skills) {
85
+ console.log(skill.name);
86
+ console.log(` ${skill.description}`);
87
+ }
88
+ console.log("");
89
+ console.log(`Read one: ${skillUrl("<name>")}`);
90
+ console.log("Install them all: npx -y @malloy-publisher/skills@latest install");
91
+ }
92
+ /**
93
+ * Which hosts this directory is set up for.
94
+ *
95
+ * Detection only ever chooses between hosts the user already has; it never
96
+ * decides *whether* to write. When it finds nothing, install asks rather than
97
+ * picking one, because a skills directory in the wrong place is invisible: the
98
+ * agent simply never loads them and nothing says why.
99
+ */
100
+ function detectTargets(root, scope) {
101
+ return Object.entries(MARKERS[scope])
102
+ .filter(([, markers]) => markers.some((marker) => fs.existsSync(path.join(root, marker))))
103
+ .map(([host]) => host);
104
+ }
105
+ /** Say what an install did, including what it cost. */
106
+ function report(target, dir, result) {
107
+ if (result.refused) {
108
+ fail(`Did not install into ${dir}: ${result.refused}.`);
109
+ }
110
+ console.log(`Installed ${result.installed} skills into ${dir}`);
111
+ if (result.skipped.length > 0) {
112
+ console.log(` left alone (symlinked): ${result.skipped.join(", ")}`);
113
+ }
114
+ if (result.refreshed.length > 0) {
115
+ console.log(` replaced: ${result.refreshed.join(", ")}`);
116
+ }
117
+ // Not a footnote: these are files the user put there that no copy puts back.
118
+ for (const gone of result.removed) {
119
+ console.log(` removed (not in the shipped skill): ${target}/${gone}`);
120
+ }
121
+ }
122
+ function install(hosts, global) {
123
+ const root = global ? os.homedir() : process.cwd();
124
+ const scope = global ? "global" : "project";
125
+ let chosen = hosts;
126
+ if (chosen.length === 0) {
127
+ chosen = detectTargets(root, scope);
128
+ if (chosen.length === 0) {
129
+ const looked = Object.values(MARKERS[scope]).flat().join(", ");
130
+ const flag = global ? " --global" : "";
131
+ fail(`Could not tell which agent to install for: no ${looked} in ${root}.\n` +
132
+ `Name one: npx -y @malloy-publisher/skills@latest install claude${flag} (or: install agents${flag})`);
133
+ }
134
+ }
135
+ for (const host of chosen) {
136
+ const dir = path.join(root, TARGETS[host]);
137
+ report(host, dir, installSkills(dir, root));
138
+ }
139
+ }
140
+ function main(argv) {
141
+ const global = argv.includes("--global");
142
+ const args = argv.filter((arg) => arg !== "--global");
143
+ const [command = "list", ...rest] = args;
144
+ if (command === "--help" || command === "-h" || command === "help") {
145
+ console.log(USAGE);
146
+ return;
147
+ }
148
+ if (command === "install") {
149
+ for (const host of rest) {
150
+ if (!(host in TARGETS)) {
151
+ fail(`Unknown install target '${host}'. ` +
152
+ `Expected one of: ${Object.keys(TARGETS).join(", ")}.`);
153
+ }
154
+ }
155
+ return install(rest, global);
156
+ }
157
+ // Anything else is refused rather than skipped: a silently ignored argument
158
+ // is how a typo turns into a command that looks like it worked.
159
+ if (command !== "list") {
160
+ // A skill name is the likeliest mistake, from an agent that expects this
161
+ // to print one, so it gets the URL that does.
162
+ const skill = listSkills().find((s) => s.name === command);
163
+ fail(skill
164
+ ? `There is no command to print a skill. Read it at ${skillUrl(command)}`
165
+ : `Unknown command or option '${command}'.\n\n${USAGE}`);
166
+ }
167
+ if (rest.length > 0) {
168
+ fail(`'list' takes no arguments, got: ${rest.join(" ")}.`);
169
+ }
170
+ if (global)
171
+ fail("--global only applies to install.");
172
+ return list();
173
+ }
174
+ // Only when run as the command, so importing this module for a test does not
175
+ // parse the test runner's own argv and exit the process.
176
+ if (process.argv[1] &&
177
+ realpath(process.argv[1]) === realpath(fileURLToPath(import.meta.url))) {
178
+ main(process.argv.slice(2));
179
+ }
180
+ function realpath(target) {
181
+ try {
182
+ return fs.realpathSync(target);
183
+ }
184
+ catch {
185
+ return target;
186
+ }
187
+ }
188
+ export { TARGETS, detectTargets, main, skillUrl };
package/dist/index.d.ts CHANGED
@@ -5,14 +5,6 @@ export interface Skill {
5
5
  /** Absolute path to the skill's directory: SKILL.md and any reference/. */
6
6
  dir: string;
7
7
  }
8
- /**
9
- * Absolute path to the directory holding the skill directories.
10
- *
11
- * Resolved from this module's own URL, which works both from dist/ once
12
- * installed and from src/ in the repo, since both sit one level under the
13
- * package root.
14
- */
15
- export declare const skillsDir: string;
16
8
  /**
17
9
  * Read every skill's frontmatter from the shipped files.
18
10
  *
@@ -20,3 +12,10 @@ export declare const skillsDir: string;
20
12
  * second copy of this metadata to regenerate or to drift.
21
13
  */
22
14
  export declare function listSkills(): Skill[];
15
+ /**
16
+ * The installer is re-exported here so `@malloy-publisher/skills` has one entry
17
+ * point: consumers get `skillsDir` to read from and `installSkills` to write
18
+ * with, without reaching into a subpath.
19
+ */
20
+ export { skillsDir } from "./payload.js";
21
+ export { countSkills, installSkills, isWithinDirectory, type SkillInstall, } from "./install.js";
package/dist/index.js CHANGED
@@ -6,15 +6,7 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
- /**
11
- * Absolute path to the directory holding the skill directories.
12
- *
13
- * Resolved from this module's own URL, which works both from dist/ once
14
- * installed and from src/ in the repo, since both sit one level under the
15
- * package root.
16
- */
17
- export const skillsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
9
+ import { skillsDir } from "./payload.js";
18
10
  function unquote(value) {
19
11
  return value.trim().replace(/^["']|["']$/g, "");
20
12
  }
@@ -47,3 +39,10 @@ export function listSkills() {
47
39
  // platforms and must not depend on the runtime's locale.
48
40
  return skills.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
49
41
  }
42
+ /**
43
+ * The installer is re-exported here so `@malloy-publisher/skills` has one entry
44
+ * point: consumers get `skillsDir` to read from and `installSkills` to write
45
+ * with, without reaching into a subpath.
46
+ */
47
+ export { skillsDir } from "./payload.js";
48
+ export { countSkills, installSkills, isWithinDirectory, } from "./install.js";
@@ -0,0 +1,68 @@
1
+ export interface SkillInstall {
2
+ /** How many skills were copied in. */
3
+ installed: number;
4
+ /** Skill names left alone because the target is a symlink. */
5
+ skipped: string[];
6
+ /** Skill names whose existing files the bundled copies replaced. */
7
+ refreshed: string[];
8
+ /**
9
+ * Paths that were inside a skill directory, are not in the bundled copy of
10
+ * that skill, and so were removed and not written back. Relative to the
11
+ * skills directory (`malloy-review/NOTES.md`), directories with a trailing
12
+ * separator.
13
+ *
14
+ * Reported separately from `refreshed` because they are a different loss.
15
+ * "Refreshed" is read as "my edits to the skill files were reverted", which
16
+ * is recoverable knowledge; a file the user put in the directory themselves
17
+ * is simply gone, and nothing named it.
18
+ */
19
+ removed: string[];
20
+ /** Why nothing at all was installed, when nothing was. */
21
+ refused?: string;
22
+ }
23
+ /**
24
+ * Copy every agent skill into targetDir as real files. The generated project is
25
+ * standalone and the user may move or commit it, so this is a recursive copy, not
26
+ * a symlink: a symlink into node_modules would dangle the moment the project is
27
+ * moved or its dependencies pruned. skillsDir already excludes non-shipping skills
28
+ * and the internal README, so a straight copy is clean.
29
+ *
30
+ * A skill directory in the target that is itself a symlink is left alone. That is
31
+ * how this repo's own contributors wire skills into their agent (skills/README.md
32
+ * tells them to), and copying through the link would overwrite the source of truth
33
+ * rather than the workspace.
34
+ *
35
+ * targetDir has to resolve inside workspaceRoot. A `.claude` that is itself a
36
+ * symlink elsewhere is not caught by the per-skill check above, and used to write
37
+ * every skill outside the workspace while reporting them installed.
38
+ *
39
+ * An existing skill directory is removed before the copy rather than copied over.
40
+ * fs.cpSync follows a symlink it finds at the destination, so checking only the
41
+ * skill directory itself left every path inside it writable through a link: a
42
+ * `.claude/skills/malloy-review/SKILL.md` linked elsewhere had the file it points
43
+ * at replaced, and a linked `reference/` (four shipped skills have one) had files
44
+ * written into whatever directory it named, all under a green
45
+ * "Installed N Malloy skills". `.claude/skills/` is shared agent territory where
46
+ * links are an expected state, so the check has to cover the whole subtree, and
47
+ * removing first is the only form of that which cannot be one level short.
48
+ *
49
+ * The copy is unconditional, so an edited skill is replaced on every run. That is
50
+ * deliberate (an existing workspace picks up skill updates), but it is reported:
51
+ * `refreshed` names what was overwritten. Because the directory is removed and
52
+ * not merged, that goes further than reverting an edit: a file the user added
53
+ * inside a skill directory is gone, and no bundled copy puts it back. Those paths
54
+ * are collected into `removed` so the caller can name them, rather than leaving
55
+ * them under a word ("refreshed") that reads as "your edits were reverted". Note
56
+ * the copy never prunes whole skills, so one dropped from a later release stays in
57
+ * the workspace until deleted by hand.
58
+ */
59
+ export declare function installSkills(targetDir: string, workspaceRoot: string): SkillInstall;
60
+ /** Count directories under dir that contain a SKILL.md, i.e. real skills. */
61
+ export declare function countSkills(dir: string): number;
62
+ /**
63
+ * Whether target, which need not exist yet, names a path inside root once every
64
+ * symlink on the way has been followed. A path utility rather than a skills
65
+ * concept, but it lives beside the only call that needs it, and exporting it
66
+ * keeps the scaffolder's own path checks on the same implementation.
67
+ */
68
+ export declare function isWithinDirectory(target: string, root: string): boolean;
@@ -0,0 +1,182 @@
1
+ // Copyright (c) Credible Data Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { skillsDir } from "./payload.js";
6
+ /**
7
+ * Copy every agent skill into targetDir as real files. The generated project is
8
+ * standalone and the user may move or commit it, so this is a recursive copy, not
9
+ * a symlink: a symlink into node_modules would dangle the moment the project is
10
+ * moved or its dependencies pruned. skillsDir already excludes non-shipping skills
11
+ * and the internal README, so a straight copy is clean.
12
+ *
13
+ * A skill directory in the target that is itself a symlink is left alone. That is
14
+ * how this repo's own contributors wire skills into their agent (skills/README.md
15
+ * tells them to), and copying through the link would overwrite the source of truth
16
+ * rather than the workspace.
17
+ *
18
+ * targetDir has to resolve inside workspaceRoot. A `.claude` that is itself a
19
+ * symlink elsewhere is not caught by the per-skill check above, and used to write
20
+ * every skill outside the workspace while reporting them installed.
21
+ *
22
+ * An existing skill directory is removed before the copy rather than copied over.
23
+ * fs.cpSync follows a symlink it finds at the destination, so checking only the
24
+ * skill directory itself left every path inside it writable through a link: a
25
+ * `.claude/skills/malloy-review/SKILL.md` linked elsewhere had the file it points
26
+ * at replaced, and a linked `reference/` (four shipped skills have one) had files
27
+ * written into whatever directory it named, all under a green
28
+ * "Installed N Malloy skills". `.claude/skills/` is shared agent territory where
29
+ * links are an expected state, so the check has to cover the whole subtree, and
30
+ * removing first is the only form of that which cannot be one level short.
31
+ *
32
+ * The copy is unconditional, so an edited skill is replaced on every run. That is
33
+ * deliberate (an existing workspace picks up skill updates), but it is reported:
34
+ * `refreshed` names what was overwritten. Because the directory is removed and
35
+ * not merged, that goes further than reverting an edit: a file the user added
36
+ * inside a skill directory is gone, and no bundled copy puts it back. Those paths
37
+ * are collected into `removed` so the caller can name them, rather than leaving
38
+ * them under a word ("refreshed") that reads as "your edits were reverted". Note
39
+ * the copy never prunes whole skills, so one dropped from a later release stays in
40
+ * the workspace until deleted by hand.
41
+ */
42
+ export function installSkills(targetDir, workspaceRoot) {
43
+ const empty = { installed: 0, skipped: [], refreshed: [], removed: [] };
44
+ if (isSymlink(targetDir)) {
45
+ return {
46
+ ...empty,
47
+ refused: "it is a symlink, so writing into it would edit whatever it points at",
48
+ };
49
+ }
50
+ if (!isWithinDirectory(targetDir, workspaceRoot)) {
51
+ return {
52
+ ...empty,
53
+ refused: "it resolves outside the workspace, so writing into it would put " +
54
+ "files somewhere else on this machine",
55
+ };
56
+ }
57
+ fs.mkdirSync(targetDir, { recursive: true });
58
+ const skipped = [];
59
+ const refreshed = [];
60
+ const removed = [];
61
+ let installed = 0;
62
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
63
+ if (!entry.isDirectory()) {
64
+ continue;
65
+ }
66
+ const source = path.join(skillsDir, entry.name);
67
+ if (!fs.existsSync(path.join(source, "SKILL.md"))) {
68
+ continue;
69
+ }
70
+ const target = path.join(targetDir, entry.name);
71
+ if (isSymlink(target)) {
72
+ skipped.push(entry.name);
73
+ continue;
74
+ }
75
+ if (fs.existsSync(target)) {
76
+ refreshed.push(entry.name);
77
+ // Read before the removal, because after it there is nothing to read:
78
+ // these are the paths the copy below does not put back.
79
+ for (const relative of pathsNotIn(target, source)) {
80
+ removed.push(path.join(entry.name, relative));
81
+ }
82
+ }
83
+ // target is not a symlink (checked above) and sits directly under a
84
+ // targetDir that resolves inside the workspace, so this removes files in
85
+ // the workspace and, for any symlink further down, the link itself rather
86
+ // than what it points at.
87
+ fs.rmSync(target, { recursive: true, force: true });
88
+ fs.cpSync(source, target, { recursive: true });
89
+ installed += 1;
90
+ }
91
+ return { installed, skipped, refreshed, removed };
92
+ }
93
+ /**
94
+ * Every path under target that source does not also hold, relative to target.
95
+ *
96
+ * A directory that source does not hold is named once and not walked: what the
97
+ * user has to look for is the directory, and walking it would turn one dropped
98
+ * folder into a hundred lines. Nothing here follows a link out of target either,
99
+ * because a symlink is compared by its own path, never by what it points at.
100
+ */
101
+ function pathsNotIn(target, source, prefix = "") {
102
+ let entries;
103
+ try {
104
+ entries = fs.readdirSync(path.join(target, prefix), {
105
+ withFileTypes: true,
106
+ });
107
+ }
108
+ catch {
109
+ // Unreadable: nothing can be said about what is in it.
110
+ return [];
111
+ }
112
+ const missing = [];
113
+ for (const entry of entries) {
114
+ const relative = path.join(prefix, entry.name);
115
+ if (!fs.existsSync(path.join(source, relative))) {
116
+ missing.push(entry.isDirectory() ? relative + path.sep : relative);
117
+ continue;
118
+ }
119
+ if (entry.isDirectory()) {
120
+ missing.push(...pathsNotIn(target, source, relative));
121
+ }
122
+ }
123
+ return missing;
124
+ }
125
+ /** Count directories under dir that contain a SKILL.md, i.e. real skills. */
126
+ export function countSkills(dir) {
127
+ if (!fs.existsSync(dir)) {
128
+ return 0;
129
+ }
130
+ return fs
131
+ .readdirSync(dir, { withFileTypes: true })
132
+ .filter((entry) => entry.isDirectory() &&
133
+ fs.existsSync(path.join(dir, entry.name, "SKILL.md"))).length;
134
+ }
135
+ /**
136
+ * Whether target, which need not exist yet, names a path inside root once every
137
+ * symlink on the way has been followed. A path utility rather than a skills
138
+ * concept, but it lives beside the only call that needs it, and exporting it
139
+ * keeps the scaffolder's own path checks on the same implementation.
140
+ */
141
+ export function isWithinDirectory(target, root) {
142
+ const realRoot = realPathOf(root);
143
+ const realTarget = realPathOf(target);
144
+ return realTarget === realRoot || realTarget.startsWith(realRoot + path.sep);
145
+ }
146
+ /**
147
+ * The path target really names. fs.realpathSync cannot be used: it throws on a
148
+ * path that does not exist yet (every file we are about to create) and on a
149
+ * dangling symlink, which is precisely the case that matters, because
150
+ * fs.writeFileSync follows a dangling link and creates the file wherever it
151
+ * points. So resolve the parent first, then follow one link at a time.
152
+ */
153
+ function realPathOf(target, hops = 0) {
154
+ const resolved = path.resolve(target);
155
+ const parent = path.dirname(resolved);
156
+ if (parent === resolved) {
157
+ return resolved;
158
+ }
159
+ const here = path.join(realPathOf(parent, hops), path.basename(resolved));
160
+ if (hops >= 40) {
161
+ // A symlink cycle. Stop following and judge the path as it stands.
162
+ return here;
163
+ }
164
+ try {
165
+ if (!fs.lstatSync(here).isSymbolicLink()) {
166
+ return here;
167
+ }
168
+ return realPathOf(path.resolve(path.dirname(here), fs.readlinkSync(here)), hops + 1);
169
+ }
170
+ catch {
171
+ // Does not exist yet, or is unreadable: it is the path it looks like.
172
+ return here;
173
+ }
174
+ }
175
+ function isSymlink(target) {
176
+ try {
177
+ return fs.lstatSync(target).isSymbolicLink();
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Absolute path to the directory holding the skill directories.
3
+ *
4
+ * Resolved from this module's own URL, which works both from dist/ once
5
+ * installed and from src/ in the repo, since both sit one level under the
6
+ * package root.
7
+ */
8
+ export declare const skillsDir: string;
@@ -0,0 +1,19 @@
1
+ // Copyright (c) Credible Data Inc.
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Where the shipped skill files are.
5
+ *
6
+ * Its own module, rather than a constant in index.ts, because both the reader
7
+ * (`listSkills`) and the writer (`installSkills`) need it and index.ts re-exports
8
+ * the writer: importing it from there would make the two modules a cycle.
9
+ */
10
+ import * as path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ /**
13
+ * Absolute path to the directory holding the skill directories.
14
+ *
15
+ * Resolved from this module's own URL, which works both from dist/ once
16
+ * installed and from src/ in the repo, since both sit one level under the
17
+ * package root.
18
+ */
19
+ export const skillsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@malloy-publisher/skills",
3
3
  "description": "Agent skills for Malloy and the Malloy Publisher",
4
- "version": "0.1.34",
4
+ "version": "0.1.36",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
+ "bin": {
10
+ "malloy-skills": "dist/cli.js"
11
+ },
9
12
  "exports": {
10
13
  ".": {
11
14
  "types": "./dist/index.d.ts",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: eval-answer
3
3
  description: 'Score one analytical answer against a verified golden, and score which of the entities the golden depends on retrieval delivered to the answerer. Run the contamination checklist, re-execute the submitted query yourself, then spawn a judge subagent per skill:eval-judge. Append attempt, tool_call, score, and retrieval_score events to the file ledger (reference/ledger-schema.md). Never explain the failure (eval-diagnose) or edit the model (eval-improve). Use when asked whether an answer was correct, to score a run, or to baseline a model.'
4
- version: 0.1.34
4
+ version: 0.1.36
5
5
  ---
6
6
 
7
7
  # Evaluate One Answer
@@ -290,5 +290,4 @@ original answerer to "try again" with the new key in context.
290
290
 
291
291
  - `skill:eval-diagnose`: why it failed, after this record exists.
292
292
  - `skill:eval-improve`: smallest model edit, model-owned issues only.
293
- - The `malloy-analysis-pitfalls` skill: checks before you trust a result you ran.
294
- The judge loads it by name; it is not part of the `eval` group.
293
+ - Step 5 of the `malloy-analysis` skill: checks before you trust a result you ran.