drupal-mcp-connector 2.7.4 → 2.8.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.
Files changed (141) hide show
  1. package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
  3. package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
  6. package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
  7. package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
  8. package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
  9. package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
  10. package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
  11. package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
  13. package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
  16. package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
  18. package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
  19. package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
  20. package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
  21. package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
  23. package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
  24. package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
  25. package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
  26. package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
  27. package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
  28. package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
  29. package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
  30. package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
  31. package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
  33. package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
  34. package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
  35. package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
  36. package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
  37. package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
  39. package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
  40. package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
  43. package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
  44. package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
  45. package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
  46. package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
  49. package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
  50. package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
  51. package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
  52. package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
  54. package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
  55. package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
  57. package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
  58. package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
  59. package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
  60. package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
  62. package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
  65. package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
  68. package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
  69. package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
  70. package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
  71. package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
  72. package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
  73. package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
  74. package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
  76. package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
  77. package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
  78. package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
  79. package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
  80. package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
  81. package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
  82. package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
  84. package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
  85. package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
  87. package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
  88. package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
  89. package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
  90. package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
  91. package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
  92. package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
  93. package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
  95. package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
  96. package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
  97. package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
  98. package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
  99. package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
  100. package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
  101. package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
  102. package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
  103. package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
  107. package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
  108. package/{.claude → .agents}/commands/drupal-search.md +3 -4
  109. package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
  110. package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
  111. package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
  112. package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
  114. package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
  117. package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
  118. package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
  121. package/CHANGELOG.md +56 -0
  122. package/README.md +21 -11
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/data-flow.js +437 -0
  131. package/src/lib/dispatch.js +36 -15
  132. package/src/lib/drupal-fetch.js +52 -6
  133. package/src/lib/field-definition.js +285 -0
  134. package/src/lib/patch-preflight.js +157 -66
  135. package/src/lib/security.js +14 -0
  136. package/src/lib/server-tools.js +9 -0
  137. package/src/lib/write-revision.js +82 -2
  138. package/src/tools/bulk.js +4 -3
  139. package/src/tools/entities.js +11 -7
  140. package/src/tools/index.js +3 -2
  141. package/src/tools/nodes.js +55 -43
@@ -7,7 +7,7 @@
7
7
  },
8
8
 
9
9
  "_security_options": {
10
- "_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. See docs/security-hardening.md."
10
+ "_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. Optional security.declaredCeiling (narrow-only X-MCP-Declared-Ceiling) and security.readBudgets (same finite classes as mcp_sentinel) bind northbound data-flow to the inbound principal and target. See docs/security.md and docs/security-hardening.md."
11
11
  },
12
12
 
13
13
  "_governance_tiers": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.7.4",
3
+ "version": "2.8.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "src/",
13
13
  "scripts/generate-commands.js",
14
- ".claude/commands/",
14
+ "scripts/install-commands.js",
15
+ ".agents/commands/",
15
16
  "config/config.example.json",
16
17
  "README.md",
17
18
  "CHANGELOG.md",
@@ -50,6 +51,7 @@
50
51
  "start:https": "MCP_TRANSPORT=https node src/index.js",
51
52
  "start:dev": "MCP_TRANSPORT=https MCP_ALLOW_HTTP=1 MCP_PORT=3443 node src/index.js",
52
53
  "generate:commands": "node scripts/generate-commands.js",
54
+ "install:commands": "node scripts/install-commands.js",
53
55
  "lint": "eslint src/",
54
56
  "lint:fix": "eslint src/ --fix",
55
57
  "test": "vitest run",
@@ -1,22 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * generate-commands.js — generate Claude Code slash commands for every tool.
3
+ * generate-commands.js — generate harness-agnostic slash-command stubs for every tool.
4
4
  *
5
- * Writes one `.claude/commands/drupal-<tool>.md` per Drupal tool, giving the
6
- * literal `/drupal-<tool>` slash command in Claude Code (e.g.
7
- * `/drupal-create-node`). Each file is scoped via `allowed-tools` to only its own
8
- * `mcp__drupal__<tool>` and instructs the model to parse `$ARGUMENTS` into the
9
- * tool's parameters before making a single call.
5
+ * Writes one `.agents/commands/drupal-<tool>.md` per Drupal tool. The files use
6
+ * protocol tool names (`drupal_list_nodes`) so any MCP client can consume them;
7
+ * they are not a vendor rule tree. Clients that scan `.agents/commands/` (Grok
8
+ * Build, when this repo is the project) pick them up as `/drupal-<tool>`.
10
9
  *
