@link-assistant/hive-mind 1.53.0 → 1.54.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.
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Centralized definitions for GitHub comments posted by solve.mjs itself
5
+ * (session bookkeeping, log uploads, auto-restart notices, etc.) — as
6
+ * opposed to comments posted by the AI agent via its own tool calls.
7
+ *
8
+ * Issue #1625: --auto-attach-solution-summary was broken because the tool's
9
+ * own "AI Work Session Started" / "Solution Draft Log" / "Ready to merge"
10
+ * comments counted as AI-authored comments, so the summary was always
11
+ * suppressed even when the AI session produced zero comments of its own.
12
+ *
13
+ * This module is the single source of truth for the marker strings embedded
14
+ * in those comments. Posting sites use these constants to *build* comment
15
+ * bodies; the summary filter uses the same constants to *detect* them. If a
16
+ * marker needs to change, changing it here updates both sides — no more
17
+ * duplicate literals drifting apart.
18
+ *
19
+ * It also provides in-memory tracking: any comment posted by solve.mjs can
20
+ * be registered by its numeric GitHub comment ID, and checkForAiCreatedComments
21
+ * uses that set as the *primary* filter (marker matching is the fallback for
22
+ * comments whose IDs were not captured, e.g. when `gh pr comment` didn't
23
+ * return JSON).
24
+ */
25
+
26
+ // ----------------------------------------------------------------------------
27
+ // Marker constants — single source of truth for comment header/keyphrase text.
28
+ // Each constant is the exact substring that both (a) appears in the posted
29
+ // comment body and (b) is searched for when filtering out tool-generated
30
+ // comments. Do NOT duplicate these literals elsewhere.
31
+ // ----------------------------------------------------------------------------
32
+
33
+ // solve.session.lib.mjs — startWorkSession() / endWorkSession()
34
+ export const AI_WORK_SESSION_STARTED_MARKER = 'AI Work Session Started';
35
+ export const AI_WORK_SESSION_COMPLETED_MARKER = 'AI Work Session Completed';
36
+ export const AI_WORK_SESSION_RESUMED_MARKER = 'AI Work Session Resumed';
37
+
38
+ // solve.session.lib.mjs — auto-resume / auto-restart on limit reset
39
+ export const AUTO_RESUME_ON_LIMIT_RESET_MARKER = 'Auto Resume (on limit reset)';
40
+ export const AUTO_RESTART_ON_LIMIT_RESET_MARKER = 'Auto Restart (on limit reset)';
41
+
42
+ // github.lib.mjs — attachLogToGitHub() success / resumed / truncated log comments
43
+ export const SOLUTION_DRAFT_LOG_MARKER = 'Solution Draft Log';
44
+
45
+ // solve.watch.lib.mjs / solve.auto-merge.lib.mjs — auto-restart notifications
46
+ export const AUTO_RESTART_MARKER = 'Auto-restart';
47
+ export const AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER = 'Auto-restart-until-mergeable Log';
48
+
49
+ // solve.auto-merge.lib.mjs — "ready to merge" status comments
50
+ export const READY_TO_MERGE_MARKER = 'Ready to merge';
51
+
52
+ // solve.auto-merge.lib.mjs — "auto-merged successfully" status comments
53
+ export const AUTO_MERGED_MARKER = 'Auto-merged';
54
+
55
+ // solve.auto-merge.lib.mjs — billing-limit notification (spending cap / free tier)
56
+ export const BILLING_LIMIT_MARKER = 'GitHub Actions Billing Limit';
57
+
58
+ // github.lib.mjs — fork contributor "Allow edits by maintainers" request
59
+ export const MAINTAINER_ACCESS_REQUEST_MARKER = 'Allow edits by maintainers';
60
+
61
+ // solve.progress-monitoring.lib.mjs — live-progress comment section markers.
62
+ // These are HTML comments so they don't render in the GitHub UI; they exist
63
+ // specifically to let the tool find its own comment later.
64
+ export const LIVE_PROGRESS_SECTION_START_MARKER = '<!-- LIVE-PROGRESS-START -->';
65
+ export const LIVE_PROGRESS_SECTION_END_MARKER = '<!-- LIVE-PROGRESS-END -->';
66
+
67
+ // claude.lib.mjs — "session force-killed due to stream timeout" notifications
68
+ export const SESSION_FORCE_KILLED_MARKER = 'Session Force-Killed';
69
+
70
+ // solve.repo-setup.lib.mjs / solve.repository.lib.mjs — issue comments posted
71
+ // when the target repository is empty / uninitialized so solving can't start.
72
+ export const REPOSITORY_INITIALIZATION_REQUIRED_MARKER = 'Repository Initialization Required';
73
+
74
+ // interactive-mode.lib.mjs — interactive mode session comments
75
+ export const INTERACTIVE_SESSION_STARTED_MARKER = 'Interactive session started';
76
+ export const INTERACTIVE_SESSION_ENDED_MARKER = 'Interactive session ended';
77
+
78
+ // github.lib.mjs — closing footer present in every log upload comment variant
79
+ export const NOW_WORKING_SESSION_IS_ENDED_MARKER = 'Now working session is ended';
80
+
81
+ // Failure-path markers (github.lib.mjs error paths)
82
+ export const SOLUTION_DRAFT_FAILED_MARKER = 'Solution Draft Failed';
83
+ export const SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER = 'Solution Draft Finished with Errors';
84
+ export const USAGE_LIMIT_REACHED_MARKER = 'Usage Limit Reached';
85
+
86
+ /**
87
+ * Every marker that identifies a tool-posted comment. Derived from the
88
+ * named constants above so that adding a new marker only requires adding
89
+ * the constant and appending it here.
90
+ */
91
+ export const TOOL_GENERATED_COMMENT_MARKERS = [AI_WORK_SESSION_STARTED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER, AI_WORK_SESSION_RESUMED_MARKER, AUTO_RESUME_ON_LIMIT_RESET_MARKER, AUTO_RESTART_ON_LIMIT_RESET_MARKER, SOLUTION_DRAFT_LOG_MARKER, AUTO_RESTART_MARKER, AUTO_RESTART_UNTIL_MERGEABLE_LOG_MARKER, READY_TO_MERGE_MARKER, AUTO_MERGED_MARKER, BILLING_LIMIT_MARKER, MAINTAINER_ACCESS_REQUEST_MARKER, LIVE_PROGRESS_SECTION_START_MARKER, SESSION_FORCE_KILLED_MARKER, REPOSITORY_INITIALIZATION_REQUIRED_MARKER, INTERACTIVE_SESSION_STARTED_MARKER, INTERACTIVE_SESSION_ENDED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER];
92
+
93
+ /**
94
+ * Markers that indicate the end of a working session. Used by
95
+ * solve.auto-merge-helpers.checkForExistingComment to scope the
96
+ * duplicate-search window to the current session only (Issue #1584).
97
+ */
98
+ export const SESSION_ENDING_MARKERS = [NOW_WORKING_SESSION_IS_ENDED_MARKER, AI_WORK_SESSION_COMPLETED_MARKER];
99
+
100
+ /**
101
+ * Determine whether a GitHub comment body matches any known tool-generated
102
+ * marker. Used as a fallback when a comment's ID was not captured by
103
+ * in-memory tracking (see below).
104
+ *
105
+ * @param {string} body - The comment body
106
+ * @returns {boolean} - True if the body contains a tool-generated marker
107
+ */
108
+ export const isToolGeneratedComment = body => {
109
+ if (!body || typeof body !== 'string') return false;
110
+ return TOOL_GENERATED_COMMENT_MARKERS.some(marker => body.includes(marker));
111
+ };
112
+
113
+ // ----------------------------------------------------------------------------
114
+ // In-memory tracking of comments posted by solve.mjs during this session.
115
+ //
116
+ // Every tool-initiated comment-post helper should register its comment ID
117
+ // via trackToolCommentId(). checkForAiCreatedComments() then uses the set
118
+ // as the primary filter, falling back to marker-based detection for any
119
+ // comment whose ID was not captured.
120
+ //
121
+ // IDs are GitHub numeric comment IDs (from issue/PR/review comment APIs),
122
+ // coerced to strings for consistent Set membership. Review (inline) comments
123
+ // and conversation comments share the same ID namespace at the API layer,
124
+ // but we never mix them since solve.mjs only posts to conversation + issue
125
+ // endpoints — review comments are AI-only.
126
+ // ----------------------------------------------------------------------------
127
+
128
+ const trackedToolCommentIds = new Set();
129
+
130
+ /**
131
+ * Register a comment ID as tool-generated. Safe to call with null/undefined
132
+ * (e.g., when comment posting failed or the ID couldn't be extracted).
133
+ * @param {string|number|null|undefined} commentId
134
+ */
135
+ export const trackToolCommentId = commentId => {
136
+ if (commentId === null || commentId === undefined) return;
137
+ trackedToolCommentIds.add(String(commentId));
138
+ };
139
+
140
+ /**
141
+ * Returns whether a given comment ID was posted by solve.mjs itself during
142
+ * this session.
143
+ * @param {string|number|null|undefined} commentId
144
+ * @returns {boolean}
145
+ */
146
+ export const isToolTrackedCommentId = commentId => {
147
+ if (commentId === null || commentId === undefined) return false;
148
+ return trackedToolCommentIds.has(String(commentId));
149
+ };
150
+
151
+ /**
152
+ * Returns the set of tracked comment IDs (read-only snapshot).
153
+ * Primarily for tests and diagnostics.
154
+ * @returns {Set<string>}
155
+ */
156
+ export const getTrackedToolCommentIds = () => new Set(trackedToolCommentIds);
157
+
158
+ /**
159
+ * Reset tracking state. Primarily for tests; solve.mjs does not need to
160
+ * call this between real sessions because each invocation is a fresh
161
+ * process.
162
+ */
163
+ export const resetTrackedToolCommentIds = () => {
164
+ trackedToolCommentIds.clear();
165
+ };
166
+
167
+ /**
168
+ * Post a GitHub comment on a PR or issue via `gh api` and return the
169
+ * numeric comment ID (as string). The ID is also automatically tracked in
170
+ * the in-memory set above.
171
+ *
172
+ * This is the preferred path for all tool-posted comments because `gh pr
173
+ * comment` / `gh issue comment` only print the comment URL to stdout, and
174
+ * extracting the numeric ID from a URL is brittle. `gh api POST` returns
175
+ * full JSON, from which the ID is trivial to extract.
176
+ *
177
+ * Falls back to best-effort URL parsing if JSON parsing fails, so a single
178
+ * API change cannot break the code path.
179
+ *
180
+ * @param {Object} options
181
+ * @param {Function} options.$ - command-stream tagged template (required — we
182
+ * accept it as a parameter so this module has no top-level dependency on
183
+ * `command-stream`, keeping it cheap to import from tests)
184
+ * @param {string} options.owner
185
+ * @param {string} options.repo
186
+ * @param {number|string} options.targetNumber - PR or issue number
187
+ * @param {string} options.body
188
+ * @returns {Promise<{ok: boolean, commentId: string|null, stderr?: string}>}
189
+ */
190
+ export const postTrackedComment = async ({ $, owner, repo, targetNumber, body }) => {
191
+ if (!$) {
192
+ throw new Error('postTrackedComment requires a command-stream $ helper');
193
+ }
194
+
195
+ // Use `gh api` with stdin to avoid shell-quoting problems on multi-line
196
+ // bodies and to get JSON back so we can extract the comment ID.
197
+ // We use the /issues/<n>/comments endpoint because it works identically
198
+ // for both PRs and issues (a PR is an issue at this endpoint).
199
+ const apiPath = `repos/${owner}/${repo}/issues/${targetNumber}/comments`;
200
+ const payload = JSON.stringify({ body });
201
+
202
+ // `gh api --input -` reads from stdin. command-stream supports .stdin(...)
203
+ // on the returned process handle (same API used in interactive-mode.lib.mjs
204
+ // via execFileAsync). We build the invocation through $ so callers can
205
+ // inject a mock.
206
+ let result;
207
+ try {
208
+ result = await $({ input: payload })`gh api ${apiPath} -X POST --input -`;
209
+ } catch (err) {
210
+ return { ok: false, commentId: null, stderr: err && err.message ? err.message : String(err) };
211
+ }
212
+
213
+ if (result.code !== 0) {
214
+ const stderr = result.stderr ? result.stderr.toString() : '';
215
+ return { ok: false, commentId: null, stderr };
216
+ }
217
+
218
+ const stdout = result.stdout ? result.stdout.toString() : '';
219
+ let commentId = null;
220
+ try {
221
+ const parsed = JSON.parse(stdout);
222
+ if (parsed && parsed.id !== undefined && parsed.id !== null) {
223
+ commentId = String(parsed.id);
224
+ }
225
+ } catch {
226
+ // Fallback: match numeric id in the JSON text, or the issuecomment-<n>
227
+ // fragment in the html_url, whichever shows up first.
228
+ const match = stdout.match(/"id"\s*:\s*(\d+)|issuecomment-(\d+)/);
229
+ if (match) commentId = match[1] || match[2] || null;
230
+ }
231
+
232
+ trackToolCommentId(commentId);
233
+
234
+ return { ok: true, commentId };
235
+ };
236
+
237
+ /**
238
+ * Post a GitHub comment whose body is already written to a file on disk.
239
+ * Used by attachLogToGitHub() where the comment body can be tens of KB
240
+ * (entire execution log embedded in a <details>) — too large for inline
241
+ * shell arguments and awkward to pipe as stdin JSON.
242
+ *
243
+ * Reads the file and posts via postTrackedComment() so the returned comment
244
+ * ID is tracked exactly like any other tool-posted comment. Kept separate
245
+ * from postTrackedComment so callers that already have a body string don't
246
+ * pay for a tempfile round-trip.
247
+ *
248
+ * @param {Object} options
249
+ * @param {Function} options.$ - command-stream tagged template
250
+ * @param {string} options.owner
251
+ * @param {string} options.repo
252
+ * @param {number|string} options.targetNumber
253
+ * @param {string} options.bodyFile - absolute path to the comment body file
254
+ * @returns {Promise<{ok: boolean, commentId: string|null, stderr?: string}>}
255
+ */
256
+ export const postTrackedCommentFromFile = async ({ $, owner, repo, targetNumber, bodyFile }) => {
257
+ if (!$) {
258
+ throw new Error('postTrackedCommentFromFile requires a command-stream $ helper');
259
+ }
260
+ if (typeof globalThis.use === 'undefined') {
261
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
262
+ }
263
+ const fs = (await globalThis.use('fs')).promises;
264
+ let body;
265
+ try {
266
+ body = await fs.readFile(bodyFile, 'utf8');
267
+ } catch (err) {
268
+ return { ok: false, commentId: null, stderr: err && err.message ? err.message : String(err) };
269
+ }
270
+ return postTrackedComment({ $, owner, repo, targetNumber, body });
271
+ };
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // Useless Claude Code tools and MCP servers for autonomous headless workflows.
3
+ //
4
+ // Hive-mind runs `claude` inside Docker with `--print --dangerously-skip-permissions`
5
+ // and no human operator. Several built-in Claude Code tools and three `claude.ai`
6
+ // OAuth MCP connectors are active by default but either:
7
+ // - wait for a human reaction that will never come (`AskUserQuestion`,
8
+ // `EnterPlanMode`);
9
+ // - have side effects that outlive the session (`CronCreate`,
10
+ // `EnterWorktree`);
11
+ // - can never complete authentication without an interactive browser
12
+ // (`claude.ai Gmail`, `claude.ai Google Drive`, `claude.ai Google Calendar`).
13
+ //
14
+ // This module centralises the block-list and provides helpers that both the
15
+ // Docker image baseline and the `solve` runtime use to disable them.
16
+ //
17
+ // Related issue: https://github.com/link-assistant/hive-mind/issues/1627
18
+
19
+ if (typeof globalThis.use === 'undefined') {
20
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
21
+ }
22
+ const fs = (await use('fs')).promises;
23
+ const os = await use('os');
24
+ const path = (await use('path')).default;
25
+
26
+ /**
27
+ * Built-in Claude Code tools that have no value (and may be harmful) in
28
+ * autonomous headless hive-mind runs. Every entry is a tool name as it
29
+ * appears in the stream-json `tools` array emitted by `claude --verbose`.
30
+ */
31
+ export const USELESS_CLAUDE_BUILTIN_TOOLS = Object.freeze(['AskUserQuestion', 'CronCreate', 'CronDelete', 'CronList', 'EnterPlanMode', 'EnterWorktree', 'ExitPlanMode', 'ExitWorktree', 'Monitor', 'NotebookEdit', 'PushNotification', 'RemoteTrigger', 'ScheduleWakeup']);
32
+
33
+ /**
34
+ * Name prefixes of MCP servers that are always unusable in headless Docker
35
+ * runs because they require interactive OAuth that cannot complete without
36
+ * a browser. Match is case-insensitive on the full MCP server name.
37
+ */
38
+ export const USELESS_MCP_SERVER_NAME_PREFIXES = Object.freeze(['claude.ai gmail', 'claude.ai google drive', 'claude.ai google calendar']);
39
+
40
+ /**
41
+ * MCP tool-name prefixes derived from {@link USELESS_MCP_SERVER_NAME_PREFIXES}.
42
+ * Claude Code exposes MCP tools as `mcp__<server-name-slug>__<tool-name>`,
43
+ * replacing non-alphanumerics in the server name with `_`. Passing these
44
+ * entries to `--disallowedTools` is a belt-and-braces measure that complements
45
+ * filtering the MCP server itself.
46
+ */
47
+ export const USELESS_MCP_TOOL_NAME_PREFIXES = Object.freeze(['mcp__claude_ai_Gmail', 'mcp__claude_ai_Google_Drive', 'mcp__claude_ai_Google_Calendar']);
48
+
49
+ /**
50
+ * Tool identifiers accepted by `claude --disallowedTools ...`. This is a
51
+ * flat list combining the built-in tools with the wildcard forms of the
52
+ * useless MCP tool-name prefixes.
53
+ */
54
+ export const buildDisallowedToolsList = () => [...USELESS_CLAUDE_BUILTIN_TOOLS, ...USELESS_MCP_TOOL_NAME_PREFIXES.map(prefix => `${prefix}__*`)];
55
+
56
+ /**
57
+ * Returns true if `name` matches one of the useless MCP server prefixes.
58
+ */
59
+ export const isUselessMcpServerName = name => {
60
+ if (!name || typeof name !== 'string') return false;
61
+ const lower = name.toLowerCase();
62
+ return USELESS_MCP_SERVER_NAME_PREFIXES.some(prefix => lower.startsWith(prefix));
63
+ };
64
+
65
+ /**
66
+ * Returns the set of MCP server entries from an object (typically
67
+ * `~/.claude.json` `mcpServers` block) with useless entries removed.
68
+ */
69
+ export const filterMcpServersObject = (mcpServers = {}) => {
70
+ const filtered = {};
71
+ for (const [name, config] of Object.entries(mcpServers || {})) {
72
+ if (isUselessMcpServerName(name)) continue;
73
+ filtered[name] = config;
74
+ }
75
+ return filtered;
76
+ };
77
+
78
+ /**
79
+ * Build a temporary MCP config JSON file that filters out both the three
80
+ * `claude.ai` OAuth connectors and (optionally) Playwright.
81
+ *
82
+ * Designed to be used with `--strict-mcp-config --mcp-config <file>` so the
83
+ * excluded servers are not even advertised to the model for this run.
84
+ *
85
+ * @param {Object} [options]
86
+ * @param {boolean} [options.excludePlaywright] - Also exclude Playwright.
87
+ * @param {Function} [options.log] - Async logger with (msg, opts) signature.
88
+ * @returns {Promise<string|null>} absolute path to the temp config, or null
89
+ * if the home `.claude.json` file cannot be read (fatal errors are
90
+ * caught — callers should treat `null` as "skip --strict-mcp-config").
91
+ */
92
+ export const buildFilteredMcpConfig = async ({ excludePlaywright = false, log } = {}) => {
93
+ try {
94
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
95
+ const claudeJson = JSON.parse(await fs.readFile(claudeJsonPath, 'utf-8'));
96
+ const mcpServers = claudeJson.mcpServers || {};
97
+ const filtered = {};
98
+ for (const [name, config] of Object.entries(mcpServers)) {
99
+ if (isUselessMcpServerName(name)) continue;
100
+ if (excludePlaywright && name.toLowerCase().includes('playwright')) continue;
101
+ filtered[name] = config;
102
+ }
103
+ const suffix = excludePlaywright ? 'no-playwright-no-useless' : 'no-useless';
104
+ const tempConfigPath = path.join(os.tmpdir(), `claude-mcp-${suffix}-${Date.now()}-${process.pid}.json`);
105
+ await fs.writeFile(tempConfigPath, JSON.stringify({ mcpServers: filtered }, null, 2));
106
+ if (log) {
107
+ const excluded = [...USELESS_MCP_SERVER_NAME_PREFIXES.map(p => `'${p}*'`), ...(excludePlaywright ? ["'playwright*'"] : [])].join(', ');
108
+ await log(`🧰 Created filtered MCP config (excluding ${excluded}): ${tempConfigPath}`, { verbose: true });
109
+ }
110
+ return tempConfigPath;
111
+ } catch (err) {
112
+ if (log) await log(`⚠️ Could not build filtered useless-MCP config: ${err.message}`, { verbose: true });
113
+ return null;
114
+ }
115
+ };
116
+
117
+ /**
118
+ * Resolve the per-session Claude CLI args for the useless-tools flag and the
119
+ * playwright-mcp flag in a single step. Returns an object with:
120
+ * - mcpConfigPath (string|null): temp file path for `--strict-mcp-config --mcp-config`
121
+ * - disallowedToolsList (string[]): values for `--disallowedTools`
122
+ *
123
+ * Callers are expected to append the returned values to the Claude command.
124
+ * Extracted from claude.lib.mjs to keep that file under the 1500-line cap.
125
+ */
126
+ export const resolveClaudeSessionToolFlags = async ({ argv, log, fallbackBuildMcpConfigWithoutPlaywright } = {}) => {
127
+ const uselessToolsDisabled = argv?.uselessToolsDisabled !== false;
128
+ const excludePlaywright = argv?.playwrightMcp === false;
129
+ let mcpConfigPath = null;
130
+ if (uselessToolsDisabled || excludePlaywright) {
131
+ mcpConfigPath = await buildFilteredMcpConfig({ excludePlaywright, log });
132
+ if (!mcpConfigPath && excludePlaywright && fallbackBuildMcpConfigWithoutPlaywright) {
133
+ mcpConfigPath = await fallbackBuildMcpConfigWithoutPlaywright(log);
134
+ }
135
+ if (mcpConfigPath && log) {
136
+ if (excludePlaywright) await log('🎭 Playwright MCP physically disabled for this session via --strict-mcp-config', { verbose: true });
137
+ if (uselessToolsDisabled) await log('🧰 Useless MCP servers (claude.ai Gmail/Drive/Calendar) disabled for this session via --strict-mcp-config (issue #1627)', { verbose: true });
138
+ }
139
+ }
140
+ const disallowedToolsList = uselessToolsDisabled ? buildDisallowedToolsList() : [];
141
+ if (uselessToolsDisabled && log) await log(`🧰 Disallowed ${disallowedToolsList.length} useless Claude Code tool(s) for this session (issue #1627)`, { verbose: true });
142
+ return { mcpConfigPath, disallowedToolsList };
143
+ };
144
+
145
+ /**
146
+ * Persist `disallowedTools` in `~/.claude/settings.json` so even interactive
147
+ * `claude` sessions launched outside of `solve` don't surface the useless
148
+ * tools. Existing entries in the settings file are preserved (shallow
149
+ * merge) and any existing `disallowedTools` list has the useless tools
150
+ * added to it without duplicates. Returns the set of tools that were
151
+ * newly added.
152
+ */
153
+ export const ensureDisallowedToolsInSettings = async ({ settingsPath, log } = {}) => {
154
+ const resolvedPath = settingsPath || path.join(os.homedir(), '.claude', 'settings.json');
155
+ const toBlock = buildDisallowedToolsList();
156
+ let settings = {};
157
+ try {
158
+ const content = await fs.readFile(resolvedPath, 'utf-8');
159
+ settings = JSON.parse(content);
160
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) settings = {};
161
+ } catch (err) {
162
+ if (err.code !== 'ENOENT' && log) {
163
+ await log(`⚠️ Could not read ${resolvedPath}: ${err.message}`, { verbose: true });
164
+ }
165
+ settings = {};
166
+ }
167
+ const existing = Array.isArray(settings.disallowedTools) ? settings.disallowedTools : [];
168
+ const merged = [...existing];
169
+ const added = [];
170
+ for (const tool of toBlock) {
171
+ if (!merged.includes(tool)) {
172
+ merged.push(tool);
173
+ added.push(tool);
174
+ }
175
+ }
176
+ settings.disallowedTools = merged;
177
+ try {
178
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
179
+ await fs.writeFile(resolvedPath, JSON.stringify(settings, null, 2));
180
+ if (log && added.length) {
181
+ await log(`🧰 Added ${added.length} useless tool(s) to ${resolvedPath} disallowedTools`, { verbose: true });
182
+ }
183
+ } catch (err) {
184
+ if (log) await log(`⚠️ Could not write ${resolvedPath}: ${err.message}`, { verbose: true });
185
+ }
186
+ return { added, total: merged.length, path: resolvedPath };
187
+ };