@esneiderbravo/speclaw 0.1.3 → 0.1.5

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
@@ -29,24 +29,28 @@ One command. No cloud, no LLM, no API keys — <b>everything runs on your machin
29
29
  <br/>
30
30
 
31
31
  > [!TIP]
32
- > **One command sets everything up.** Run `npx @esneiderbravo/speclaw init`, pick the agents you
33
- > use, and speclaw scaffolds the project, indexes your code, and hands your agent
34
- > a ready-to-paste prompt to finish the setup.
32
+ > **Install once, then one command sets everything up.** Install speclaw globally, run
33
+ > `speclaw init`, pick the agents you use, and speclaw scaffolds the project, indexes
34
+ > your code, and hands your agent a ready-to-paste prompt to finish the setup.
35
35
 
36
36
  <br/>
37
37
 
38
38
  ## ◆ Quick start
39
39
 
40
- In your project root:
40
+ Install speclaw globally (once), then run `init` in your project root:
41
41
 
42
42
  ```bash
43
- npx @esneiderbravo/speclaw init
43
+ npm i -g @esneiderbravo/speclaw
44
+ speclaw init
44
45
  ```
45
46
 
46
47
  <p align="center">
47
- <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/terminal-quickstart.png" width="800" alt="npx @esneiderbravo/speclaw init">
48
+ <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/terminal-quickstart.png" width="800" alt="speclaw init">
48
49
  </p>
49
50
 
51
+ The `speclaw` command is now available everywhere — run `speclaw index`,
52
+ `speclaw visualize`, or `speclaw lawbook …` directly in any project.
53
+
50
54
  `init` will:
51
55
 
52
56
  1. **Ask which agents you use** (Claude Code, Cursor, Codex, …) — and configure
@@ -57,7 +61,9 @@ npx @esneiderbravo/speclaw init
57
61
  5. Print a prompt to paste into your agent so it fills the constitution with your
58
62
  project's real architecture and conventions.
59
63
 
60
- Works with `npm`, `pnpm` (`pnpm dlx @esneiderbravo/speclaw init`), and `yarn`.
64
+ Prefer not to install globally? A one-off `npx @esneiderbravo/speclaw init` works
65
+ too (also `pnpm dlx` / `yarn dlx`) — but installing globally means you can run the
66
+ `speclaw` commands directly afterwards.
61
67
 
62
68
  <br/>
63
69
 
@@ -118,6 +124,25 @@ still use Compass and the lawbook engine by calling the CLI from its shell.
118
124
 
119
125
  <br/>
120
126
 
127
+ ## ◆ Staying up to date
128
+
129
+ speclaw checks for new releases in the background (at most once a day) and nudges
130
+ you when one lands. To upgrade:
131
+
132
+ ```bash
133
+ speclaw update
134
+ ```
135
+
136
+ `update` upgrades the global package **and** brings the current project up to date
137
+ without a re-init: any new standards, skills, commands, or feature steps are added
138
+ **additively** — your existing files are never touched. It re-applies only the tool
139
+ packs this project already uses.
140
+
141
+ - `speclaw update --check` — report whether an update exists, change nothing.
142
+ - `NO_UPDATE_NOTIFIER=1` — silence the reminder.
143
+
144
+ <br/>
145
+
121
146
  ## ◆ Requirements
122
147
 
123
148
  - **Node.js ≥ 22** — uses the built-in `node:sqlite`.
@@ -11,7 +11,8 @@ import { ui, c, banner, renderProgress, clearProgress } from "../lib/ui.js";
11
11
  const PACK_LABELS = {
12
12
  agents: "dev-agents (backend · frontend · product)",
13
13
  };