11
- * These files are *client ergonomics* for operators using Claude Code as an MCP
12
- * client against this server not provider-specific project rules. Development
13
- * instructions for any agent live only in AGENTS.md.
10
+ * Clients that only scan a vendor home directory (Claude Code `~/.claude/commands`,
11
+ * Grok `~/.grok/commands`) get the same files via `npm run install:commands`.
14
12
  *
15
13
  * Driven from the same tool definitions as the server (src/tools/index.js), so the
16
14
  * command set never drifts from the tools. Run: `npm run generate:commands`.
17
15
  *
18
- * Exports `renderCommandMarkdown`, `commandFileName`, and `generate` for tests; the
19
- * file-writing side effect runs only when executed directly.
16
+ * Exports `renderCommandMarkdown`, `renderClaudeCommandMarkdown`, `commandFileName`,
17
+ * `COMMANDS_DIR`, and `generate` for tests; the file-writing side effect runs only
18
+ * when executed directly.
20
19
  */
21
20
 
22
21
  import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
@@ -26,7 +25,8 @@ import { allDefinitions } from "../src/tools/index.js";
26
25
  import { paramList, toolNameToPromptName } from "../src/lib/tool-prompts.js";
27
26
  import { isDestructiveTool } from "../src/lib/operations.js";
28
27
 
29
- const COMMANDS_DIR = new URL("../.claude/commands/", import.meta.url);
28
+ /** Canonical, harness-agnostic command tree shipped in the repo and the npm package. */
29
+ export const COMMANDS_DIR = new URL("../.agents/commands/", import.meta.url);
30
30
 
31
31
  /** Map a tool definition to its command filename: `drupal_create_node` → `drupal-create-node.md`. */
