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.
- package/README.md +4 -3
- package/dist/cli.js +97 -34
- 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
|
|
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`
|
|
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 {
|
|
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.
|
|
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
|
|
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,
|
|
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) &&
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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",
|
|
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" :
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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