speccle 0.11.0 → 0.12.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/README.md CHANGED
@@ -1,14 +1,23 @@
1
1
  # speccle
2
2
 
3
- The deterministic tooling the skills invoke: one bin, three commands —
3
+ The deterministic tooling the skills invoke one bin:
4
4
 
5
5
  ```
6
+ speccle init # record repo facts + vendor the skills into .claude/skills/
7
+ speccle doctor # report staleness across the CLI, skills, and strength stack
8
+ speccle update # refresh the vendored skills; print the CLI + stack fix commands
6
9
  speccle lint # enforce the convention over a repo's specs
7
10
  speccle claims # join criteria to the test names that claim them
8
11
  speccle strength # oracle-strength heatmap: per-criterion killed ÷ covered
9
12
  speccle strength init # provision the strength stack into a target
10
13
  ```
11
14
 
15
+ - `init` / `doctor` / `update` — the setup and staleness surface: `init` records the
16
+ repo's test facts in `.speccle/config.json` and vendors the skills into
17
+ `.claude/skills/`; `doctor` reports whether those skills and the strength stack still
18
+ match this CLI; `update` refreshes the skills forward and prints the CLI + stack fix
19
+ commands. See [Install](https://github.com/matthewalton/speccle/blob/main/README.md#install)
20
+ and [Updating](https://github.com/matthewalton/speccle/blob/main/README.md#updating).
12
21
  - `lint` — enforce the [convention](https://github.com/matthewalton/speccle/blob/main/docs/convention.md) over a repo's specs.
13
22
  - `claims` — join every criterion to the test names carrying its id, statically. No
14
23
  reports needed, so it is cheap enough to gate on.
package/dist/claims.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { readConfig } from "./config.js";
3
4
  import { DEFAULT_DIALECT, resolveDialect } from "./dialects.js";
4
5
  import { discoverSpecs, discoverTests } from "./discover.js";
5
6
  import { compareCriterionIds, parseSpec, readClaimedIds } from "./spec.js";
6
7
  export async function claims(target, options = {}) {
7
- const dialect = resolveDialect(options.dialect ?? DEFAULT_DIALECT);
8
8
  const root = resolve(target);
9
9
  if (!(await isDirectory(root)))
10
10
  throw new Error(`path not found: ${target}`);
11
+ // An explicit --dialect wins outright; otherwise `.speccle/config.json` is the source
12
+ // of truth, falling back to the default only when the repo has recorded nothing.
13
+ const declared = options.dialect ?? (await readConfig(root))?.dialect;
14
+ const dialect = resolveDialect(declared ?? DEFAULT_DIALECT);
11
15
  const specFiles = await discoverSpecs(root);
12
16
  const specs = await Promise.all(specFiles.map(async (file) => parseSpec(await readFile(join(root, file), "utf8"), file)));
13
17
  const criteria = new Map();
package/dist/cli.js CHANGED
@@ -1,18 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  import { check } from "./check.js";
3
3
  import { claims } from "./claims.js";
4
+ import { initConfig } from "./config.js";
4
5
  import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
5
- import { init } from "./init.js";
6
+ import { doctor } from "./doctor.js";
7
+ import { init, ownVersion } from "./init.js";
6
8
  import { lint } from "./lint.js";
7
- import { renderCheck, renderClaims, renderHuman, renderInit, renderStrength } from "./render.js";
9
+ import { renderCheck, renderClaims, renderConfigInit, renderDoctor, renderHuman, renderInit, renderSkillsInit, renderStrength, renderUpdate, } from "./render.js";
10
+ import { materializeSkills } from "./skills.js";
8
11
  import { DEFAULT_COVERAGE_SUMMARY, DEFAULT_MUTATION_REPORT, strength } from "./strength.js";
12
+ import { update } from "./update.js";
9
13
  const USAGE = `Usage: speccle <command> [options]
10
14
 
11
15
  Commands:
16
+ init [path] [--json] Record repo facts in .speccle/config.json and materialize
17
+ the skills into .claude/skills/
18
+ doctor [path] [--json] Report staleness across the CLI, skills, and strength stack
19
+ update [path] [--json] Refresh the skills as a diff; report the stack and binary fixes
12
20
  lint [path] [--json] Lint every SPEC.md under path (default: current directory)
13
21
  claims [path] [--json] Join criteria to the test names that claim them — no reports needed
14
22
  strength [path] [--json] Oracle-strength heatmap: per-criterion killed ÷ covered
15
23
  strength init [path] [--json] Provision the strength stack: devDependencies + configs
24
+ --version, -v Print the installed CLI version
16
25
 
17
26
  claims options:
18
27
  --dialect <name> Test dialect: ${DIALECT_NAMES.join(", ")} (default: ${DEFAULT_DIALECT})
@@ -30,12 +39,22 @@ strength init options:
30
39
  Exit codes: 0 clean, 1 violations, 2 usage error`;
31
40
  async function main(argv) {
32
41
  const [command, ...rest] = argv;
42
+ if (command === "--version" || command === "-v") {
43
+ console.log(await ownVersion());
44
+ return 0;
45
+ }
46
+ if (command === "init")
47
+ return runInit(rest);
48
+ if (command === "doctor")
49
+ return runDoctor(rest);
50
+ if (command === "update")
51
+ return runUpdate(rest);
33
52
  if (command === "lint")
34
53
  return runLint(rest);
35
54
  if (command === "claims")
36
55
  return runClaims(rest);
37
56
  if (command === "strength" && rest[0] === "init")
38
- return runInit(rest.slice(1));
57
+ return runStrengthInit(rest.slice(1));
39
58
  if (command === "strength")
40
59
  return runStrength(rest);
41
60
  console.error(USAGE);
@@ -79,6 +98,62 @@ async function runClaims(args) {
79
98
  console.log(json ? JSON.stringify(report, null, 2) : renderClaims(report));
80
99
  return report.clean ? 0 : 1;
81
100
  }
101
+ async function runDoctor(args) {
102
+ let json = false;
103
+ const positional = [];
104
+ for (const arg of args) {
105
+ if (arg === "--json")
106
+ json = true;
107
+ else if (arg.startsWith("-")) {
108
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
109
+ return 2;
110
+ }
111
+ else
112
+ positional.push(arg);
113
+ }
114
+ if (positional.length > 1) {
115
+ console.error(`doctor takes at most one path\n\n${USAGE}`);
116
+ return 2;
117
+ }
118
+ let report;
119
+ try {
120
+ report = await doctor(positional[0] ?? ".");
121
+ }
122
+ catch (err) {
123
+ console.error(message(err));
124
+ return 2;
125
+ }
126
+ console.log(json ? JSON.stringify(report, null, 2) : renderDoctor(report));
127
+ return report.ok ? 0 : 1;
128
+ }
129
+ async function runUpdate(args) {
130
+ let json = false;
131
+ const positional = [];
132
+ for (const arg of args) {
133
+ if (arg === "--json")
134
+ json = true;
135
+ else if (arg.startsWith("-")) {
136
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
137
+ return 2;
138
+ }
139
+ else
140
+ positional.push(arg);
141
+ }
142
+ if (positional.length > 1) {
143
+ console.error(`update takes at most one path\n\n${USAGE}`);
144
+ return 2;
145
+ }
146
+ let report;
147
+ try {
148
+ report = await update(positional[0] ?? ".");
149
+ }
150
+ catch (err) {
151
+ console.error(message(err));
152
+ return 2;
153
+ }
154
+ console.log(json ? JSON.stringify(report, null, 2) : renderUpdate(report));
155
+ return 0;
156
+ }
82
157
  async function runLint(args) {
83
158
  let json = false;
84
159
  const positional = [];
@@ -172,6 +247,46 @@ async function runStrength(args) {
172
247
  return 0;
173
248
  }
174
249
  async function runInit(args) {
250
+ let json = false;
251
+ const positional = [];
252
+ for (const arg of args) {
253
+ if (arg === "--json")
254
+ json = true;
255
+ else if (arg.startsWith("-")) {
256
+ console.error(`Unknown option: ${arg}\n\n${USAGE}`);
257
+ return 2;
258
+ }
259
+ else
260
+ positional.push(arg);
261
+ }
262
+ if (positional.length > 1) {
263
+ console.error(`init takes at most one path\n\n${USAGE}`);
264
+ return 2;
265
+ }
266
+ const root = positional[0] ?? ".";
267
+ let config;
268
+ let skills;
269
+ try {
270
+ // Materialize first, then stamp the version onto the config — so the recorded anchor
271
+ // only ever names skills that actually landed on disk.
272
+ skills = await materializeSkills(root);
273
+ config = await initConfig(root, await ownVersion());
274
+ }
275
+ catch (err) {
276
+ console.error(message(err));
277
+ return 2;
278
+ }
279
+ if (json) {
280
+ console.log(JSON.stringify({ config, skills }, null, 2));
281
+ }
282
+ else {
283
+ console.log(renderConfigInit(config));
284
+ console.log("");
285
+ console.log(renderSkillsInit(skills));
286
+ }
287
+ return 0;
288
+ }
289
+ async function runStrengthInit(args) {
175
290
  let json = false;
176
291
  let skipInstall = false;
177
292
  const mutate = [];
package/dist/config.js ADDED
@@ -0,0 +1,114 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { DEFAULT_DIALECT, DIALECT_NAMES } from "./dialects.js";
4
+ import { detectPackageManager } from "./init.js";
5
+ /** Where Speccle reads a repo's facts (ADR-0040). */
6
+ export const CONFIG_DIR = ".speccle";
7
+ export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
8
+ /** Reads `.speccle/config.json`; undefined when the repo has no config yet. */
9
+ export async function readConfig(root) {
10
+ let raw;
11
+ try {
12
+ raw = await readFile(join(root, CONFIG_FILE), "utf8");
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ let config;
18
+ try {
19
+ config = JSON.parse(raw);
20
+ }
21
+ catch (err) {
22
+ throw new Error(`${CONFIG_FILE} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
23
+ }
24
+ // A wrong dialect must be a loud, visible failure — that is the whole point of writing
25
+ // it down rather than guessing every time (ADR-0040).
26
+ assertDialect(config.dialect);
27
+ for (const override of config.overrides ?? []) {
28
+ if (override.dialect !== undefined)
29
+ assertDialect(override.dialect);
30
+ }
31
+ return config;
32
+ }
33
+ /**
34
+ * The dialect and suite in force at a root-relative path. Every override whose path is a
35
+ * prefix of `path` applies, most-specific last, so a longer override wins per field and a
36
+ * shorter one still fills a field the longer omits.
37
+ */
38
+ export function resolveFacts(config, path) {
39
+ let { dialect, suite } = config;
40
+ const matching = (config.overrides ?? [])
41
+ .filter((override) => underPath(path, override.path))
42
+ .sort((a, b) => a.path.length - b.path.length);
43
+ for (const override of matching) {
44
+ if (override.dialect !== undefined)
45
+ dialect = override.dialect;
46
+ if (override.suite !== undefined)
47
+ suite = override.suite;
48
+ }
49
+ return { dialect, suite };
50
+ }
51
+ /** Speccle's first guess at a repo's facts — written down, then corrected by hand. */
52
+ export async function detectConfig(root) {
53
+ const dialect = await detectDialect(root);
54
+ return { dialect, suite: await detectSuite(root, dialect) };
55
+ }
56
+ /**
57
+ * Writes `.speccle/config.json` from detection, or keeps an existing one untouched — the
58
+ * written record is the source of truth, never the detection (ADR-0040), the same posture
59
+ * `strength init` takes with the stack it provisions.
60
+ *
61
+ * When `skillsVersion` is given, it is (re)stamped even on the kept path: materialization
62
+ * always refreshes the skills to the current version, so the anchor must move with them or
63
+ * a re-run would leave the recorded version behind the files it just wrote. The facts
64
+ * themselves stay kept — only the stamp follows the skills.
65
+ */
66
+ export async function initConfig(root, skillsVersion) {
67
+ const existing = await readConfig(root);
68
+ if (existing !== undefined) {
69
+ const config = skillsVersion !== undefined ? { ...existing, skillsVersion } : existing;
70
+ if (skillsVersion !== undefined && existing.skillsVersion !== skillsVersion) {
71
+ await writeConfig(root, config);
72
+ }
73
+ return { root, file: CONFIG_FILE, action: "kept", config };
74
+ }
75
+ const detected = await detectConfig(root);
76
+ const config = skillsVersion !== undefined ? { ...detected, skillsVersion } : detected;
77
+ await writeConfig(root, config);
78
+ return { root, file: CONFIG_FILE, action: "written", config };
79
+ }
80
+ export async function writeConfig(root, config) {
81
+ await mkdir(join(root, CONFIG_DIR), { recursive: true });
82
+ await writeFile(join(root, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n");
83
+ }
84
+ async function detectDialect(root) {
85
+ if (await exists(join(root, "Package.swift")))
86
+ return "swift";
87
+ // package.json or nothing: the TypeScript stack is the default dialect.
88
+ return DEFAULT_DIALECT;
89
+ }
90
+ // The default suite runs the repo's own `test` script, except a swift repo, whose
91
+ // SwiftPM default cannot know an xcodebuild scheme (ADR-0040) — the human corrects it.
92
+ async function detectSuite(root, dialect) {
93
+ if (dialect === "swift")
94
+ return "swift test";
95
+ const packageManager = await detectPackageManager(root);
96
+ return packageManager === "bun" ? "bun run test" : `${packageManager} test`;
97
+ }
98
+ function assertDialect(name) {
99
+ if (!DIALECT_NAMES.includes(name)) {
100
+ throw new Error(`${CONFIG_FILE}: unknown test dialect: ${name} — known dialects: ${DIALECT_NAMES.join(", ")}`);
101
+ }
102
+ }
103
+ function underPath(path, prefix) {
104
+ return path === prefix || path.startsWith(`${prefix}/`);
105
+ }
106
+ async function exists(path) {
107
+ try {
108
+ await access(path);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,127 @@
1
+ import { access, readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ import { readConfig } from "./config.js";
4
+ import { ownVersion, STRENGTH_DEPS, STRYKER_CONFIG_NAMES } from "./init.js";
5
+ import { SKILLS_DIR } from "./skills.js";
6
+ /**
7
+ * Reports the truth about the three things that drift in a Speccle consumer — the CLI, the
8
+ * committed skills, and the strength stack — and never mutates a byte (#182, ADR-0046).
9
+ * Offline and deterministic: it compares the repo against the installed CLI, not against
10
+ * the npm registry — "is there a newer release" is `update`'s network job, not this one's.
11
+ */
12
+ export async function doctor(target) {
13
+ const root = resolve(target);
14
+ if (!(await isDirectory(root)))
15
+ throw new Error(`path not found: ${target}`);
16
+ const cli = await ownVersion();
17
+ const config = await readConfig(root);
18
+ const recorded = config?.skillsVersion ?? null;
19
+ const skillsStatus = deriveSkillsStatus(await hasSkills(root), recorded, cli);
20
+ const provisioned = await anyPresent(root, STRYKER_CONFIG_NAMES);
21
+ const declared = await declaredDeps(root);
22
+ const deps = STRENGTH_DEPS.map((spec) => checkDep(spec, declared));
23
+ const stackStatus = !provisioned
24
+ ? "absent"
25
+ : deps.some((dep) => dep.status !== "ok")
26
+ ? "drift"
27
+ : "current";
28
+ const ok = (skillsStatus === "current" || skillsStatus === "absent") && stackStatus !== "drift";
29
+ return {
30
+ root,
31
+ cli,
32
+ skills: { recorded, bundled: cli, status: skillsStatus },
33
+ stack: { provisioned, deps, status: stackStatus },
34
+ ok,
35
+ };
36
+ }
37
+ function deriveSkillsStatus(present, recorded, bundled) {
38
+ if (!present)
39
+ return "absent";
40
+ if (recorded === null)
41
+ return "unstamped";
42
+ const order = compareVersions(recorded, bundled);
43
+ return order === 0 ? "current" : order < 0 ? "stale" : "ahead";
44
+ }
45
+ function checkDep(spec, declared) {
46
+ const at = spec.lastIndexOf("@");
47
+ const name = spec.slice(0, at);
48
+ const wantedMajor = majorOf(spec.slice(at + 1)) ?? 0;
49
+ const range = declared.get(name);
50
+ if (range === undefined)
51
+ return { name, wantedMajor, declared: null, status: "missing" };
52
+ const declaredMajor = majorOf(range);
53
+ // A repo ahead of the preset is the consumer's choice, not drift to nag about; only a
54
+ // major below the preset is the "bump never propagated" bug this surface exists to catch.
55
+ const status = declaredMajor !== null && declaredMajor < wantedMajor ? "behind" : "ok";
56
+ return { name, wantedMajor, declared: range, status };
57
+ }
58
+ /** The union of a package.json's dependencies and devDependencies; empty when absent. */
59
+ async function declaredDeps(root) {
60
+ let raw;
61
+ try {
62
+ raw = await readFile(join(root, "package.json"), "utf8");
63
+ }
64
+ catch {
65
+ return new Map();
66
+ }
67
+ let pkg;
68
+ try {
69
+ pkg = JSON.parse(raw);
70
+ }
71
+ catch {
72
+ return new Map();
73
+ }
74
+ return new Map(Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }));
75
+ }
76
+ async function hasSkills(root) {
77
+ try {
78
+ const entries = await readdir(join(root, SKILLS_DIR), { withFileTypes: true });
79
+ return entries.some((entry) => entry.isDirectory());
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ async function anyPresent(root, names) {
86
+ for (const name of names) {
87
+ if (await exists(join(root, name)))
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+ /** The leading integer of a version or range — 4 from "^4", "4.1.0", ">=4.0.0". */
93
+ function majorOf(range) {
94
+ const digits = /\d+/.exec(range);
95
+ return digits === null ? null : Number(digits[0]);
96
+ }
97
+ function compareVersions(a, b) {
98
+ const [pa, pb] = [parseVersion(a), parseVersion(b)];
99
+ for (let i = 0; i < 3; i++) {
100
+ if (pa[i] !== pb[i])
101
+ return pa[i] < pb[i] ? -1 : 1;
102
+ }
103
+ return 0;
104
+ }
105
+ /** [major, minor, patch] of a clean semver, prerelease and build metadata dropped. */
106
+ function parseVersion(version) {
107
+ const core = version.split(/[-+]/)[0] ?? "";
108
+ const parts = core.split(".").map((part) => Number(part) || 0);
109
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
110
+ }
111
+ async function isDirectory(path) {
112
+ try {
113
+ return (await stat(path)).isDirectory();
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ async function exists(path) {
120
+ try {
121
+ await access(path);
122
+ return true;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
package/dist/init.js CHANGED
@@ -17,7 +17,7 @@ const LOCKFILES = [
17
17
  ["bun.lock", "bun"],
18
18
  ["bun.lockb", "bun"],
19
19
  ];
20
- const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
20
+ export const STRYKER_CONFIG_NAMES = ["stryker.config", "stryker.conf"].flatMap((base) => ["json", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
21
21
  const VITEST_CONFIG_NAMES = ["vitest.config", "vite.config"].flatMap((base) => ["ts", "mts", "cts", "js", "mjs", "cjs"].map((ext) => `${base}.${ext}`));
22
22
  export function strykerConfig(packageManager, mutate) {
23
23
  return {
@@ -109,7 +109,8 @@ export async function detectDoubleLoad(root, settingsFile = join(homedir(), ".cl
109
109
  return false;
110
110
  }
111
111
  }
112
- async function ownVersion() {
112
+ /** The installed CLI's own version — the `speccle@X` that ships this bin and its skills. */
113
+ export async function ownVersion() {
113
114
  const raw = await readFile(new URL("../package.json", import.meta.url), "utf8");
114
115
  return JSON.parse(raw).version;
115
116
  }
@@ -123,7 +124,7 @@ async function readPackageJson(root) {
123
124
  }
124
125
  return JSON.parse(raw);
125
126
  }
126
- async function detectPackageManager(root) {
127
+ export async function detectPackageManager(root) {
127
128
  for (const [lockfile, manager] of LOCKFILES) {
128
129
  if (await exists(join(root, lockfile)))
129
130
  return manager;
@@ -138,7 +139,7 @@ async function provision(root, existingNames, writeName, content) {
138
139
  await writeFile(join(root, writeName), content());
139
140
  return { file: writeName, action: "written" };
140
141
  }
141
- function installCommandFor(manager, deps) {
142
+ export function installCommandFor(manager, deps) {
142
143
  const subcommand = manager === "npm" ? "install -D" : manager === "bun" ? "add -d" : "add -D";
143
144
  return `${manager} ${subcommand} ${deps.join(" ")}`;
144
145
  }
package/dist/render.js CHANGED
@@ -1,3 +1,109 @@
1
+ export function renderConfigInit(report) {
2
+ const lines = [
3
+ report.action === "written"
4
+ ? `wrote ${report.file}`
5
+ : `kept ${report.file} — already present, left untouched`,
6
+ ` dialect ${report.config.dialect}`,
7
+ ` suite ${report.config.suite}`,
8
+ ];
9
+ for (const override of report.config.overrides ?? []) {
10
+ const facts = [
11
+ override.dialect !== undefined ? `dialect ${override.dialect}` : undefined,
12
+ override.suite !== undefined ? `suite ${override.suite}` : undefined,
13
+ ].filter((fact) => fact !== undefined);
14
+ lines.push(` ${override.path}: ${facts.join(", ")}`);
15
+ }
16
+ lines.push("");
17
+ lines.push("these are facts about your repo, not judgement — edit .speccle/config.json by hand");
18
+ if (report.config.dialect === "swift" && report.config.suite === "swift test") {
19
+ lines.push("swift: set suite to your xcodebuild scheme if this is not a SwiftPM package");
20
+ }
21
+ return lines.join("\n");
22
+ }
23
+ export function renderSkillsInit(report) {
24
+ const lines = [`materialized ${plural(report.skills.length, "skill")} into ${report.dir}/`];
25
+ for (const { name, action } of report.skills) {
26
+ lines.push(` ${(action === "written" ? "wrote" : "refreshed").padEnd(9)} ${name}`);
27
+ }
28
+ lines.push("");
29
+ lines.push("these are generated files — commit them; re-run `speccle init` to refresh");
30
+ return lines.join("\n");
31
+ }
32
+ export function renderDoctor(report) {
33
+ const lines = [
34
+ `speccle ${report.cli}`,
35
+ "",
36
+ `skills ${describeSkills(report.skills)}`,
37
+ `stack ${describeStack(report.stack)}`,
38
+ ];
39
+ if (report.stack.status === "drift") {
40
+ const width = Math.max(...report.stack.deps.map((dep) => dep.name.length));
41
+ for (const dep of report.stack.deps) {
42
+ if (dep.status === "ok")
43
+ continue;
44
+ lines.push(` ${dep.name.padEnd(width)} ${describeDep(dep)}`);
45
+ }
46
+ }
47
+ lines.push("");
48
+ if (report.skills.status === "absent" && report.stack.status === "absent") {
49
+ lines.push("Speccle is not set up here — run `speccle init`");
50
+ }
51
+ else if (report.ok) {
52
+ lines.push("up to date");
53
+ }
54
+ else {
55
+ lines.push("out of date — run `speccle update`");
56
+ }
57
+ return lines.join("\n");
58
+ }
59
+ function describeSkills(skills) {
60
+ switch (skills.status) {
61
+ case "current":
62
+ return `current (${skills.bundled})`;
63
+ case "stale":
64
+ return `stale — committed ${skills.recorded}, this CLI ships ${skills.bundled}`;
65
+ case "ahead":
66
+ return `ahead — committed ${skills.recorded} is newer than this CLI (${skills.bundled})`;
67
+ case "unstamped":
68
+ return "present but unversioned — re-run `speccle init` to record the version";
69
+ case "absent":
70
+ return "not materialized — run `speccle init`";
71
+ }
72
+ }
73
+ function describeStack(stack) {
74
+ if (stack.status === "absent")
75
+ return "not provisioned — run `speccle strength init`";
76
+ if (stack.status === "current")
77
+ return "current";
78
+ return "drift — the stack is behind the current preset";
79
+ }
80
+ function describeDep(dep) {
81
+ return dep.status === "missing"
82
+ ? `missing — preset wants ^${dep.wantedMajor}`
83
+ : `behind — has ${dep.declared}, preset wants ^${dep.wantedMajor}`;
84
+ }
85
+ export function renderUpdate(report) {
86
+ const lines = [];
87
+ if (report.skills.from === report.skills.to) {
88
+ lines.push(`skills already at ${report.skills.to} — refreshed in place; review the diff`);
89
+ }
90
+ else {
91
+ lines.push(`skills ${report.skills.from ?? "unversioned"} → ${report.skills.to} — review & commit the diff`);
92
+ }
93
+ if (report.stack.status === "absent") {
94
+ lines.push("stack not provisioned — run `speccle strength init`");
95
+ }
96
+ else if (report.stack.status === "current") {
97
+ lines.push("stack up to date");
98
+ }
99
+ else {
100
+ lines.push("stack behind the preset — run:");
101
+ lines.push(` ${report.stack.fixCommand}`);
102
+ }
103
+ lines.push(`cli ${report.cli.version} installed — update the binary with:`);
104
+ lines.push(` ${report.cli.command}`);
105
+ return lines.join("\n");
106
+ }
1
107
  export function renderCheck(report) {
2
108
  const lines = [
3
109
  `mutation ${describeReport(report.mutation)}`,
package/dist/skills.js ADDED
@@ -0,0 +1,63 @@
1
+ import { access, cp, mkdir, readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /** Where materialized skills land in a target repo — the path Claude Code loads them from. */
5
+ export const SKILLS_DIR = ".claude/skills";
6
+ /**
7
+ * Copies every bundled skill into the target's `.claude/skills/<name>/`, refreshing an
8
+ * existing copy rather than merging into it, so the materialized tree mirrors the tarball
9
+ * exactly and a re-run's git diff is the whole change a human reviews (ADR-0046, ADR-0040).
10
+ * Only the skills Speccle ships are touched, by name; any other skill in the folder is left
11
+ * alone.
12
+ */
13
+ export async function materializeSkills(root, source) {
14
+ const from = source ?? (await bundledSkillsDir());
15
+ const names = await skillNames(from);
16
+ const target = join(root, SKILLS_DIR);
17
+ await mkdir(target, { recursive: true });
18
+ const skills = [];
19
+ for (const name of names) {
20
+ const dest = join(target, name);
21
+ const existed = await exists(dest);
22
+ await rm(dest, { recursive: true, force: true });
23
+ await cp(join(from, name), dest, { recursive: true });
24
+ skills.push({ name, action: existed ? "refreshed" : "written" });
25
+ }
26
+ return { root, dir: SKILLS_DIR, source: from, skills };
27
+ }
28
+ async function skillNames(source) {
29
+ let entries;
30
+ try {
31
+ entries = await readdir(source, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ throw new Error(`no bundled skills to materialize at ${source}`);
35
+ }
36
+ return entries
37
+ .filter((entry) => entry.isDirectory())
38
+ .map((entry) => entry.name)
39
+ .sort();
40
+ }
41
+ /**
42
+ * The shipped skills: a top-level `skills/` beside `dist/` in the published tarball
43
+ * (ADR-0046). Running from source there is no bundled copy yet, so fall back to the
44
+ * plugin's skill dirs — the very files the build copies into the tarball.
45
+ */
46
+ async function bundledSkillsDir() {
47
+ const bundled = fileURLToPath(new URL("../skills", import.meta.url));
48
+ if (await exists(bundled))
49
+ return bundled;
50
+ const pluginSource = fileURLToPath(new URL("../../plugin/skills", import.meta.url));
51
+ if (await exists(pluginSource))
52
+ return pluginSource;
53
+ throw new Error(`no bundled skills found — expected them beside the CLI at ${bundled}`);
54
+ }
55
+ async function exists(path) {
56
+ try {
57
+ await access(path);
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }