Skip to content

Commit a0db530

Browse files
committed
refactor(plugins-mcp): dedupe collectors, guards, and register loops
Summary: zero-behavior-change merges in src/plugins, src/mcp, src/web — one generic plugin-candidate collector, one COMMAND_NAME_PATTERN export, one lane-local isENOENT, warning-resolver delegation to diagnostics helper, one shared register loop, inlined segment matcher. isPluginEnabled kept: tests/unit/plugin-register.test.ts imports it. Verification: bun run typecheck clean; bun test ./src/plugins ./src/mcp ./src/web --randomize --seed 424242 → 659 pass 0 fail (identical to pristine-worktree baseline); bun run check EXIT=0 (full suite 7755 pass 0 fail).
1 parent 810fd77 commit a0db530

10 files changed

Lines changed: 99 additions & 89 deletions

‎src/mcp/tool-name.ts‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,14 @@ const MCP_READ_ONLY_TOOL_PREFIXES = [
113113
"fetch_",
114114
] as const;
115115

116-
function mcpToolSegmentMatchesPrefix(
117-
segment: string,
118-
prefixes: readonly string[],
119-
): boolean {
120-
return prefixes.some((prefix) => segment.startsWith(prefix));
121-
}
122-
123116
// Name-prefix fallback when a server omits ToolAnnotations on tools/list.
124117
export function isReadOnlyMcpTool(name: string): boolean {
125118
const parsed = parseMcpToolName(name);
126119
if (parsed === null) return false;
127120
const segment = parsed.tool;
128-
if (mcpToolSegmentMatchesPrefix(segment, MCP_MUTATING_TOOL_PREFIXES))
121+
if (MCP_MUTATING_TOOL_PREFIXES.some((prefix) => segment.startsWith(prefix)))
129122
return false;
130-
return mcpToolSegmentMatchesPrefix(segment, MCP_READ_ONLY_TOOL_PREFIXES);
123+
return MCP_READ_ONLY_TOOL_PREFIXES.some((prefix) =>
124+
segment.startsWith(prefix),
125+
);
131126
}

‎src/plugins/agent-plugins.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { PluginModule } from "./loader.js";
77
import type { PluginConfig } from "../config/settings.js";
88
import { isPluginModuleEnabled } from "./register.js";
99
import {
10-
pluginWarningSink,
10+
resolvePluginWarningHandler,
1111
type PluginLoadDiagnostics,
1212
} from "./diagnostics.js";
1313
import { type } from "arktype";
@@ -27,8 +27,9 @@ function resolveAgentProfileWarningHandler(
2727
): (msg: string) => void {
2828
if (typeof opts === "function") return opts;
2929
if (opts.diagnostics !== undefined)
30-
return pluginWarningSink(opts.diagnostics);
31-
if (opts.onWarning !== undefined) return opts.onWarning;
30+
return resolvePluginWarningHandler({ diagnostics: opts.diagnostics });
31+
if (opts.onWarning !== undefined)
32+
return resolvePluginWarningHandler({ onWarning: opts.onWarning });
3233
return () => undefined;
3334
}
3435

‎src/plugins/data-only-commands.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type CommandResult,
88
type SubcommandDefinition,
99
} from "../tui/commands/registry.js";
10-
import { splitFrontmatter } from "./frontmatter.js";
10+
import { COMMAND_NAME_PATTERN, splitFrontmatter } from "./frontmatter.js";
1111

1212
// A data-only command plugin declares its slash commands as markdown files, the
1313
// same convention Claude Code (`.claude/commands/`), OpenCode
@@ -25,8 +25,6 @@ import { splitFrontmatter } from "./frontmatter.js";
2525
// replaced with the args passed to that command level (all args for a flat
2626
// command; the args after the subcommand for a namespaced one).
2727

