@esneiderbravo/speclaw 0.1.14 → 0.1.15

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.
@@ -0,0 +1,31 @@
1
+ import { pkgVersion } from "../../shared/version.js";
2
+ import { checkForUpdates, upgradeNotice } from "../lib/update-check.js";
3
+ /**
4
+ * Print the locally installed speclaw version and, when a newer version is
5
+ * published on npm, a clickable suggestion to upgrade.
6
+ *
7
+ * The installed version (from the package's own `package.json`) always goes to
8
+ * **stdout** as a bare line, so it stays script- and pipe-friendly
9
+ * (`v=$(speclaw --version)`). The upgrade suggestion — which requires a network
10
+ * lookup — goes to **stderr** and only when stderr is an interactive TTY and
11
+ * the notifier is not disabled, so scripts and CI pay no network cost and get
12
+ * clean output. The lookup is forced (bypasses the daily cache) so an explicit
13
+ * version query reflects npm right now, and every failure is swallowed: a
14
+ * flaky or offline registry must never break `--version`.
15
+ */
16
+ export async function runVersion() {
17
+ console.log(pkgVersion());
18
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.SPECLAW_NO_UPDATE_NOTIFIER)
19
+ return;
20
+ if (!process.stderr.isTTY)
21
+ return;
22
+ try {
23
+ const { current, latest, updateAvailable } = await checkForUpdates({ force: true });
24
+ if (updateAvailable && latest) {
25
+ process.stderr.write("\n" + upgradeNotice(current, latest) + "\n\n");
26
+ }
27
+ }
28
+ catch {
29
+ /* the update check is best-effort — never let it break `--version` */
30
+ }
31
+ }
package/dist/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseFlags } from "./lib/args.js";
3
- import { ui } from "./lib/ui.js";
3
+ import { ui, header } from "./lib/ui.js";
4
4
  import { maybeNotifyUpdate } from "./lib/update-check.js";
5
5
  const HELP = `speclaw — spec-driven, agent-ready projects (foundation + Compass + Lawbook)
6
6
 
@@ -36,7 +36,40 @@ Other
36
36
  doctor Verify the installation
37
37
  mcp Start the MCP server (used by your agent's config)
38
38
  help Show this help
39
+ --version Print the installed speclaw version
39
40
  `;
41
+ // Commands that open with the one-line branded header. These are the
42
+ // interactive, human-facing commands whose stdout is prose. Deliberately
43
+ // excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
44
+ // query family (`explore`/`search`/`recall`/`impact`/`trace`, machine-consumed
45
+ // output), `mcp` (a long-running stdio server), and `init` (already opens with
46
+ // the fuller `banner()`).
47
+ const HEADER_COMMANDS = new Set([
48
+ undefined,
49
+ "help",
50
+ "--help",
51
+ "-h",
52
+ "update",
53
+ "agent",
54
+ "doctor",
55
+ "index",
56
+ "watch",
57
+ "lawbook",
58
+ ]);
59
+ /**
60
+ * Print the branded header once, ahead of a command's output, when it is a
61
+ * header-eligible command AND stdout is an interactive terminal (so pipes,
62
+ * redirection, and CI stay clean — mirroring the color gate in `ui.ts`). A
63
+ * forced-color signal counts as interactive so the header is exercisable in a
64
+ * child process.
65
+ */
66
+ function maybeHeader(cmd) {
67
+ if (!process.stdout.isTTY && process.env.FORCE_COLOR !== "1")
68
+ return;
69
+ if (!HEADER_COMMANDS.has(cmd))
70
+ return;
71
+ header();
72
+ }
40
73
  /** Run the handler for a single command. Returns when the command completes. */