14
- function detectProjectName(cwd) {
14
+ /** Best-effort project name: the package.json name (unscoped) or the directory name. */
15
+ export function detectProjectName(cwd) {
15
16
  try {
16
17
  const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
17
18
  if (pkg.name)
@@ -67,15 +68,24 @@ export async function runInit(flags) {
67
68
  process.exit(1);
68
69
  }
69
70
  const profile = { project_name: projectName };
70
- // 1. Content + chosen agents, with a check per piece installed
71
+ // 1. Content + chosen agents, with a check per piece installed. Re-running
72
+ // init is safe: every writer skips files that already exist, so anything you
73
+ // (or your agent) filled in is preserved — init only adds what's missing.
74
+ const reinit = fs.existsSync(path.join(cwd, "LAWS.md"));
71
75
  ui.step(`Setting up ${c.bold(c.cyan(projectName))}`);
72
- scaffold(cwd, profile, packs, agents);
76
+ if (reinit) {
77
+ ui.info("speclaw is already set up here — your existing files are kept; only missing pieces are added.");
78
+ }
79
+ const report = scaffold(cwd, profile, packs, agents);
73
80
  ui.ok(`Foundation ${c.muted("— LAWS.md + 8 standards + CLAUDE.md/AGENTS.md")}`);
74
81
  ui.ok(`Lawbook workflow ${c.muted("— draft · build · sync · archive · explore")}`);
75
82
  for (const p of packs)
76
83
  ui.ok(`${PACK_LABELS[p] ?? p + " pack"}`);
77
84
  specInit(cwd);
78
85
  ui.ok(`Lawbook workspace ${c.muted("— lawbook/")}`);
86
+ if (reinit && report.skipped.length) {
87
+ ui.info(`${report.written.length} added · ${c.cream(String(report.skipped.length))} preserved untouched`);
88
+ }
79
89
  ui.step("Configuring agents");
80
90
  for (const id of agents)
81
91
  ui.ok(`${agentById(id).label} ${c.muted("— symlinks + MCP")}`);
@@ -0,0 +1,100 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { ui, c } from "../lib/ui.js";
5
+ import { checkForUpdates, isNewer } from "../lib/update-check.js";
6
+ import { pkgName, pkgVersion } from "../../shared/version.js";
7
+ import { scaffold } from "../../modules/foundation/scaffold.js";
8
+ import { detectConfiguredAgents } from "../../shared/agents.js";
9
+ import { readManifest } from "../../shared/manifest.js";
10
+ import { loadPacks } from "../../modules/tools/packs.js";
11
+ import { detectProjectName } from "./init.js";
12
+ const MIGRATIONS = [
13
+ // e.g. { version: "0.3.0", describe: "…", run: (p, r) => { … } }
14
+ ];
15
+ /**
16
+ * Update speclaw and bring the current project up to date without a full re-init:
17
+ * upgrade the global package, then additively apply any new standards, skills,
18
+ * commands, and feature steps this project is missing (existing files untouched).
19
+ *
20
+ * @param flags - `--check` reports without changing anything; `--migrate-only`
21
+ * skips the global upgrade and only applies project changes (used internally
22
+ * after the package is upgraded, so migrations run from the new version).
23
+ */
24
+ export async function runUpdate(flags) {
25
+ const cwd = process.cwd();
26
+ const migrateOnly = Boolean(flags["migrate-only"]);
27
+ const checkOnly = Boolean(flags.check);
28
+ const winShell = process.platform === "win32";
29
+ if (!migrateOnly) {
30
+ ui.step("Checking for updates");
31
+ const { current, latest, updateAvailable } = await checkForUpdates({ force: true });
32
+ if (!latest) {
33
+ ui.warn("Could not reach the npm registry — skipping the version check.");
34
+ }
35
+ else if (updateAvailable) {
36
+ ui.info(`${c.muted(current)} ${c.muted("→")} ${c.cyan(latest)}`);
37
+ if (checkOnly) {
38
+ ui.info(`Run ${ui.code("speclaw update")} to upgrade and apply what's new.`);
39
+ return;
40
+ }
41
+ ui.step(`Updating ${pkgName()} globally`);
42
+ const install = spawnSync("npm", ["install", "-g", `${pkgName()}@latest`], { stdio: "inherit", shell: winShell });
43
+ if (install.status !== 0) {
44
+ ui.err("Global update failed. Try again with elevated permissions (e.g. sudo), or check your npm setup.");
45
+ process.exit(1);
46
+ }
47
+ ui.ok(`Updated to ${latest}`);
48
+ // Re-exec the NEWLY installed binary so migrations run with the new assets
49
+ // and any new feature steps — not this (now-stale) process.
50
+ const re = spawnSync("speclaw", ["update", "--migrate-only"], { stdio: "inherit", shell: winShell });
51
+ if (re.error) {
52
+ ui.warn(`Upgraded — now run ${ui.code("speclaw update --migrate-only")} to apply project changes.`);
53
+ return;
54
+ }
55
+ process.exit(re.status ?? 0);
56
+ }
57
+ else {
58
+ ui.ok(`Already on the latest version (${current}).`);
59
+ if (checkOnly)
60
+ return;
61
+ }
62
+ }
63
+ applyProjectMigrations(cwd);
64
+ }
65
+ /**
66
+ * Additively apply the current version's content and feature steps to a project.
67
+ * No-op with a hint when the directory isn't a speclaw project.
68
+ */
69
+ function applyProjectMigrations(cwd) {
70
+ const initialized = fs.existsSync(path.join(cwd, "ai-specs")) || fs.existsSync(path.join(cwd, "LAWS.md"));
71
+ if (!initialized) {
72
+ ui.step("Project");
73
+ ui.info(`No speclaw project here — run ${ui.code("speclaw init")} to set one up.`);
74
+ return;
75
+ }
76
+ ui.step("Applying what's new to this project");
77
+ const fromVersion = readManifest(cwd)?.version ?? "0.0.0";
78
+ const agents = detectConfiguredAgents(cwd);
79
+ const known = loadPacks();
80
+ const packs = (readManifest(cwd)?.packs ?? []).filter((p) => p in known);
81
+ // scaffold is non-destructive: only missing files are written, and it refreshes
82
+ // the manifest to the current version. Existing files are left exactly as-is.
83
+ const report = scaffold(cwd, { project_name: detectProjectName(cwd) }, packs, agents);
84
+ const newFiles = report.written.filter((w) => !w.includes(".gitignore"));
85
+ if (newFiles.length) {
86
+ for (const w of newFiles)
87
+ ui.ok(c.cream(path.relative(cwd, w.split(" (")[0])));
88
+ ui.info(`${newFiles.length} new file(s) added · ${report.skipped.length} left untouched.`);
89
+ }
90
+ else {
91
+ ui.ok("Content already up to date — nothing new to add.");
92
+ }
93
+ const pending = MIGRATIONS.filter((m) => isNewer(m.version, fromVersion));
94
+ for (const m of pending) {
95
+ ui.step(`Step for ${m.version}: ${m.describe}`);
96
+ m.run(cwd, report);
97
+ }
98
+ ui.plain();
99
+ ui.ok(`On ${c.cyan(pkgVersion())}. No re-init needed.`);
100
+ }
package/dist/cli/index.js CHANGED
@@ -1,12 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseFlags } from "./lib/args.js";
3
3
  import { ui } from "./lib/ui.js";
4
- const HELP = `speclaw spec-driven, agent-ready projects (foundation + Compass + Spec)
4
+ import { maybeNotifyUpdate } from "./lib/update-check.js";
5
+ const HELP = `speclaw — spec-driven, agent-ready projects (foundation + Compass + Lawbook)
5
6
 
6
7
  Usage: speclaw <command> [options]
7
8
 
9
+ Install globally so the command is always available:
10
+ npm i -g @esneiderbravo/speclaw
11
+
8
12
  Setup
9
13
  init Interactive setup: pick agents, scaffold, index, get the prompt
14
+ update Upgrade speclaw and apply only what's new (no re-init)
10
15
  agent list Show which agents are configured
11
16
  agent add <id> Configure another agent later (symlinks + MCP)
12
17
 
@@ -32,10 +37,8 @@ Other
32
37
  mcp Start the MCP server (used by your agent's config)
33
38
  help Show this help
34
39
  `;
35
- /** Parse argv and dispatch to the matching command handler. */
36
- async function main() {
37
- const [cmd, ...rest] = process.argv.slice(2);
38
- const flags = parseFlags(rest);
40
+ /** Run the handler for a single command. Returns when the command completes. */
41
+ async function dispatch(cmd, flags) {
39
42
  switch (cmd) {
40
43
  case undefined:
41
44
  case "help":
@@ -50,6 +53,8 @@ async function main() {
50
53
  }
51
54
  case "init":
52
55
  return (await import("./commands/init.js")).runInit(flags);
56
+ case "update":
57
+ return (await import("./commands/update.js")).runUpdate(flags);
53
58
  case "agent":
54
59
  return (await import("./commands/agent.js")).runAgent(flags);
55
60
  case "index":
@@ -74,6 +79,13 @@ async function main() {
74
79
  process.exit(1);
75
80
  }
76
81
  }
82
+ /** Parse argv, run the command, then surface an update notice if one is due. */
83
+ async function main() {
84
+ const [cmd, ...rest] = process.argv.slice(2);
85
+ const flags = parseFlags(rest);
86
+ await dispatch(cmd, flags);
87
+ await maybeNotifyUpdate(cmd);
88
+ }
77
89
  main().catch((err) => {
78
90
  ui.err(err.message);
79
91
  process.exit(1);
@@ -0,0 +1,120 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { pkgName, pkgVersion } from "../../shared/version.js";
5
+ import { c } from "./ui.js";
6
+ // A lightweight, best-effort update notifier. The registry is queried at most
7
+ // once a day (result cached under ~/.speclaw/), the lookup is time-boxed, and
8
+ // every failure is swallowed — checking for updates must never slow down or
9
+ // break a command. Notices go to stderr so piped stdout stays clean.
10
+ const TTL_MS = 24 * 60 * 60 * 1000;
11
+ function cacheFile() {
12
+ return path.join(os.homedir(), ".speclaw", "update-check.json");
13
+ }
14
+ function readCache() {
15
+ try {
16
+ const c = JSON.parse(fs.readFileSync(cacheFile(), "utf8"));
17
+ if (typeof c.checkedAt === "number" && typeof c.latest === "string")
18
+ return c;
19
+ }
20
+ catch {
21
+ /* no cache yet */
22
+ }
23
+ return null;
24
+ }
25
+ function writeCache(latest) {
26
+ try {
27
+ const f = cacheFile();
28
+ fs.mkdirSync(path.dirname(f), { recursive: true });
29
+ fs.writeFileSync(f, JSON.stringify({ checkedAt: Date.now(), latest }));
30
+ }
31
+ catch {
32
+ /* cache is an optimization; ignore failures */
33
+ }
34
+ }
35
+ async function fetchLatest(name) {
36
+ const ctrl = new AbortController();
37
+ const timer = setTimeout(() => ctrl.abort(), 2500);
38
+ try {
39
+ const url = `https://registry.npmjs.org/${name.replace("/", "%2F")}/latest`;
40
+ const res = await fetch(url, { signal: ctrl.signal });
41
+ if (!res.ok)
42
+ return null;
43
+ const body = (await res.json());
44
+ return typeof body.version === "string" ? body.version : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+ /**
54
+ * Compare two dotted versions (ignoring any prerelease suffix).
55
+ *
56
+ * @returns True when `latest` is strictly newer than `current`.
57
+ */
58
+ export function isNewer(latest, current) {
59
+ const parse = (v) => v.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
60
+ const a = parse(latest);
61
+ const b = parse(current);
62
+ for (let i = 0; i < 3; i++) {
63
+ const x = a[i] ?? 0;
64
+ const y = b[i] ?? 0;
65
+ if (x > y)
66
+ return true;
67
+ if (x < y)
68
+ return false;
69
+ }
70
+ return false;
71
+ }
72
+ /**
73
+ * Resolve the current version and the latest published one, using the daily
74
+ * cache unless `force` is set. Falls back to stale cache when offline.
75
+ *
76
+ * @param opts - `force` bypasses the cache and always queries the registry.
77
+ * @returns The current/latest versions and whether an upgrade is available.
78
+ */
79
+ export async function checkForUpdates(opts = {}) {
80
+ const current = pkgVersion();
81
+ const cache = readCache();
82
+ let latest = null;
83
+ if (!opts.force && cache && Date.now() - cache.checkedAt < TTL_MS) {
84
+ latest = cache.latest;
85
+ }
86
+ else {
87
+ latest = await fetchLatest(pkgName());
88
+ if (latest)
89
+ writeCache(latest);
90
+ else if (cache)
91
+ latest = cache.latest; // offline: use whatever we last knew
92
+ }
93
+ return { current, latest, updateAvailable: !!latest && isNewer(latest, current) };
94
+ }
95
+ /**
96
+ * Print a one-line "update available" notice to stderr when a newer version
97
+ * exists. No-op for the `mcp`/`update`/`help` commands, on non-TTY stderr, or
98
+ * when NO_UPDATE_NOTIFIER / SPECLAW_NO_UPDATE_NOTIFIER is set. Never throws.
99
+ *
100
+ * @param cmd - The command that just ran (used to skip noisy contexts).
101
+ */
102
+ export async function maybeNotifyUpdate(cmd) {
103
+ try {
104
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.SPECLAW_NO_UPDATE_NOTIFIER)
105
+ return;
106
+ if (!process.stderr.isTTY)
107
+ return;
108
+ if (!cmd || ["mcp", "update", "help", "--help", "-h"].includes(cmd))
109
+ return;
110
+ const { current, latest, updateAvailable } = await checkForUpdates();
111
+ if (!updateAvailable || !latest)
112
+ return;
113
+ process.stderr.write("\n" +
114
+ " " + c.amber("⬆ speclaw ") + c.muted(current + " → ") + c.cyan(latest) + c.muted(" available") + "\n" +
115
+ " " + c.muted("run ") + c.cyan("speclaw update") + c.muted(" — upgrades and applies only what's new") + "\n\n");
116
+ }
117
+ catch {
118
+ /* the notifier is best-effort — never let it break a command */
119
+ }
120
+ }
@@ -193,7 +193,7 @@ function draw(){
193
193
  }
194
194
  ctx.globalAlpha=1;
195
195
  }
196
- function loop(){ tick(); draw(); requestAnimationFrame(loop); } loop();
196
+ function loop(){ tick(); draw(); requestAnimationFrame(loop); }
197
197
 
198
198
  // interaction
199
199
  let dragging=null, panning=false, lastX=0, lastY=0;
@@ -214,5 +214,7 @@ addEventListener('mousemove', e=>{
214
214
  addEventListener('mouseup', ()=>{ dragging=null; panning=false; });
215
215
  cv.addEventListener('wheel', e=>{ e.preventDefault(); const f=e.deltaY<0?1.1:0.9;
216
216
  const mx=e.clientX, my=e.clientY; ox=mx-(mx-ox)*f; oy=my-(my-oy)*f; scale*=f; }, {passive:false});
217
+
218
+ loop(); // start once every declaration above is initialized (avoids the TDZ on 'dragging')
217
219
  </script></body></html>`;
218
220
  }
@@ -6,6 +6,8 @@ import { emptyReport, ensureGitignore } from "../../shared/install.js";
6
6
  import { configureAgent } from "../../shared/agents.js";
7
7
  import { installWorkflow } from "../lawbook/register.js";
8
8
  import { installPack, loadPacks } from "../tools/packs.js";
9
+ import { writeManifest } from "../../shared/manifest.js";
10
+ import { pkgVersion } from "../../shared/version.js";
9
11
  const ASSETS = assetsDir(import.meta.url);
10
12
  // Every {{var}} the foundation templates may reference. Ones the agent didn't
11
13
  // provide default to empty so a bare `scaffold` never leaves a raw {{tag}}.
@@ -88,6 +90,9 @@ export function scaffold(projectPath, profile, packNames, agents = []) {
88
90
  ensureGitignore(projectPath, ".speclaw/", "speclaw local code Compass (never commit)", report);
89
91
  for (const id of agents)
90
92
  configureAgent(projectPath, id, report); // only the chosen agents
93
+ // Record what was installed so `speclaw update` can re-apply only these packs
94
+ // (additively) and gate feature migrations by version — no full re-init.
95
+ writeManifest(projectPath, pkgVersion(), packNames);
91
96
  report.nextSteps = [
92
97
  "Run the `lawbook_init` tool to set up the spec-driven workflow (creates lawbook/). No external CLI needed — it's built into speclaw.",
93
98
  "Run the `compass_index` tool to build the local code graph (.speclaw/). No install, no LLM — it's built into speclaw. Re-run it after significant edits.",
@@ -0,0 +1,35 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function manifestPath(projectPath) {
4
+ return path.join(projectPath, "ai-specs", ".speclaw.json");
5
+ }
6
+ /**
7
+ * Read a project's speclaw manifest.
8
+ *
9
+ * @param projectPath - Project root to read from.
10
+ * @returns The manifest, or null if the project has none (e.g. an older init).
11
+ */
12
+ export function readManifest(projectPath) {
13
+ try {
14
+ const m = JSON.parse(fs.readFileSync(manifestPath(projectPath), "utf8"));
15
+ return { version: String(m.version ?? "0.0.0"), packs: Array.isArray(m.packs) ? m.packs.map(String) : [] };
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /**
22
+ * Write (or refresh) a project's manifest, recording the current version and the
23
+ * union of previously-installed and newly-installed packs.
24
+ *
25
+ * @param projectPath - Project root to write into.
26
+ * @param version - The speclaw version doing the write.
27
+ * @param packs - Pack names installed in this run.
28
+ */
29
+ export function writeManifest(projectPath, version, packs) {
30
+ const prev = readManifest(projectPath);
31
+ const merged = Array.from(new Set([...(prev?.packs ?? []), ...packs]));
32
+ const p = manifestPath(projectPath);
33
+ fs.mkdirSync(path.dirname(p), { recursive: true });
34
+ fs.writeFileSync(p, JSON.stringify({ version, packs: merged }, null, 2) + "\n");
35
+ }
@@ -0,0 +1,27 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // The package.json sits at the package root — two levels up from this compiled
5
+ // file (dist/shared/version.js -> dist -> <root>). Read once and cache.
6
+ let cached = null;
7
+ function readPkg() {
8
+ if (cached)
9
+ return cached;
10
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
11
+ try {
12
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
13
+ cached = { name: String(pkg.name ?? "@esneiderbravo/speclaw"), version: String(pkg.version ?? "0.0.0") };
14
+ }
15
+ catch {
16
+ cached = { name: "@esneiderbravo/speclaw", version: "0.0.0" };
17
+ }
18
+ return cached;
19
+ }
20
+ /** The published package name (e.g. `@esneiderbravo/speclaw`). */
21
+ export function pkgName() {
22
+ return readPkg().name;
23
+ }
24
+ /** The currently installed package version (e.g. `0.1.4`). */
25
+ export function pkgVersion() {
26
+ return readPkg().version;
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },