formhaus 0.14.2 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +4 -3
  2. package/dist/cli.js +97 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ Adds the [Formhaus](https://formhaus.dev) MCP server and agent instructions to t
6
6
  npx formhaus
7
7
  ```
8
8
 
9
- Run it inside a project to also add the Cursor rule there. Requires Node 20 or later.
9
+ Run it anywhere inside a project to also add the Cursor rule at the project root: the nearest folder above with `.git`, `package.json` or `.cursor`, below your home directory. Requires Node 20 or later.
10
10
 
11
11
  ## What it changes
12
12
 
@@ -14,12 +14,12 @@ Run it inside a project to also add the Cursor rule there. Requires Node 20 or l
14
14
  |-------|-------------|--------|
15
15
  | Claude Code | `claude` on `PATH` | `claude mcp add --scope user formhaus -- npx -y @formhaus/mcp` and the `formhaus-create-form` skill in `~/.claude/skills` |
16
16
  | Codex | `~/.codex` or `codex` on `PATH` | `[mcp_servers.formhaus]` in `~/.codex/config.toml` |
17
- | Cursor | `~/.cursor` or `cursor` on `PATH` | `formhaus` in `~/.cursor/mcp.json`, and `.cursor/rules/formhaus.mdc` when run inside a project |
17
+ | Cursor | `~/.cursor` or `cursor` on `PATH` | `formhaus` in `~/.cursor/mcp.json`, and `.cursor/rules/formhaus.mdc` at the project root |
18
18
  | Claude Desktop | its config directory on macOS and Windows | `formhaus` in `claude_desktop_config.json` |
19
19
 
20
20
  Windsurf, Gemini CLI, OpenCode, VS Code and Claude Desktop on Linux are detected and listed with manual setup steps.
21
21
 
22
- Existing entries are kept. An existing `formhaus` server with another command or args is reported and left unchanged, and a project `.mcp.json` never counts as set up. Before the first change to a file, the original is saved as `<file>.bak`. Formhaus does not write to a file that is a symbolic link, or through a linked directory inside the project. Running it again changes nothing. If the Formhaus Claude Code plugin is installed, Claude Code is left as is.
22
+ Existing entries are kept. An existing `formhaus` server with another command or args is reported and left unchanged, and a project `.mcp.json` never counts as set up. Before the first change to a config file, the original is saved as `<file>.bak`. If you edited the Cursor rule or the skill, Formhaus keeps your version. `--force` replaces it and saves your version as `<file>.bak`. Formhaus does not write to a file that is a symbolic link, or through a linked directory inside the project. Running it again changes nothing. If the Formhaus Claude Code plugin is installed, Claude Code is left as is.
23
23
 
24
24
  ## Options
25
25
 
@@ -27,6 +27,7 @@ Existing entries are kept. An existing `formhaus` server with another command or
27
27
  npx formhaus [setup] [options]
28
28
 
29
29
  --dry-run Show the planned changes and write nothing
30
+ --force Replace your edited skill and rule, saving them as .bak
30
31
  -h, --help Show help
31
32
  -v, --version Show the version
32
33
  ```
package/dist/cli.js CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { homedir } from 'os';
3
3
  import { parseArgs, isDeepStrictEqual } from 'util';
4
- import { existsSync, statSync, accessSync, constants, readFileSync, mkdirSync, copyFileSync, rmSync, writeFileSync, chmodSync, renameSync, lstatSync } from 'fs';
5
- import { join, isAbsolute, sep, dirname, relative, resolve } from 'path';
4
+ import { join, isAbsolute, resolve, dirname, parse, sep, relative } from 'path';
6
5
  import { spawnSync } from 'child_process';
6
+ import { randomBytes, createHash } from 'crypto';
7
+ import { existsSync, statSync, accessSync, constants, realpathSync, readFileSync, mkdirSync, copyFileSync, renameSync, rmSync, lstatSync, writeFileSync, chmodSync } from 'fs';
7
8
 
8
9
  // package.json
9
- var version = "0.14.2";
10
+ var version = "0.14.3";
10
11
 
11
12
  // ../../.cursor/rules/formhaus.mdc
12
13
  var formhaus_default = '---\ndescription: Formhaus form definitions. Use when creating, editing or reviewing a Formhaus JSON form definition, or code that passes one to FormEngine or FormRenderer.\nalwaysApply: false\n---\n\n# Formhaus form definitions\n\nA Formhaus form is a JSON definition rendered by `@formhaus/core` with `@formhaus/react` or `@formhaus/vue`. One definition drives the web form and the Figma plugin.\n\n- Start each definition file with `"$schema": "https://formhaus.dev/schema/form-definition.json"`.\n- Required keys: `id` (kebab-case), `title`, `submit: { "label": "..." }`.\n- Use `fields` for a single page or `steps` for a multi-step form, never both.\n- Field `key` values are camelCase and unique across all steps.\n- `show` and `showAny` conditions reference existing field keys and use `eq`, `neq`, `in`, `notIn` or `notEmpty`.\n- Step `routes` only point to later steps. `to: null` ends the form without auto-submission.\n\n## Checking definitions\n\nWhen the `formhaus` MCP server is available (`npx -y @formhaus/mcp`):\n\n- Call `capabilities` for the current field types, validation rules and step semantics.\n- Call `example_definitions` for working examples.\n- Call `validate_definition` after every change and fix all `errors` before finishing.\n- Call `simulate_path` with sample answers to check branching steps and conditions.\n\nReference: https://formhaus.dev/api/definition.html\n';
@@ -44,38 +45,91 @@ function refuseLinkedDirectories(ctx, root, file) {
44
45
  const parts = relative(root, dirname(file)).split(sep).filter(Boolean);
45
46
  for (let index = 1; index <= parts.length; index++) refuseSymlink(ctx, resolve(root, ...parts.slice(0, index)));
46
47
  }
47
- function writeText(ctx, file, text, project) {
48
- const backup = `${file}.bak`;
49
- const temporary = `${file}.tmp`;
48
+ function refuseLinks(ctx, file, project) {
50
49
  if (project !== void 0) refuseLinkedDirectories(ctx, project, file);
51
- for (const path of [file, backup, temporary]) refuseSymlink(ctx, path);
50
+ for (const path of [file, `${file}.bak`]) refuseSymlink(ctx, path);
51
+ }
52
+ function temporaryPath(file) {
53
+ return `${file}.${randomBytes(8).toString("hex")}.tmp`;
54
+ }
55
+ function replaceWith(temporary, file, create) {
56
+ try {
57
+ create();
58
+ renameSync(temporary, file);
59
+ } catch (error) {
60
+ if (error.code !== "EEXIST") rmSync(temporary, { force: true });
61
+ throw error;
62
+ }
63
+ }
64
+ function writeText(ctx, file, text, project, replaceBackup = false) {
65
+ const backup = `${file}.bak`;
66
+ refuseLinks(ctx, file, project);
52
67
  if (ctx.dryRun) return;
53
68
  mkdirSync(dirname(file), { recursive: true });
54
- if (existsSync(file) && !existsSync(backup)) copyFileSync(file, backup, constants.COPYFILE_EXCL);
69
+ if (existsSync(file) && replaceBackup) {
70
+ const backupTemporary = temporaryPath(backup);
71
+ replaceWith(backupTemporary, backup, () => copyFileSync(file, backupTemporary, constants.COPYFILE_EXCL));
72
+ } else if (existsSync(file) && !existsSync(backup)) copyFileSync(file, backup, constants.COPYFILE_EXCL);
55
73
  const mode = statSync(file, { throwIfNoEntry: false })?.mode;
56
- rmSync(temporary, { force: true });
57
- writeFileSync(temporary, text, { flag: "wx", mode: mode === void 0 ? void 0 : mode & 384 });
58
- if (mode !== void 0) chmodSync(temporary, mode & 4095);
59
- renameSync(temporary, file);
74
+ const temporary = temporaryPath(file);
75
+ replaceWith(temporary, file, () => {
76
+ writeFileSync(temporary, text, { flag: "wx", mode: mode === void 0 ? void 0 : mode & 384 });
77
+ if (mode !== void 0) chmodSync(temporary, mode & 4095);
78
+ });
60
79
  }
61
- function writeIfChanged(ctx, file, text, project) {
80
+ function sha256(text) {
81
+ return createHash("sha256").update(text).digest("hex");
82
+ }
83
+ function writeAsset(ctx, file, text, shipped, project) {
62
84
  const current = readText(file);
63
85
  if (current === text) return "present";
64
- writeText(ctx, file, text, project);
65
- return current === void 0 ? "added" : "updated";
86
+ refuseLinks(ctx, file, project);
87
+ if (current === void 0 || shipped.includes(sha256(current))) {
88
+ writeText(ctx, file, text, project);
89
+ return current === void 0 ? "added" : "updated";
90
+ }
91
+ if (!ctx.force) return "kept";
92
+ writeText(ctx, file, text, project, true);
93
+ return "replaced";
94
+ }
95
+ function sameDirectory(a, b) {
96
+ if (resolve(a) === resolve(b)) return true;
97
+ try {
98
+ return realpathSync(a) === realpathSync(b);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ function isProjectRoot(directory) {
104
+ return existsSync(join(directory, ".git")) || existsSync(join(directory, "package.json")) || isDirectory(join(directory, ".cursor"));
105
+ }
106
+ function isFilesystemRoot(directory) {
107
+ return parse(directory).root === directory;
108
+ }
109
+ function findProjectRoot(ctx, isRoot = isFilesystemRoot) {
110
+ let directory = resolve(ctx.cwd);
111
+ while (!sameDirectory(directory, ctx.home) && !isRoot(directory)) {
112
+ if (isProjectRoot(directory)) return directory;
113
+ const parent = dirname(directory);
114
+ if (parent === directory) return void 0;
115
+ directory = parent;
116
+ }
117
+ return void 0;
66
118
  }
67
119
  function display(ctx, path) {
68
- return path.startsWith(ctx.home + sep) ? `~${path.slice(ctx.home.length)}` : path;
120
+ return path === ctx.home || path.startsWith(ctx.home + sep) ? `~${path.slice(ctx.home.length)}` : path;
69
121
  }
70
122
  function step(ctx, agent, item, where, apply) {
71
123
  const target = display(ctx, where);
72
124
  const firstBackup = existsSync(where) && !existsSync(`${where}.bak`);
73
125
  try {
74
126
  const state = apply();
75
- const backedUp = firstBackup && !ctx.dryRun && (state === "added" || state === "updated");
76
- return { agent, item, where: backedUp ? `${target} (backup: ${target}.bak)` : target, state };
127
+ const backedUp = state === "replaced" || firstBackup && !ctx.dryRun && (state === "added" || state === "updated");
128
+ const change = { agent, item, where: backedUp ? `${target} (backup: ${target}.bak)` : target, state };
129
+ if (state === "kept") change.note = `Run npx formhaus --force to replace it. Your version will be saved as ${target}.bak.`;
130
+ return change;
77
131
  } catch (error) {
78
- return { agent, item, where: target, state: "failed", error: error instanceof Error ? error.message : String(error) };
132
+ return { agent, item, where: target, state: "failed", note: error instanceof Error ? error.message : String(error) };
79
133
  }
80
134
  }
81
135
 
@@ -141,6 +195,7 @@ function command(ctx, executable, args) {
141
195
  // src/claude-code.ts
142
196
  var NAME = "Claude Code";
143
197
  var PLUGIN_PREFIX = `${SERVER_NAME}@`;
198
+ var SHIPPED_SKILLS = ["e929afc9f58a267647341d962283d84749a18028e5ec2c4bd770a214c26a7c25"];
144
199
  var CLAUDE_ADD_ARGS = ["mcp", "add", "--scope", "user", SERVER_NAME, "--", SERVER.command, ...SERVER.args];
145
200
  function configDir(ctx) {
146
201
  return ctx.env.CLAUDE_CONFIG_DIR || join(ctx.home, ".claude");
@@ -203,7 +258,7 @@ var claudeCode = {
203
258
  NAME,
204
259
  "formhaus-create-form skill",
205
260
  skill,
206
- () => hasPlugin(ctx) ? "present" : writeIfChanged(ctx, skill, SKILL_default)
261
+ () => hasPlugin(ctx) ? "present" : writeAsset(ctx, skill, SKILL_default, SHIPPED_SKILLS)
207
262
  ),
208
263
  ...projectWarning(ctx)
209
264
  ];
@@ -887,7 +942,7 @@ function validateTablePeek(ctx, peek, ptr) {
887
942
  if (peek === null || ctx.uk === 2)
888
943
  TomlError.x(peek === null ? "trying to redefine an already defined table or value" : "document contains an unsafe property", ctx, ptr);
889
944
  }
890
- function parse(toml, options = {}) {
945
+ function parse2(toml, options = {}) {
891
946
  let ctx = {
892
947
  s: toml,
893
948
  p: 0,
@@ -975,7 +1030,7 @@ function isTable(value) {
975
1030
  }
976
1031
  function parseToml(text) {
977
1032
  try {
978
- return parse(text, { integersAsBigInt: true });
1033
+ return parse2(text, { integersAsBigInt: true });
979
1034
  } catch {
980
1035
  return void 0;
981
1036
  }
@@ -1026,9 +1081,7 @@ function claudeDesktopDir(ctx) {
1026
1081
  function found(ctx, directory, bin) {
1027
1082
  return isDirectory(directory) || findOnPath(ctx, bin) !== void 0;
1028
1083
  }
1029
- function isProject(ctx) {
1030
- return ctx.cwd !== ctx.home && existsSync(join(ctx.cwd, "package.json"));
1031
- }
1084
+ var SHIPPED_RULES = ["1bb79502ba88f1d38bef0bb3f34ede713f94487643496eae94ca6be7d2c8a32c"];
1032
1085
  var codex = {
1033
1086
  name: "Codex",
1034
1087
  detect: (ctx) => found(ctx, codexHome(ctx), "codex"),
@@ -1043,9 +1096,13 @@ var cursor = {
1043
1096
  apply(ctx) {
1044
1097
  const file = join(ctx.home, ".cursor", "mcp.json");
1045
1098
  const changes = [step(ctx, "Cursor", "MCP server", file, () => addJsonServer(ctx, file))];
1046
- if (!isProject(ctx)) return changes;
1047
- const rule = join(ctx.cwd, ".cursor", "rules", "formhaus.mdc");
1048
- changes.push(step(ctx, "Cursor", "formhaus rule", rule, () => writeIfChanged(ctx, rule, formhaus_default, ctx.cwd)));
1099
+ const root = findProjectRoot(ctx);
1100
+ if (root === void 0) {
1101
+ const note = "Run npx formhaus inside a project to add the Cursor rule.";
1102
+ return [...changes, { agent: "Cursor", item: "formhaus rule", where: display(ctx, ctx.cwd), state: "skipped", note }];
1103
+ }
1104
+ const rule = join(root, ".cursor", "rules", "formhaus.mdc");
1105
+ changes.push(step(ctx, "Cursor", "formhaus rule", rule, () => writeAsset(ctx, rule, formhaus_default, SHIPPED_RULES, root)));
1049
1106
  return changes;
1050
1107
  }
1051
1108
  };
@@ -1074,8 +1131,11 @@ var MANUAL_AGENTS = [
1074
1131
  var LABELS = {
1075
1132
  added: ["added", "will add"],
1076
1133
  updated: ["updated", "will update"],
1134
+ replaced: ["replaced", "will replace"],
1077
1135
  present: ["already set up", "already set up"],
1136
+ kept: ["has your edits, left unchanged", "has your edits, will leave unchanged"],
1078
1137
  different: ["different server, check it", "different server, check it"],
1138
+ skipped: ["skipped, not in a project", "skipped, not in a project"],
1079
1139
  failed: ["failed", "failed"]
1080
1140
  };
1081
1141
  function label(ctx, state) {
@@ -1089,7 +1149,7 @@ function printChanges(ctx, changes, print) {
1089
1149
  for (const change of changes) {
1090
1150
  const row = [change.agent.padEnd(agentWidth), change.item.padEnd(itemWidth), label(ctx, change.state).padEnd(stateWidth), change.where];
1091
1151
  print(` ${row.join(" ")}`);
1092
- if (change.error) print(` ${change.error}`);
1152
+ if (change.note) print(` ${change.note}`);
1093
1153
  }
1094
1154
  }
1095
1155
  function printManual(names, print) {
@@ -1115,7 +1175,7 @@ function setup(ctx, print) {
1115
1175
  printManual(manual, print);
1116
1176
  }
1117
1177
  const failed = changes.some((change) => change.state === "failed");
1118
- const changed = changes.some((change) => change.state === "added" || change.state === "updated");
1178
+ const changed = changes.some((change) => change.state === "added" || change.state === "updated" || change.state === "replaced");
1119
1179
  print("");
1120
1180
  const different = changes.some((change) => change.state === "different");
1121
1181
  if (different) {
@@ -1136,16 +1196,18 @@ var HELP = `Usage: npx formhaus [setup] [options]
1136
1196
  Adds the Formhaus MCP server (npx -y @formhaus/mcp) to the coding agents
1137
1197
  installed on this machine: Claude Code, Codex, Cursor and Claude Desktop.
1138
1198
  Installs the formhaus-create-form skill in Claude Code and, inside a project,
1139
- the Formhaus rule in .cursor/rules. Running it again changes nothing.
1199
+ the Formhaus rule in .cursor/rules at the project root. Running it again
1200
+ changes nothing. Your edits to the skill and the rule are kept.
1140
1201
 
1141
1202
  Options:
1142
1203
  --dry-run Show the planned changes and write nothing
1204
+ --force Replace your edited skill and rule, saving them as .bak
1143
1205
  -h, --help Show this help
1144
1206
  -v, --version Show the version`;
1145
1207
  function main(argv, ctx, print, printError) {
1146
1208
  let parsed;
1147
1209
  try {
1148
- parsed = parse2(argv);
1210
+ parsed = parse3(argv);
1149
1211
  } catch (error) {
1150
1212
  printError(`${error instanceof Error ? error.message : String(error)}
1151
1213
  Run npx formhaus --help for usage.`);
@@ -1161,14 +1223,15 @@ Run npx formhaus --help for usage.`);
1161
1223
  Run npx formhaus --help for usage.`);
1162
1224
  return 1;
1163
1225
  }
1164
- return setup({ ...ctx, dryRun: values["dry-run"] === true }, print);
1226
+ return setup({ ...ctx, dryRun: values["dry-run"] === true, force: values.force === true }, print);
1165
1227
  }
1166
- function parse2(argv) {
1228
+ function parse3(argv) {
1167
1229
  return parseArgs({
1168
1230
  args: argv,
1169
1231
  allowPositionals: true,
1170
1232
  options: {
1171
1233
  "dry-run": { type: "boolean" },
1234
+ force: { type: "boolean" },
1172
1235
  help: { type: "boolean", short: "h" },
1173
1236
  version: { type: "boolean", short: "v" }
1174
1237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formhaus",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Set up the Formhaus MCP server and agent instructions in Claude Code, Codex, Cursor and Claude Desktop with one command.",
5
5
  "type": "module",
6
6
  "engines": {