fix(cli): clarify plugin tool command mistakes

Summary:
- clarify CLI diagnostics when an unknown subcommand is actually a plugin agent tool
- route early proxy-preflight misses through the same policy helper
- refresh bundled sidecar update fixtures for current package ownership

Verification:
- pnpm test src/cli/run-main.test.ts src/cli/run-main.exit.test.ts src/plugins/manifest-command-aliases.test.ts
- pnpm test src/infra/update-global.test.ts src/infra/update-runner.test.ts
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md src/cli/run-main-policy.ts src/cli/run-main.ts src/cli/run-main.test.ts src/cli/run-main.exit.test.ts src/plugins/manifest-command-aliases.ts src/plugins/manifest-command-aliases.runtime.ts src/plugins/manifest-command-aliases.test.ts
- pnpm build
- live temp lossless-claw manifest: pnpm openclaw lcm_recent emits the agent-tool diagnostic and no plugins.allow suggestion

Co-authored-by: 100yenadmin <100yenadmin+agent-77214@100yen.org>
This commit is contained in:
Peter Steinberger
2026-05-09 03:11:44 -04:00
committed by GitHub
parent 678c41a222
commit 0c50957dbb
10 changed files with 369 additions and 18 deletions

View File

@@ -146,6 +146,7 @@ Docs: https://docs.openclaw.ai
- Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd.
- Cron/agents: recognize same-target `edit``write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD.
- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD.
- CLI/router: when `openclaw <name>` does not match a CLI subcommand, check plugin tool manifests first so names like `lcm_recent` get an agent-tool diagnostic instead of the misleading suggestion to add the tool name to `plugins.allow`. Fixes #77214. Thanks @100yenadmin.
- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context.
- Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup.
- Plugins/Nix: allow externally configured plugin roots under `/nix/store` to load in `OPENCLAW_NIX_MODE=1` while keeping normal external plugin hardlink rejection unchanged. Thanks @joshp123.

View File

@@ -1,8 +1,10 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveManifestCommandAliasOwnerInRegistry,
resolveManifestToolOwnerInRegistry,
type PluginManifestCommandAliasRecord,
type PluginManifestCommandAliasRegistry,
type PluginManifestToolOwnerRecord,
} from "../plugins/manifest-command-aliases.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -105,6 +107,11 @@ export function resolveMissingPluginCommandMessage(
config?: OpenClawConfig;
registry?: PluginManifestCommandAliasRegistry;
}) => PluginManifestCommandAliasRecord | undefined;
resolveToolOwner?: (params: {
toolName: string | undefined;
config?: OpenClawConfig;
registry?: PluginManifestCommandAliasRegistry;
}) => PluginManifestToolOwnerRecord | undefined;
},
): string | null {
const normalizedPluginId = normalizeLowercaseStringOrEmpty(pluginId);
@@ -171,6 +178,47 @@ export function resolveMissingPluginCommandMessage(
return null;
}
const toolOwner = options?.registry
? resolveManifestToolOwnerInRegistry({
toolName: normalizedPluginId,
registry: options.registry,
})
: options?.resolveToolOwner?.({
toolName: normalizedPluginId,
config,
...(options?.registry ? { registry: options.registry } : {}),
});
if (toolOwner) {
// Apply plugins.allow / plugins.entries[X].enabled to the owning plugin so
// a disabled/denied plugin's manifest-declared tool name does not get a
// false attribution. The runtime resolver
// (resolveManifestToolOwner) already filters by control-plane availability,
// but pure-registry callers and any future ones still need this guard.
const ownerEnabled =
config?.plugins?.entries?.[toolOwner.pluginId]?.enabled !== false &&
(allow.length === 0 || allow.includes(toolOwner.pluginId));
if (ownerEnabled) {
// Per-account / per-tool runtime gates (e.g. Feishu's
// channels.feishu.enabled / tools.<x> toggles) are not declarable as
// manifest configSignals, so a positive manifest-availability signal
// proves "could be loaded if config permits", not "currently registered".
// Soften the wording when the runtime resolver could only prove
// manifest-level ownership.
if (toolOwner.availability === "manifest-only") {
return (
`"${normalizedPluginId}" may be provided by the "${toolOwner.pluginId}" plugin ` +
`as an agent tool, not a CLI subcommand. ` +
"Run `openclaw --help` to see available CLI subcommands."
);
}
return (
`"${normalizedPluginId}" is an agent tool available from the "${toolOwner.pluginId}" plugin, ` +
`not a CLI subcommand. Use it from an agent turn (model tool-use), not the CLI. ` +
"Run `openclaw --help` to see available CLI subcommands."
);
}
}
if (allow.length > 0 && !allow.includes(normalizedPluginId)) {
if (parentPluginId && allow.includes(parentPluginId)) {
return null;

View File

@@ -23,6 +23,8 @@ const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => ({})));
const resolvePluginCliRootOwnerIdsMock = vi.hoisted(() => vi.fn());
const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
@@ -170,6 +172,11 @@ vi.mock("../plugins/cli-registry-loader.js", () => ({
resolvePluginCliRootOwnerIds: resolvePluginCliRootOwnerIdsMock,
}));
vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({
resolveManifestCommandAliasOwner: resolveManifestCommandAliasOwnerMock,
resolveManifestToolOwner: resolveManifestToolOwnerMock,
}));
vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: restoreTerminalStateMock,
}));
@@ -237,6 +244,8 @@ describe("runCli exit behavior", () => {
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "googlemeet" ? ["google-meet"] : [],
);
resolveManifestCommandAliasOwnerMock.mockReturnValue(undefined);
resolveManifestToolOwnerMock.mockReturnValue(undefined);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
delete process.env.OPENCLAW_HIDE_BANNER;
});
@@ -462,6 +471,22 @@ describe("runCli exit behavior", () => {
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("reports plugin tool command mistakes before proxy startup", async () => {
resolveManifestToolOwnerMock.mockReturnValueOnce({
toolName: "lcm_recent",
pluginId: "lossless-claw",
availability: "loaded",
});
await expect(runCli(["node", "openclaw", "lcm_recent"])).rejects.toThrow(
'"lcm_recent" is an agent tool available from the "lossless-claw" plugin',
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled();
});
it("does not install the env proxy dispatcher for bypassed skills inspection commands", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
tryRouteCliMock.mockResolvedValueOnce(true);

View File

@@ -31,6 +31,15 @@ const memoryCoreCommandAliasRegistry: PluginManifestCommandAliasRegistry = {
],
};
const losslessClawToolRegistry: PluginManifestCommandAliasRegistry = {
plugins: [
{
id: "lossless-claw",
contracts: { tools: ["lcm_recent", "lcm_search"] },
},
],
};
describe("isGatewayRunFastPathArgv", () => {
it("matches only plain gateway foreground starts without root options or help", () => {
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway"])).toBe(true);
@@ -366,4 +375,101 @@ describe("resolveMissingPluginCommandMessage", () => {
expect(message).toContain('"memory-wiki"');
expect(message).toContain("plugins.allow");
});
it("identifies an agent tool name and points the user at model tool-use", () => {
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
plugins: {
allow: ["lossless-claw"],
},
},
{ registry: losslessClawToolRegistry },
);
expect(message).not.toBeNull();
expect(message).toContain('"lcm_recent"');
expect(message).toContain('"lossless-claw"');
expect(message).toContain("agent tool");
expect(message).not.toContain("plugins.allow");
});
it("matches agent tool names case-insensitively", () => {
const message = resolveMissingPluginCommandMessage("LCM_Recent", undefined, {
registry: losslessClawToolRegistry,
});
expect(message).not.toBeNull();
expect(message).toContain("agent tool");
expect(message).toContain('"lossless-claw"');
});
it("preserves the plugins.allow suggestion when the unknown name is not a plugin tool", () => {
const message = resolveMissingPluginCommandMessage(
"totally-unknown",
{
plugins: {
allow: ["quietchat"],
},
},
{ registry: losslessClawToolRegistry },
);
expect(message).not.toBeNull();
expect(message).toContain('`plugins.allow` excludes "totally-unknown"');
});
it("does not attribute a tool to an owning plugin excluded by plugins.allow", () => {
// The owning plugin is denied via plugins.allow, so the manifest-declared
// tool is not available through the owning plugin. Fall through to the
// standard plugins.allow message instead of falsely attributing it.
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
plugins: {
allow: ["quietchat"],
},
},
{ registry: losslessClawToolRegistry },
);
expect(message).not.toBeNull();
expect(message).not.toContain("agent tool available");
expect(message).toContain('`plugins.allow` excludes "lcm_recent"');
});
it("does not attribute a tool to an owning plugin disabled via plugins.entries", () => {
const message = resolveMissingPluginCommandMessage(
"lcm_recent",
{
plugins: {
entries: {
"lossless-claw": { enabled: false },
},
},
},
{ registry: losslessClawToolRegistry },
);
// entries.<id>.enabled = false on the OWNING plugin invalidates the
// plugin-tool attribution. With no allow filter on the bare name the
// diagnostic returns null (no actionable message); callers handle that
// as "not a recognised plugin command".
expect(message).toBeNull();
});
it("uses softer 'may be provided by' wording for manifest-only availability", () => {
// Some runtime gates (per-account enabled, per-tool toggles in the Feishu
// family etc.) cannot be expressed as manifest configSignals, so the
// runtime resolver reports availability: "manifest-only" when ownership is
// only manifest-provable. The diagnostic must avoid asserting "registered
// by" in that case.
const manifestOnlyOwner = {
toolName: "feishu_chat",
pluginId: "feishu",
availability: "manifest-only" as const,
};
const message = resolveMissingPluginCommandMessage("feishu_chat", undefined, {
resolveToolOwner: () => manifestOnlyOwner,
});
expect(message).not.toBeNull();
expect(message).toContain("may be provided by");
expect(message).toContain('"feishu"');
expect(message).not.toContain("registered by");
});
});

View File

@@ -338,6 +338,21 @@ async function resolveUnownedCliPrimary(params: {
return primary;
}
async function resolveUnownedCliPrimaryMessage(params: {
primary: string;
config: OpenClawConfig;
}): Promise<string> {
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
return (
resolveMissingPluginCommandMessageFromPolicy(params.primary, params.config, {
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
}) ??
`Unknown command: openclaw ${params.primary}. No built-in command or plugin CLI metadata owns "${params.primary}".`
);
}
async function bootstrapCliProxyCaptureAndDispatcher(
startupTrace: ReturnType<typeof createGatewayCliMainStartupTrace>,
options: { ensureDispatcher?: boolean } = {},
@@ -432,9 +447,7 @@ export async function runCli(argv: string[] = process.argv) {
const config = await readBestEffortCliConfig();
const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config });
if (unownedPrimary) {
throw new Error(
`Unknown command: openclaw ${unownedPrimary}. No built-in command or plugin CLI metadata owns "${unownedPrimary}".`,
);
throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config }));
}
const { startProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
proxyHandle = await startProxy(config?.proxy ?? undefined);
@@ -673,13 +686,14 @@ export async function runCli(argv: string[] = process.argv) {
(command) => command.name() === primary || command.aliases().includes(primary),
)
) {
const { resolveManifestCommandAliasOwner } =
const { resolveManifestCommandAliasOwner, resolveManifestToolOwner } =
await import("../plugins/manifest-command-aliases.runtime.js");
const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy(
primary,
config,
{
resolveCommandAliasOwner: resolveManifestCommandAliasOwner,
resolveToolOwner: resolveManifestToolOwner,
},
);
if (missingPluginCommandMessage) {

View File

@@ -33,7 +33,7 @@ import {
type CommandRunner,
} from "./update-global.js";
const MATRIX_HELPER_API = bundledDistPluginFile("matrix", "helper-api.js");
const TELEGRAM_RUNTIME_API = bundledDistPluginFile("telegram", "runtime-api.js");
async function writeGlobalPackageJson(packageRoot: string, version = "1.0.0") {
await fs.writeFile(
path.join(packageRoot, "package.json"),
@@ -687,9 +687,9 @@ describe("update global helpers", () => {
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toStrictEqual([]);
await fs.rm(path.join(packageRoot, MATRIX_HELPER_API));
await fs.rm(path.join(packageRoot, TELEGRAM_RUNTIME_API));
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`missing packaged dist file ${MATRIX_HELPER_API}`,
`missing packaged dist file ${TELEGRAM_RUNTIME_API}`,
);
await fs.writeFile(
@@ -796,10 +796,10 @@ describe("update global helpers", () => {
it("verifies legacy sidecars for installed bundled plugins without inventory", async () => {
await withTempDir({ prefix: "openclaw-update-global-legacy-plugin-" }, async (packageRoot) => {
await writeGlobalPackageJson(packageRoot);
await writeBundledPluginPackageJson(packageRoot, "matrix", "@openclaw/matrix");
await writeBundledPluginPackageJson(packageRoot, "telegram", "@openclaw/telegram");
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`missing bundled runtime sidecar ${MATRIX_HELPER_API}`,
`missing bundled runtime sidecar ${TELEGRAM_RUNTIME_API}`,
);
});
});
@@ -809,11 +809,11 @@ describe("update global helpers", () => {
{ prefix: "openclaw-update-global-critical-sidecars-" },
async (packageRoot) => {
await writeGlobalPackageJson(packageRoot, "2026.4.15");
await writeBundledPluginPackageJson(packageRoot, "matrix", "@openclaw/matrix");
await writeBundledPluginPackageJson(packageRoot, "telegram", "@openclaw/telegram");
await writePackageDistInventory(packageRoot);
await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain(
`missing bundled runtime sidecar ${MATRIX_HELPER_API}`,
`missing bundled runtime sidecar ${TELEGRAM_RUNTIME_API}`,
);
},
);

View File

@@ -12,7 +12,7 @@ import { runGatewayUpdate } from "./update-runner.js";
type CommandResponse = { stdout?: string; stderr?: string; code?: number | null };
type CommandResult = { stdout: string; stderr: string; code: number | null };
const MATRIX_HELPER_API = bundledDistPluginFile("matrix", "helper-api.js");
const TELEGRAM_RUNTIME_API = bundledDistPluginFile("telegram", "runtime-api.js");
const fixtureRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-update-" });
function toCommandResult(response?: CommandResponse): CommandResult {
@@ -1776,10 +1776,10 @@ describe("runGatewayUpdate", () => {
);
await writeBundledRuntimeSidecars(pkgRoot);
const inventory = await writePackageDistInventory(pkgRoot);
expect(inventory).toContain(MATRIX_HELPER_API);
const matrixHelperApiPath = path.join(pkgRoot, MATRIX_HELPER_API);
await expect(pathExists(matrixHelperApiPath)).resolves.toBe(true);
await fs.rm(matrixHelperApiPath);
expect(inventory).toContain(TELEGRAM_RUNTIME_API);
const telegramRuntimeApiPath = path.join(pkgRoot, TELEGRAM_RUNTIME_API);
await expect(pathExists(telegramRuntimeApiPath)).resolves.toBe(true);
await fs.rm(telegramRuntimeApiPath);
},
});
@@ -1788,7 +1788,7 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("error");
expect(result.reason).toBe("global-install-failed");
expect(result.steps.at(-1)?.stderrTail).toContain(
`missing packaged dist file ${MATRIX_HELPER_API}`,
`missing packaged dist file ${TELEGRAM_RUNTIME_API}`,
);
});

View File

@@ -1,10 +1,18 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import {
resolveManifestCommandAliasOwnerInRegistry,
resolveManifestToolOwnerInRegistry,
type PluginManifestCommandAliasRegistry,
type PluginManifestCommandAliasRecord,
type PluginManifestToolOwnerRecord,
} from "./manifest-command-aliases.js";
import { loadManifestMetadataRegistry } from "./manifest-contract-eligibility.js";
import {
isManifestPluginAvailableForControlPlane,
loadManifestMetadataRegistry,
loadManifestMetadataSnapshot,
} from "./manifest-contract-eligibility.js";
import { hasManifestToolAvailability } from "./manifest-tool-availability.js";
export function resolveManifestCommandAliasOwner(params: {
command: string | undefined;
@@ -25,3 +33,83 @@ export function resolveManifestCommandAliasOwner(params: {
registry,
});
}
/**
* Resolve which plugin owns an agent-tool name, applying control-plane
* availability filters so disabled/denied plugins are not falsely attributed.
*
* Behavior:
* - Walks the full manifest snapshot (not the lighter-weight registry view) so
* per-tool `configSignals`/`authSignals` are visible.
* - Skips plugins that fail `isManifestPluginAvailableForControlPlane`
* (`plugins.allow` / `plugins.deny` / `plugins.entries[id].enabled` /
* installed-index).
* - For matched tools, runs `hasManifestToolAvailability` to check the
* tool's own configSignals (e.g. Feishu's `appId`/`appSecret` gate).
* - Reports `availability: "loaded"` when both filters pass, enough for a
* direct "available from this plugin" diagnostic.
* - Reports `availability: "manifest-only"` when the manifest declares
* ownership but availability is not provable from manifest alone (e.g.
* per-account `enabled` flags or per-tool toggles that are runtime-only).
* Caller should soften the wording to "may be provided by".
*
* Falls back to the pure registry walk only when an explicit registry is
* supplied (no snapshot to filter against).
*/
export function resolveManifestToolOwner(params: {
toolName: string | undefined;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
registry?: PluginManifestCommandAliasRegistry;
}): PluginManifestToolOwnerRecord | undefined {
if (params.registry) {
return resolveManifestToolOwnerInRegistry({
toolName: params.toolName,
registry: params.registry,
});
}
const normalizedToolName = normalizeOptionalLowercaseString(params.toolName);
if (!normalizedToolName) {
return undefined;
}
const snapshot = loadManifestMetadataSnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const env = params.env ?? process.env;
for (const plugin of snapshot.plugins) {
const tools = plugin.contracts?.tools;
if (!tools || tools.length === 0) {
continue;
}
const match = tools.find(
(entry) => normalizeOptionalLowercaseString(entry) === normalizedToolName,
);
if (!match) {
continue;
}
const pluginAvailable = isManifestPluginAvailableForControlPlane({
snapshot,
plugin,
config: params.config,
});
if (!pluginAvailable) {
// Plugin is denied/disabled/uninstalled; do not attribute this tool to it.
continue;
}
const toolAvailable = hasManifestToolAvailability({
plugin,
toolNames: [match],
config: params.config,
env,
});
return {
toolName: match,
pluginId: plugin.id,
availability: toolAvailable ? "loaded" : "manifest-only",
};
}
return undefined;
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
normalizeManifestCommandAliases,
resolveManifestCommandAliasOwnerInRegistry,
resolveManifestToolOwnerInRegistry,
} from "./manifest-command-aliases.js";
describe("manifest command aliases", () => {
@@ -46,4 +47,31 @@ describe("manifest command aliases", () => {
name: "legacy-memory",
});
});
it("resolves agent tool owners from contracts.tools", () => {
const registry = {
plugins: [
{
id: "lossless-claw",
contracts: { tools: ["lcm_recent", "lcm_search"] },
},
{
id: "other-plugin",
contracts: { tools: ["unrelated_tool"] },
},
],
};
expect(resolveManifestToolOwnerInRegistry({ toolName: "lcm_recent", registry })).toMatchObject({
pluginId: "lossless-claw",
toolName: "lcm_recent",
});
expect(resolveManifestToolOwnerInRegistry({ toolName: "LCM_Recent", registry })).toMatchObject({
pluginId: "lossless-claw",
});
expect(
resolveManifestToolOwnerInRegistry({ toolName: "missing_tool", registry }),
).toBeUndefined();
expect(resolveManifestToolOwnerInRegistry({ toolName: "", registry })).toBeUndefined();
});
});

View File

@@ -20,11 +20,29 @@ export type PluginManifestCommandAliasRecord = PluginManifestCommandAlias & {
enabledByDefault?: boolean;
};
export type PluginManifestToolOwnerRecord = {
toolName: string;
pluginId: string;
/**
* "loaded" — the owning plugin passes control-plane availability filters and
* the tool itself passes manifest-tool-availability checks (configSignals/
* authSignals). The diagnostic can say the tool is available from this plugin.
*
* "manifest-only" — the manifest claims ownership but availability checks
* either failed (plugin denied/disabled, missing required config) or were
* not performed (pure registry lookup with no plugin metadata snapshot).
* Emit a softer "may be provided by" message in that case so the diagnostic
* does not over-assert about plugins that the runtime never registered.
*/
availability?: "loaded" | "manifest-only";
};
export type PluginManifestCommandAliasRegistry = {
plugins: readonly {
id: string;
enabledByDefault?: boolean;
commandAliases?: readonly PluginManifestCommandAlias[];
contracts?: { tools?: readonly string[] };
}[];
};
@@ -62,6 +80,29 @@ export function normalizeManifestCommandAliases(
return normalized.length > 0 ? normalized : undefined;
}
export function resolveManifestToolOwnerInRegistry(params: {
toolName: string | undefined;
registry: PluginManifestCommandAliasRegistry;
}): PluginManifestToolOwnerRecord | undefined {
const normalizedToolName = normalizeOptionalLowercaseString(params.toolName);
if (!normalizedToolName) {
return undefined;
}
for (const plugin of params.registry.plugins) {
const tools = plugin.contracts?.tools;
if (!tools || tools.length === 0) {
continue;
}
const match = tools.find(
(entry) => normalizeOptionalLowercaseString(entry) === normalizedToolName,
);
if (match) {
return { toolName: match, pluginId: plugin.id };
}
}
return undefined;
}
export function resolveManifestCommandAliasOwnerInRegistry(params: {
command: string | undefined;
registry: PluginManifestCommandAliasRegistry;