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.
- package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
- package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
- package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
- package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
- package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
- package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
- package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
- package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
- package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
- package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
- package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
- package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
- package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
- package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
- package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
- package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
- package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
- package/{.claude → .agents}/commands/drupal-search.md +3 -4
- package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
- package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
- package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
- package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
- package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
- package/CHANGELOG.md +56 -0
- package/README.md +21 -11
- package/config/config.example.json +1 -1
- package/package.json +4 -2
- package/scripts/generate-commands.js +42 -21
- package/scripts/install-commands.js +157 -0
- package/src/lib/backends/backend-interface.js +20 -1
- package/src/lib/backends/jsonapi.js +47 -4
- package/src/lib/body-summary.js +60 -0
- package/src/lib/data-flow.js +437 -0
- package/src/lib/dispatch.js +36 -15
- package/src/lib/drupal-fetch.js +52 -6
- package/src/lib/field-definition.js +285 -0
- package/src/lib/patch-preflight.js +157 -66
- package/src/lib/security.js +14 -0
- package/src/lib/server-tools.js +9 -0
- package/src/lib/write-revision.js +82 -2
- package/src/tools/bulk.js +4 -3
- package/src/tools/entities.js +11 -7
- package/src/tools/index.js +3 -2
- 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.
|
|
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
|
-
"
|
|
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
|
|
3
|
+
* generate-commands.js — generate harness-agnostic slash-command stubs for every tool.
|
|
4
4
|
*
|
|
5
|
-
* Writes one `.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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`, `
|
|
19
|
-
* file-writing side effect runs only
|
|
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
|
-
|
|
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:
|
|
68
|
+
if (options.allowedTools) frontmatter.push(`allowed-tools: ${options.allowedTools}`);
|
|
69
|
+
frontmatter.push("---");
|
|
63
70
|
|
|
64
|
-
const body = [`Call the
|
|
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
|
|
79
|
+
body.push("This tool takes no arguments — call it directly.");
|
|
73
80
|
} else {
|
|
74
|
-
body.push(
|
|
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
|
|
87
|
-
"
|
|
88
|
-
"
|
|
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 .
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|