41
74
  async function dispatch(cmd, flags) {
42
75
  switch (cmd) {
@@ -46,6 +79,10 @@ async function dispatch(cmd, flags) {
46
79
  case "-h":
47
80
  console.log(HELP);
48
81
  return;
82
+ case "version":
83
+ case "--version":
84
+ case "-v":
85
+ return (await import("./commands/version.js")).runVersion();
49
86
  case "mcp": {
50
87
  const { startMcpServer } = await import("../server.js");
51
88
  await startMcpServer();
@@ -83,6 +120,7 @@ async function dispatch(cmd, flags) {
83
120
  async function main() {
84
121
  const [cmd, ...rest] = process.argv.slice(2);
85
122
  const flags = parseFlags(rest);
123
+ maybeHeader(cmd);
86
124
  await dispatch(cmd, flags);
87
125
  await maybeNotifyUpdate(cmd);
88
126
  }
@@ -2,6 +2,7 @@
2
2
  // = the "law", cream text, muted gray, green/amber for status) rendered as
3
3
  // 24-bit truecolor ANSI — no dependency needed. Colors auto-disable when the
4
4
  // output is not a TTY or NO_COLOR is set.
5
+ import { pkgVersion } from "../../shared/version.js";
5
6
  const PALETTE = {
6
7
  cyan: [46, 230, 230], // #2EE6E6 — the accent / "law"
7
8
  cyanDim: [23, 193, 193], // #17C1C1
@@ -12,6 +13,44 @@ const PALETTE = {
12
13
  red: [235, 90, 90],
13
14
  };
14
15
  const colorOn = (Boolean(process.stdout.isTTY) || process.env.FORCE_COLOR === "1") && !process.env.NO_COLOR;
16
+ // Whether the terminal reliably renders the unicode box/block glyphs the brand
17
+ // output uses. Non-Windows terminals are assumed capable; a Windows console is
18
+ // trusted only under a modern-terminal signal (Windows Terminal, an embedding
19
+ // program like VS Code, or CI) — a legacy conhost with a non-UTF-8 code page
20
+ // would otherwise show mojibake. No dependency; the check runs once at load.
21
+ const unicodeOn = process.platform !== "win32" ||
22
+ Boolean(process.env.WT_SESSION || process.env.TERM_PROGRAM || process.env.CI);
23
+ // The brand glyph set, resolved once against terminal capability. Every branded
24
+ // renderer (header, banner, box, progress) draws from this so unicode and ASCII
25
+ // terminals degrade together instead of one surface emitting unrenderable
26
+ // glyphs. The ASCII fallbacks are chosen to preserve each drawing's shape.
27
+ const G = unicodeOn
28
+ ? {
29
+ diamond: "◈",
30
+ dot: "·",
31
+ boxTL: "╭",
32
+ boxTR: "╮",
33
+ boxBL: "╰",
34
+ boxBR: "╯",
35
+ boxV: "│",
36
+ boxH: "─",
37
+ bar: "▇",
38
+ fill: "█",
39
+ track: "░",
40
+ }
41
+ : {
42
+ diamond: ">",
43
+ dot: "-",
44
+ boxTL: "+",
45
+ boxTR: "+",
46
+ boxBL: "+",
47
+ boxBR: "+",
48
+ boxV: "|",
49
+ boxH: "-",
50
+ bar: "#",
51
+ fill: "#",
52
+ track: "-",
53
+ };
15
54
  function paint(rgb, s) {
16
55
  if (!colorOn)
17
56
  return s;
@@ -20,6 +59,22 @@ function paint(rgb, s) {
20
59
  function bold(s) {
21
60
  return colorOn ? `\x1b[1m${s}\x1b[0m` : s;
22
61
  }
62
+ /**
63
+ * Wrap `label` in an OSC 8 terminal hyperlink pointing at `url`, so a
64
+ * capable terminal renders it as a clickable link. Terminals that don't
65
+ * support OSC 8 simply ignore the escapes and show the label. Falls back to a
66
+ * plain `label (url)` when rich output is off (non-TTY / NO_COLOR) so piped and
67
+ * dumb-terminal output stays legible.
68
+ *
69
+ * @param label - The visible, clickable text.
70
+ * @param url - The target the terminal opens on click.
71
+ * @returns The label wrapped as a hyperlink, or `label (url)` when off.
72
+ */
73
+ export function link(label, url) {
74
+ if (!colorOn)
75
+ return `${label} (${url})`;
76
+ return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
77
+ }
23
78
  /** Brand color helpers for composing styled strings. */
24
79
  export const c = {
25
80
  cyan: (s) => paint(PALETTE.cyan, s),
@@ -42,21 +97,43 @@ export const ui = {
42
97
  plain: (s = "") => console.log(s),
43
98
  code: (s) => c.cyan(s),
44
99
  };
100
+ /**
101
+ * A single-line branded header — mark · name · installed version · tagline —
102
+ * printed once at the top of interactive commands (see `src/cli/index.ts`). The
103
+ * version comes from the cached {@link pkgVersion}. Glyphs degrade to ASCII on
104
+ * terminals without reliable unicode, and the styling no-ops to plain text when
105
+ * color is off, so the line stays legible everywhere.
106
+ *
107
+ * Example: `◈ speclaw v0.1.15 · where specs become law`
108
+ */
109
+ export function header() {
110
+ const mark = c.cyan(G.diamond);
111
+ const name = bold(c.cream("speclaw"));
112
+ const ver = c.muted("v" + pkgVersion());
113
+ const tag = c.muted(G.dot + " where specs become law");
114
+ console.log(`${mark} ${name} ${ver} ${tag}`);
115
+ }
45
116
  /**
46
117
  * The speclaw wordmark + logo mark (a document whose bottom line — the law — is
47
118
  * highlighted in cyan). Printed at the top of `speclaw init`.
48
119
  */
49
120
  export function banner() {
50
- const bar = c.cyan("▇▇▇▇▇▇");
51
- const line = c.muted("──────");
121
+ const H = G.boxH;
122
+ const bar = c.cyan(G.bar.repeat(6));
123
+ const line = c.muted(H.repeat(6));
52
124
  const edge = c.muted;
53
125
  console.log();
54
- console.log(" " + edge("╭────────╮"));
55
- console.log(" " + edge(" ") + line + edge(" ") + " " + bold(c.cream("s p e c l a w")));
56
- console.log(" " + edge("│ ") + c.muted("──── ") + edge(" │") + " " + c.muted("where specs become law"));
57
- console.log(" " + edge("│ ") + c.muted("─────") + " " + edge(" │"));
58
- console.log(" " + edge("") + bar + edge(" │"));
59
- console.log(" " + edge("╰────────╯"));
126
+ console.log(" " + edge(G.boxTL + H.repeat(8) + G.boxTR));
127
+ console.log(" " + edge(G.boxV + " ") + line + edge(" " + G.boxV) + " " + bold(c.cream("s p e c l a w")));
128
+ console.log(" " +
129
+ edge(G.boxV + " ") +
130
+ c.muted(H.repeat(4) + " ") +
131
+ edge(" " + G.boxV) +
132
+ " " +
133
+ c.muted("where specs become law"));
134
+ console.log(" " + edge(G.boxV + " ") + c.muted(H.repeat(5)) + " " + edge(" " + G.boxV));
135
+ console.log(" " + edge(G.boxV + " ") + bar + edge(" " + G.boxV));
136
+ console.log(" " + edge(G.boxBL + H.repeat(8) + G.boxBR));
60
137
  console.log();
61
138
  }
62
139
  /** Render a single-line progress bar on stderr (so stdout stays clean). */
@@ -66,7 +143,7 @@ export function renderProgress(done, total, label) {
66
143
  const width = 26;
67
144
  const ratio = total > 0 ? done / total : 1;
68
145
  const filled = Math.round(ratio * width);
69
- const bar = c.cyan("█".repeat(filled)) + c.muted("░".repeat(width - filled));
146
+ const bar = c.cyan(G.fill.repeat(filled)) + c.muted(G.track.repeat(width - filled));
70
147
  const pct = c.cyanDim(String(Math.round(ratio * 100)).padStart(3) + "%");
71
148
  const shortLabel = label.length > 38 ? "…" + label.slice(-37) : label;
72
149
  process.stderr.write(`\r ${bar} ${pct} ${c.muted(shortLabel.padEnd(38))}`);
@@ -78,11 +155,12 @@ export function clearProgress() {
78
155
  /** Draw a cyan-bordered block (used for the copy-paste agent prompt). */
79
156
  export function box(lines, title) {
80
157
  const width = Math.min(72, Math.max(...lines.map((l) => l.length), title?.length ?? 0) + 2);
158
+ const H = G.boxH;
81
159
  const top = title
82
- ? "╭─ " + c.cyanDim(title) + " " + "─".repeat(Math.max(0, width - title.length - 3))
83
- : "╭" + "─".repeat(width);
84
- console.log(" " + c.muted(top) + c.muted("╮"));
160
+ ? G.boxTL + H + " " + c.cyanDim(title) + " " + H.repeat(Math.max(0, width - title.length - 3))
161
+ : G.boxTL + H.repeat(width);
162
+ console.log(" " + c.muted(top) + c.muted(G.boxTR));
85
163
  for (const l of lines)
86
- console.log(" " + c.muted(" ") + c.cream(l.padEnd(width - 2)) + c.muted(" "));
87
- console.log(" " + c.muted("╰" + "─".repeat(width) + "╯"));
164
+ console.log(" " + c.muted(G.boxV + " ") + c.cream(l.padEnd(width - 2)) + c.muted(" " + G.boxV));
165
+ console.log(" " + c.muted(G.boxBL + H.repeat(width) + G.boxBR));
88
166
  }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { pkgName, pkgVersion } from "../../shared/version.js";
5
- import { c } from "./ui.js";
5
+ import { c, link } from "./ui.js";
6
6
  // A lightweight, best-effort update notifier. The registry is queried at most
7
7
  // once a day (result cached under ~/.speclaw/), the lookup is time-boxed, and
8
8
  // every failure is swallowed — checking for updates must never slow down or
@@ -95,9 +95,36 @@ export async function checkForUpdates(opts = {}) {
95
95
  }
96
96
  return { current, latest, updateAvailable: !!latest && isNewer(latest, current) };
97
97
  }
98
+ /** The public npm page for a package, where an upgrade can be reviewed. */
99
+ export function npmPackageUrl(name) {
100
+ return `https://www.npmjs.com/package/${name}`;
101
+ }
102
+ /**
103
+ * Build the two-line "update available" notice. The latest version is rendered
104
+ * as a clickable link to the package's npm page, so a capable terminal lets the
105
+ * user open the release with a single click while `speclaw update` remains the
106
+ * command that performs the upgrade.
107
+ *
108
+ * @param current - The installed version.
109
+ * @param latest - The newest published version.
110
+ * @returns The formatted, styled notice (no leading/trailing blank lines).
111
+ */
112
+ export function upgradeNotice(current, latest) {
113
+ const latestLink = c.cyan(link(latest, npmPackageUrl(pkgName())));
114
+ return (" " +
115
+ c.amber("⬆ speclaw ") +
116
+ c.muted(current + " → ") +
117
+ latestLink +
118
+ c.muted(" available") +
119
+ "\n" +
120
+ " " +
121
+ c.muted("run ") +
122
+ c.cyan("speclaw update") +
123
+ c.muted(" — upgrades and applies only what's new"));
124
+ }
98
125
  /**
99
- * Print a one-line "update available" notice to stderr when a newer version
100
- * exists. No-op for the `mcp`/`update`/`help` commands, on non-TTY stderr, or
126
+ * Print the "update available" notice to stderr when a newer version exists.
127
+ * No-op for the `mcp`/`update`/`help`/`version` commands, on non-TTY stderr, or
101
128
  * when NO_UPDATE_NOTIFIER / SPECLAW_NO_UPDATE_NOTIFIER is set. Never throws.
102
129
  *
103
130
  * @param cmd - The command that just ran (used to skip noisy contexts).
@@ -110,23 +137,13 @@ export async function maybeNotifyUpdate(cmd) {
110
137
  return;
111
138
  // `init` shows its own prominent up-front warning and ends on the clean
112
139
  // copy-paste prompt — don't append a second notice after it.
113
- if (!cmd || ["mcp", "update", "init", "help", "--help", "-h"].includes(cmd))
140
+ if (!cmd ||
141
+ ["mcp", "update", "init", "help", "--help", "-h", "version", "--version", "-v"].includes(cmd))
114
142
  return;
115
143
  const { current, latest, updateAvailable } = await checkForUpdates();
116
144
  if (!updateAvailable || !latest)
117
145
  return;
118
- process.stderr.write("\n" +
119
- " " +
120
- c.amber("⬆ speclaw ") +
121
- c.muted(current + " → ") +
122
- c.cyan(latest) +
123
- c.muted(" available") +
124
- "\n" +
125
- " " +
126
- c.muted("run ") +
127
- c.cyan("speclaw update") +
128
- c.muted(" — upgrades and applies only what's new") +
129
- "\n\n");
146
+ process.stderr.write("\n" + upgradeNotice(current, latest) + "\n\n");
130
147
  }
131
148
  catch {
132
149
  /* the notifier is best-effort — never let it break a command */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -41,6 +41,8 @@
41
41
  "lint": "eslint .",
42
42
  "format": "prettier --write .",
43
43
  "check": "prettier --check . && eslint .",
44
+ "pretest": "tsc -p tsconfig.test.json && node scripts/prep-test-assets.mjs",
45
+ "test": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' 'dist-test/test/**/*.test.js'",
44
46
  "prepublishOnly": "npm run build"
45
47
  },
46
48
  "engines": {