28-
const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
29-
3028
interface LoadedBody {
3129
description: string;
3230
body: string;

‎src/plugins/data-only.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface DataOnlyPlugin {
2626
commandPlugin?: CommandPlugin;
2727
}
2828

29-
function isENOENT(err: unknown): boolean {
29+
export function isENOENT(err: unknown): boolean {
3030
return (
3131
typeof err === "object" &&
3232
err !== null &&

‎src/plugins/frontmatter.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ export interface ParsedMarkdown {
99
body: string;
1010
}
1111

12+
// Slash-command names are kebab-case identifiers. Data-only command files and
13+
// skill commands validate the same shape, so the pattern lives here once —
14+
// both modules already import this file for splitFrontmatter.
15+
export const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
16+
1217
// Strip a leading `---\n...\n---` YAML block. Returns { frontmatter, body }.
1318
// No frontmatter block -> empty-object frontmatter (an agent/command can
1419
// legitimately have none). A present-but-malformed block -> null frontmatter

‎src/plugins/loader.ts‎

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import { pathIsInsideOrEqual } from "../util/path-contain.js";
1212
import {
1313
parsePluginManifest,
1414
PluginManifestSchema,
15+
type PluginCredentialField,
16+
type PluginKind,
1517
type PluginManifest,
1618
} from "./manifest.js";
1719
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
1820
import type { PluginLoadReporter } from "../telemetry/product-events.js";
1921
import { runtimePluginLoadReporter } from "../telemetry/singleton.js";
20-
import { loadDataOnlyPlugin } from "./data-only.js";
22+
import { isENOENT, loadDataOnlyPlugin } from "./data-only.js";
2123

2224
import {
2325
resolvePluginWarningHandler,
@@ -76,19 +78,46 @@ export interface PluginModule {
7678
shadowedRepoDefaultEnabled?: boolean;
7779
}
7880

79-
function isENOENT(err: unknown): boolean {
80-
return (
81-
typeof err === "object" &&
82-
err !== null &&
83-
"code" in err &&
84-
(err as { code?: unknown }).code === "ENOENT"
85-
);
86-
}
87-
8881
function errorText(err: unknown): string {
8982
return err instanceof Error ? err.message : String(err);
9083
}
9184

85+
// Flat candidate behind the tool-plugin and web-provider collectors: both
86+
// select modules by manifest kind + factory key and project the same shape,
87+
// so the per-kind entry points stay one-line typed wrappers.
88+
export interface CollectedPluginCandidate<TFactory> {
89+
id: string;
90+
name: string;
91+
description?: string;
92+
credentials: PluginCredentialField[];
93+
factory: (options: unknown) => TFactory | Promise<TFactory>;
94+
}
95+
96+
export function collectPluginCandidates<TFactory>(
97+
modules: PluginModule[],
98+
opts: {
99+
kind: PluginKind;
100+
factoryKey: "createToolPlugin" | "createWebProvider";
101+
},
102+
): CollectedPluginCandidate<TFactory>[] {
103+
const out: CollectedPluginCandidate<TFactory>[] = [];
104+
for (const mod of modules) {
105+
if (mod.manifest?.kind !== opts.kind) continue;
106+
const factory = mod[opts.factoryKey];
107+
if (typeof factory !== "function") continue;
108+
out.push({
109+
id: mod.manifest.id,
110+
name: mod.manifest.name,
111+
...(mod.manifest.description !== undefined
112+
? { description: mod.manifest.description }
113+
: {}),
114+
credentials: mod.manifest.credentials ?? [],
115+
factory: factory as CollectedPluginCandidate<TFactory>["factory"],
116+
});
117+
}
118+
return out;
119+
}
120+
92121
// Read and validate a manifest.json beside the module. Plugins may declare
93122
// their manifest as a JS export (mod.manifest) or a sibling manifest.json file;
94123
// this covers the JSON path so plugins that are pure data + commands work too.
@@ -427,23 +456,16 @@ export function expandSkipDiagnosticsHandler(
427456
return (skip) => diagnostics.warnings.push(formatExpandSkip(skip));
428457
}
429458

430-
/**
431-
* `onSkip` when no diagnostics collector is in play: one explicit stderr
432-
* line, module-private and only reached by an internal caller's own
433-
* deliberate choice (see `resolveExpandSkip` below) — never `expandPluginPath`
434-
* falling back to it on its own.
435-
*/
436-
function stderrExpandSkip(skip: ExpandPluginPathSkip): void {
437-
process.stderr.write(`plugins: ${formatExpandSkip(skip)}\n`);
438-
}
439-
440459
/** Diagnostics when given, else the explicit stderr line — no silent option. */
441460
function resolveExpandSkip(
442461
diagnostics?: PluginLoadDiagnostics,
443462
): (skip: ExpandPluginPathSkip) => void {
444-
return diagnostics !== undefined
445-
? expandSkipDiagnosticsHandler(diagnostics)
446-
: stderrExpandSkip;
463+
const onWarning = resolvePluginWarningHandler(
464+
diagnostics !== undefined
465+
? { diagnostics }
466+
: { onWarning: stderrPluginWarning },
467+
);
468+
return (skip) => onWarning(formatExpandSkip(skip));
447469
}
448470

449471
/**
@@ -928,11 +950,11 @@ export async function discoverClaudeInstalledPlugins(
928950
parsed = JSON.parse(raw);
929951
} catch {
930952
// Prefer collector when present; else stderrPluginWarning.
931-
resolvePluginWarningHandler(
932-
opts.diagnostics !== undefined
933-
? { diagnostics: opts.diagnostics }
934-
: { onWarning: stderrPluginWarning },
935-
)(`failed to parse ${registryPath}`);
953+
(opts.diagnostics !== undefined
954+
? resolvePluginWarningHandler({ diagnostics: opts.diagnostics })
955+
: resolvePluginWarningHandler({ onWarning: stderrPluginWarning }))(
956+
`failed to parse ${registryPath}`,
957+
);
936958
return [];
937959
}
938960
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {

‎src/plugins/register.ts‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,28 +89,40 @@ export function registerCommandPlugins(
8989
config: Record<string, PluginConfig> | (() => Record<string, PluginConfig>),
9090
): string[] {
9191
const getConfig = typeof config === "function" ? config : () => config;
92-
const registered: string[] = [];
93-
for (const mod of modules) {
92+
return collectEnabledModules(modules, (mod) => {
9493
const id = mod.manifest?.id;
9594
if (id === undefined || !registerCommandPluginModule(mod, getConfig))
96-
continue;
97-
if (isPluginModuleEnabled(mod, getConfig())) registered.push(id);
98-
}
99-
return registered;
95+
return undefined;
96+
return isPluginModuleEnabled(mod, getConfig()) ? id : undefined;
97+
});
10098
}
10199

102100
export function registerWorkflowPlugins(
103101
modules: PluginModule[],
104102
config: Record<string, PluginConfig>,
105103
): string[] {
106-
const registered: string[] = [];
107-
for (const mod of modules) {
108-
if (!isEnabledWorkflowPlugin(mod, config)) continue;
104+
return collectEnabledModules(modules, (mod) => {
105+
if (!isEnabledWorkflowPlugin(mod, config)) return undefined;
109106
const workflowPlugin = mod.workflowPlugin;
110107
const id = mod.manifest?.id;
111-
if (workflowPlugin === undefined || id === undefined) continue;
108+
if (workflowPlugin === undefined || id === undefined) return undefined;
112109
registerWorkflowPlugin(workflowPlugin);
113-
registered.push(id);
110+
return id;
111+
});
112+
}
113+
114+
// Shared register loop: each module is offered to `collect`, which performs
115+
// that kind's registration side effects and returns the manifest id exactly
116+
// when the module counts as registered, or undefined to skip it. One pass in
117+
// module order; per-kind predicates stay at the call sites above.
118+
function collectEnabledModules(
119+
modules: PluginModule[],
120+
collect: (mod: PluginModule) => string | undefined,
121+
): string[] {
122+
const registered: string[] = [];
123+
for (const mod of modules) {
124+
const id = collect(mod);
125+
if (id !== undefined) registered.push(id);
114126
}
115127
return registered;
116128
}

‎src/plugins/skill-commands.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type CommandDefinition,
66
type CommandResult,
77
} from "../tui/commands/registry.js";
8-
import { splitFrontmatter } from "./frontmatter.js";
8+
import { COMMAND_NAME_PATTERN, splitFrontmatter } from "./frontmatter.js";
99

1010
// Slash is the operator action surface: `/<skill-name> [args]` sends the skill
1111
// body (plus args) to the agent. Convention/internal skills opt out with
@@ -15,8 +15,6 @@ import { splitFrontmatter } from "./frontmatter.js";
1515
// skips the skill from `discoverSkills` lazy listing. Explicit `use_skill` /
1616
// `resolveSkillBody` still loads the body by name.
1717

18-
const COMMAND_NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
19-
2018
// `$ARGUMENTS` (Claude Code convention) interpolates inline when the author used
2119
// it; otherwise args append after the body so the skill instructions run against
2220
// the user's target (e.g. an issue id).

‎src/plugins/tool-plugins.ts‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ToolPlugin } from "@intx/tools-posix";
2-
import type { PluginModule } from "./loader.js";
2+
import { collectPluginCandidates, type PluginModule } from "./loader.js";
33
import type { PluginConfig } from "../config/settings.js";
44
import type { PluginCredentialField } from "./manifest.js";
55
import { scrubSecrets } from "../web/secret-scrub.js";
@@ -22,21 +22,10 @@ export interface ToolPluginCandidate {
2222
export function collectToolPlugins(
2323
modules: PluginModule[],
2424
): ToolPluginCandidate[] {
25-
const out: ToolPluginCandidate[] = [];
26-
for (const mod of modules) {
27-
if (mod.manifest?.kind !== "tool") continue;
28-
if (typeof mod.createToolPlugin !== "function") continue;
29-
out.push({
30-
id: mod.manifest.id,
31-
name: mod.manifest.name,
32-
...(mod.manifest.description !== undefined
33-
? { description: mod.manifest.description }
34-
: {}),
35-
credentials: mod.manifest.credentials ?? [],
36-
factory: mod.createToolPlugin as ToolPluginCandidate["factory"],
37-
});
38-
}
39-
return out;
25+
return collectPluginCandidates<ToolPlugin>(modules, {
26+
kind: "tool",
27+
factoryKey: "createToolPlugin",
28+
});
4029
}
4130

4231
// A tool plugin adds in-process agent capabilities, so it is wired in only when

‎src/web/plugin-provider.ts‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { WebProvider } from "./types.js";
22
import { scrubSecrets } from "./secret-scrub.js";
33
import type { PluginModule } from "../plugins/loader.js";
4+
import { collectPluginCandidates } from "../plugins/loader.js";
45
import type { PluginConfig } from "../config/settings.js";
56
import type { PluginCredentialField } from "../plugins/manifest.js";
67

@@ -18,21 +19,10 @@ export interface WebPluginCandidate {
1819
export function collectWebPlugins(
1920
modules: PluginModule[],
2021
): WebPluginCandidate[] {
21-
const out: WebPluginCandidate[] = [];
22-
for (const mod of modules) {
23-
if (mod.manifest?.kind !== "web") continue;
24-
if (typeof mod.createWebProvider !== "function") continue;
25-
out.push({
26-
id: mod.manifest.id,
27-
name: mod.manifest.name,
28-
...(mod.manifest.description !== undefined
29-
? { description: mod.manifest.description }
30-
: {}),
31-
credentials: mod.manifest.credentials ?? [],
32-
factory: mod.createWebProvider as WebPluginCandidate["factory"],
33-
});
34-
}
35-
return out;
22+
return collectPluginCandidates<WebProvider>(modules, {
23+
kind: "web",
24+
factoryKey: "createWebProvider",
25+
});
3626
}
3727

3828
// Pick the active web plugin: an explicit `web` override wins; otherwise the

0 commit comments

Comments
 (0)