32
32
  export function commandFileName(def) {
@@ -49,19 +49,26 @@ function argumentHint(params) {
49
49
  * Render the markdown for one tool's slash command.
50
50
  *
51
51
  * @param {object} def - The tool definition ({name, description, inputSchema}).
52
+ * @param {object} [options]
53
+ * @param {string} [options.allowedTools] - Optional Claude Code `allowed-tools` value.
54
+ * @param {string} [options.argumentsPhrase="the arguments supplied with this command"]
55
+ * Phrase used in the parse-arguments instruction. Claude Code install rewrites
56
+ * this to `` `$ARGUMENTS` `` because that client substitutes the placeholder.
52
57
  * @returns {string} File contents (ends with a trailing newline).
53
58
  */
54
- export function renderCommandMarkdown(def) {
59
+ export function renderCommandMarkdown(def, options = {}) {
55
60
  const params = paramList(def.inputSchema);
56
61
  const required = params.filter((p) => p.required);
57
62
  const optional = params.filter((p) => !p.required);
58
63
  const line = (p) => `- \`${p.name}\` (${p.hint})${p.description ? `: ${p.description}` : ""}`;
64
+ const argumentsPhrase = options.argumentsPhrase ?? "the arguments supplied with this command";
59
65
 
60
66
  const frontmatter = ["---", `description: ${yamlString(def.description)}`];
61
67
  if (params.length) frontmatter.push(`argument-hint: ${yamlString(argumentHint(params))}`);
62
- frontmatter.push(`allowed-tools: mcp__drupal__${def.name}`, "---");
68
+ if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
69
+ frontmatter.push("---");
63
70
 
64
- const body = [`Call the \`mcp__drupal__${def.name}\` MCP tool.`, "", def.description];
71
+ const body = [`Call the MCP tool \`${def.name}\`.`, "", def.description];
65
72
 
66
73
  if (isDestructiveTool(def.name)) {
67
74
  body.push("", "> ⚠ **Destructive** — this permanently changes or deletes data. Confirm with the user before calling.");
@@ -69,9 +76,9 @@ export function renderCommandMarkdown(def) {
69
76
 
70
77
  body.push("");
71
78
  if (params.length === 0) {
72
- body.push("This tool takes no arguments — call it directly (ignore `$ARGUMENTS`).");
79
+ body.push("This tool takes no arguments — call it directly.");
73
80
  } else {
74
- body.push("Parse the request in `$ARGUMENTS` into this tool's parameters:", "");
81
+ body.push(`Parse ${argumentsPhrase} into this tool's parameters:`, "");
75
82
  if (required.length) {
76
83
  body.push("**Required:**");
77
84
  required.forEach((p) => body.push(line(p)));
@@ -83,15 +90,29 @@ export function renderCommandMarkdown(def) {
83
90
  body.push("");
84
91
  }
85
92
  body.push(
86
- "If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not " +
87
- "invent values. Coerce each value to its JSON type (booleans → true/false, numbers → " +
88
- "numeric, object/array → parse JSON), then make the single tool call and summarize the result."
93
+ "If a required parameter is missing, ask before calling — do not invent values. " +
94
+ "Coerce each value to its JSON type (booleans → true/false, numbers → numeric, " +
95
+ "object/array → parse JSON), then make the single tool call and summarize the result."
89
96
  );
90
97
  }
91
98
 
92
99
  return `${frontmatter.join("\n")}\n\n${body.join("\n")}\n`;
93
100
  }
94
101
 
102
+ /**
103
+ * Claude Code adapter: same stub plus `allowed-tools` scoped to that client's
104
+ * MCP tool name, and `$ARGUMENTS` so typed `/drupal-*` args are not dropped.
105
+ *
106
+ * @param {object} def - The tool definition.
107
+ * @returns {string} File contents.
108
+ */
109
+ export function renderClaudeCommandMarkdown(def) {
110
+ return renderCommandMarkdown(def, {
111
+ allowedTools: `mcp__drupal__${def.name}`,
112
+ argumentsPhrase: "`$ARGUMENTS`",
113
+ });
114
+ }
115
+
95
116
  /**
96
117
  * Write one command file per tool, pruning stale `drupal-*.md` first so removed
97
118
  * tools leave no orphans.
@@ -118,5 +139,5 @@ const invokedDirectly =
118
139
  process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
119
140
  if (invokedDirectly) {
120
141
  const written = generate();
121
- console.error(`[generate-commands] wrote ${written.length} command files to .claude/commands/`);
142
+ console.error(`[generate-commands] wrote ${written.length} command files to .agents/commands/`);
122
143
  }
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * install-commands.js — copy generated `/drupal-*` stubs into an operator's
4
+ * client home directories.
5
+ *
6
+ * The canonical files live in `.agents/commands/` (in-repo, harness-agnostic).
7
+ * Claude Code and Grok only auto-load project commands from vendor paths
8
+ * (`.claude/commands/`, `.grok/commands/`). This script writes the *user-level*
9
+ * copies so `/drupal-*` works in every project without committing a vendor
10
+ * folder to this repo or to a consuming application.
11
+ *
12
+ * Default targets: `~/.claude/commands` (Claude adapter) and `~/.grok/commands`
13
+ * (canonical files). Pass `--clients` to subset. Never writes into a project
14
+ * tree.
15
+ *
16
+ * Run: `npm run install:commands -- [--home DIR] [--clients claude,grok,agents]`
17
+ */
18
+
19
+ import { mkdirSync, readdirSync, rmSync, writeFileSync, realpathSync } from "fs";
20
+ import { homedir } from "os";
21
+ import { join, resolve } from "path";
22
+ import { pathToFileURL } from "url";
23
+
24
+ import { allDefinitions } from "../src/tools/index.js";
25
+ import {
26
+ commandFileName,
27
+ renderCommandMarkdown,
28
+ renderClaudeCommandMarkdown,
29
+ } from "./generate-commands.js";
30
+
31
+ /** Whitelisted install targets. `rel` is under `--home` (default: os.homedir()). */
32
+ export const CLIENTS = {
33
+ claude: {
34
+ rel: ".claude/commands",
35
+ render: renderClaudeCommandMarkdown,
36
+ },
37
+ grok: {
38
+ rel: ".grok/commands",
39
+ render: renderCommandMarkdown,
40
+ },
41
+ agents: {
42
+ rel: ".agents/commands",
43
+ render: renderCommandMarkdown,
44
+ },
45
+ };
46
+
47
+ const DEFAULT_CLIENTS = ["claude", "grok"];
48
+
49
+ /**
50
+ * Parse CLI flags. Unknown flags throw.
51
+ *
52
+ * @param {string[]} argv - Arguments after the script name.
53
+ * @returns {{home?: string, clients: string[], help?: boolean}}
54
+ */
55
+ export function parseArgs(argv) {
56
+ const out = { clients: [...DEFAULT_CLIENTS] };
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ if (a === "--help" || a === "-h") {
60
+ out.help = true;
61
+ continue;
62
+ }
63
+ if (a === "--home") {
64
+ out.home = argv[++i];
65
+ if (!out.home) throw new Error("--home requires a directory");
66
+ continue;
67
+ }
68
+ if (a.startsWith("--home=")) {
69
+ out.home = a.slice("--home=".length);
70
+ continue;
71
+ }
72
+ if (a === "--clients") {
73
+ const raw = argv[++i];
74
+ if (!raw) throw new Error("--clients requires a comma-separated list");
75
+ out.clients = splitClients(raw);
76
+ continue;
77
+ }
78
+ if (a.startsWith("--clients=")) {
79
+ out.clients = splitClients(a.slice("--clients=".length));
80
+ continue;
81
+ }
82
+ throw new Error(`Unknown argument: ${a}`);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function splitClients(raw) {
88
+ const names = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
89
+ if (!names.length) throw new Error("--clients requires at least one client");
90
+ return names;
91
+ }
92
+
93
+ /**
94
+ * Write one `drupal-*.md` per tool into each requested client directory,
95
+ * pruning stale stubs first. Unknown client names fail closed.
96
+ *
97
+ * @param {object} [options]
98
+ * @param {string} [options.home] - Install root (default: os.homedir()).
99
+ * @param {string[]} [options.clients] - Subset of CLIENTS keys.
100
+ * @param {Array<object>} [options.definitions]
101
+ * @returns {Array<{client: string, dir: string, written: string[]}>}
102
+ */
103
+ export function install(options = {}) {
104
+ const home = resolve(options.home || homedir());
105
+ const names = options.clients || DEFAULT_CLIENTS;
106
+ const definitions = options.definitions || allDefinitions;
107
+
108
+ const results = [];
109
+ for (const name of names) {
110
+ const client = CLIENTS[name];
111
+ if (!client) {
112
+ throw new Error(`Unknown client "${name}". Allowed: ${Object.keys(CLIENTS).join(", ")}`);
113
+ }
114
+ const dir = join(home, client.rel);
115
+ mkdirSync(dir, { recursive: true });
116
+ for (const f of readdirSync(dir)) {
117
+ if (/^drupal-.*\.md$/.test(f)) rmSync(join(dir, f));
118
+ }
119
+ const written = [];
120
+ for (const def of definitions) {
121
+ const file = commandFileName(def);
122
+ writeFileSync(join(dir, file), client.render(def));
123
+ written.push(file);
124
+ }
125
+ results.push({ client: name, dir, written });
126
+ }
127
+ return results;
128
+ }
129
+
130
+ const HELP = `Usage: node scripts/install-commands.js [--home DIR] [--clients claude,grok,agents]
131
+
132
+ Copy generated /drupal-* command stubs into operator home directories.
133
+ Does not write into a project tree.
134
+
135
+ --home DIR Install root (default: the current user's home)
136
+ --clients LIST Comma-separated subset of: claude, grok, agents
137
+ (default: claude,grok)
138
+ `;
139
+
140
+ const invokedDirectly =
141
+ process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
142
+ if (invokedDirectly) {
143
+ try {
144
+ const opts = parseArgs(process.argv.slice(2));
145
+ if (opts.help) {
146
+ console.error(HELP);
147
+ process.exit(0);
148
+ }
149
+ const results = install(opts);
150
+ for (const r of results) {
151
+ console.error(`[install-commands] wrote ${r.written.length} files to ${r.dir} (${r.client})`);
152
+ }
153
+ } catch (err) {
154
+ console.error(`[install-commands] ${err.message}`);
155
+ process.exit(1);
156
+ }
157
+ }
@@ -93,7 +93,10 @@ export class Backend {
93
93
 
94
94
  /**
95
95
  * Update an entity.
96
- * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} _input
96
+ * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object, resourceVersion?: string}} _input
97
+ * `resourceVersion` is a JSON:API revision selector (`rel:working-copy`,
98
+ * `rel:latest-version`, `id:<vid>`). Backends that cannot address revisions
99
+ * ignore it.
97
100
  * @returns {Promise<import("../canonical.js").CanonicalEntity>}
98
101
  */
99
102
  async updateEntity(_input) { return notImplemented("updateEntity"); }
@@ -139,6 +142,22 @@ export class Backend {
139
142
  */
140
143
  async getEntitySchema(_entityType, _bundle) { return notImplemented("getEntitySchema"); }
141
144
 
145
+ /**
146
+ * Resolve Field API metadata for one field (internal introspection).
147
+ *
148
+ * `getEntitySchema` is sampling-only and does NOT expose `allowed_formats`.
149
+ * The JSON:API adapter reads `field_config` via an internal fetch — not via
150
+ * agent entity tools (`field_config` is on the agent deny list). Returns
151
+ * null when the definition cannot be resolved. Callers must not invent
152
+ * `allowed_formats` from `defaultTextFormat` or `full_html`.
153
+ *
154
+ * Default: unknown. GraphQL is read-only for writes; write sites use JSON:API.
155
+ *
156
+ * @param {{entityType: string, bundle: string, fieldName: string}} _ref
157
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
158
+ */
159
+ async getFieldDefinition(_ref) { return null; }
160
+
142
161
  /**
143
162
  * List user roles.
144
163
  * @returns {Promise<Array<{id: string, machineName: string, label: string, weight: number}>>}
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
11
11
  import { validateUuid, validateMachineName } from "../validate.js";
12
+ import { parseFieldConfigObject } from "../field-definition.js";
12
13
  import { Backend } from "./backend-interface.js";
13
14
  import {
14
15
  makeCanonicalEntity,
@@ -208,8 +209,10 @@ export class JsonApiBackend extends Backend {
208
209
  if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
209
210
  if (INTERNAL_ATTR_RE.test(k)) {
210
211
  // Paragraph ERR attach needs the current revision id (#192).
212
+ // Node / revisionable writes need the working vs live vid (#166).
211
213
  // Other drupal_internal__* attributes stay stripped.
212
- return entityType === "paragraph" && k === "drupal_internal__revision_id";
214
+ return k === "drupal_internal__vid"
215
+ || (entityType === "paragraph" && k === "drupal_internal__revision_id");
213
216
  }
214
217
  return true;
215
218
  })
@@ -339,17 +342,24 @@ export class JsonApiBackend extends Backend {
339
342
  /**
340
343
  * Update an entity via JSON:API PATCH. Retries without `status` on moderated
341
344
  * bundles — see writeWithModerationFallback.
342
- * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object}} input
345
+ * @param {{entityType: string, bundle: string, id: string, attributes?: object, relationships?: object, resourceVersion?: string}} input
346
+ * `resourceVersion` is a JSON:API revision selector (e.g. `rel:working-copy`).
347
+ * When set, it is appended so a forward revision is PATCHed instead of the
348
+ * canonical default (#166 / Drupal #2795279).
343
349
  * @returns {Promise<import("../canonical.js").CanonicalEntity>} The updated entity.
344
350
  */
345
- async updateEntity({ entityType, bundle, id, attributes = {}, relationships }) {
351
+ async updateEntity({ entityType, bundle, id, attributes = {}, relationships, resourceVersion }) {
346
352
  validateUuid(id);
347
353
  const buildPayload = (attrs) => {
348
354
  const payload = { data: { type: `${entityType}--${bundle}`, id, attributes: attrs } };
349
355
  if (relationships) payload.data.relationships = relationships;
350
356
  return payload;
351
357
  };
352
- const data = await this.writeWithModerationFallback(`${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`, "PATCH", buildPayload, attributes);
358
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
359
+ if (resourceVersion) {
360
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
361
+ }
362
+ const data = await this.writeWithModerationFallback(path, "PATCH", buildPayload, attributes);
353
363
  return this.toCanonical(data.data);
354
364
  }
355
365
 
@@ -512,6 +522,39 @@ export class JsonApiBackend extends Backend {
512
522
  });
513
523
  }
514
524
 
525
+ /**
526
+ * Read Field API metadata from JSON:API `field_config` (chain step 1).
527
+ *
528
+ * This is an internal introspection call, not an agent entity-tool read:
529
+ * `field_config` is on the connector deny list. Do not invent
530
+ * `allowed_formats` from `this.site.defaultTextFormat` or `full_html`.
531
+ * Empty / unreadable `field_config` returns null so the caller can try
532
+ * Drush `config:get field.field.{entityType}.{bundle}.{field}` and, if
533
+ * that also fails, keep the historical default chain only while the list
534
+ * is unknown.
535
+ *
536
+ * @param {{entityType: string, bundle: string, fieldName: string}} ref
537
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
538
+ */
539
+ async getFieldDefinition({ entityType, bundle, fieldName }) {
540
+ validateMachineName(entityType, "entityType");
541
+ validateMachineName(bundle, "bundle");
542
+ validateMachineName(fieldName, "fieldName");
543
+ const params = new URLSearchParams();
544
+ params.set("filter[entity_type]", entityType);
545
+ params.set("filter[bundle]", bundle);
546
+ params.set("filter[field_name]", fieldName);
547
+ params.set("page[limit]", "1");
548
+ let data;
549
+ try {
550
+ data = await drupalFetch(this.site, `/jsonapi/field_config/field_config?${params}`);
551
+ } catch {
552
+ return null;
553
+ }
554
+ const row = Array.isArray(data?.data) ? data.data[0] : data?.data;
555
+ return parseFieldConfigObject(row?.attributes, fieldName);
556
+ }
557
+
515
558
  /**
516
559
  * Describe a bundle's fields by sampling one entity and inferring attribute
517
560
  * types from its values. JSON:API has no schema endpoint, so an empty bundle
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Decide whether the `summary` argument may be written onto `body`.
3
+ *
4
+ * Core `text_with_summary` stores a `summary` property. `text_long` and
5
+ * sampled `text_formatted` do not — Drupal drops an unknown property, so a
6
+ * silent write would lose the teaser. Fail closed when the sampled schema
7
+ * cannot confirm the property exists (#163).
8
+ */
9
+
10
+ /** Warning attached when `summary` is written onto a text_with_summary body. */
11
+ export const SUMMARY_DEPRECATED_WARNING = {
12
+ code: "summary_parameter_deprecated",
13
+ message:
14
+ "The `summary` argument writes body.summary on core text_with_summary. " +
15
+ "Prefer setting the site's dedicated deck/summary field via `fields`.",
16
+ };
17
+
18
+ /**
19
+ * @param {object} backend Backend with `getEntitySchema`.
20
+ * @param {string} bundle Node bundle machine name.
21
+ * @param {string|undefined} summary Caller-supplied summary; undefined = omitted.
22
+ * @returns {Promise<{deprecated: boolean}>} Whether a deprecation notice applies.
23
+ * @throws {Error} When `summary` was supplied and the body field has no
24
+ * summary property, or the schema cannot be determined.
25
+ */
26
+ export async function assertBodySummaryWritable(backend, bundle, summary) {
27
+ if (summary === undefined) return { deprecated: false };
28
+
29
+ let schema;
30
+ try {
31
+ schema = await backend.getEntitySchema("node", bundle);
32
+ } catch (err) {
33
+ const detail = err instanceof Error ? err.message : String(err);
34
+ throw new Error(
35
+ `Cannot write \`summary\`: the body field schema for node.${bundle} could not be determined (${detail}). Set the site's dedicated deck field via \`fields\` instead.`,
36
+ );
37
+ }
38
+
39
+ const bodyType = schema?.attributes?.body;
40
+ if (bodyType === "text_with_summary") {
41
+ return { deprecated: true };
42
+ }
43
+
44
+ const sampled = bodyType ? ` (sampled as ${bodyType})` : "";
45
+ throw new Error(
46
+ `This site's body field has no summary property${sampled}; set the site's deck field via \`fields\`. The \`summary\` argument is only valid for core text_with_summary body fields.`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Attach the `summary` deprecation notice to a write (or dryRun) result.
52
+ *
53
+ * @param {object|null|undefined} result Tool response.
54
+ * @returns {object|null|undefined} Result with `_warnings` when result is an object.
55
+ */
56
+ export function attachSummaryDeprecation(result) {
57
+ if (!result || typeof result !== "object") return result;
58
+ const existing = Array.isArray(result._warnings) ? result._warnings : [];
59
+ return { ...result, _warnings: [...existing, SUMMARY_DEPRECATED_WARNING] };
60
+ }