fix: regenerate pi-claude-cli MCP config per session and auto-sync skill tools
The MCP config was generated lazily once and locked, so engine session-scoped tools (fn_review_spec, fn_review_step) never reached the Claude CLI subprocess and triage/executor sessions failed with "unknown tool" errors. Now the config is hashed per call and rewritten when the tool set changes. Also adds scripts/sync-fusion-skill-tools.mjs to regenerate the SKILL.md tool-categories block from extension.ts at build time, with a --check mode wired into skill-sync tests so drift fails CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@
|
|||||||
"dev:ui": "pnpm --filter @fusion/dashboard dev",
|
"dev:ui": "pnpm --filter @fusion/dashboard dev",
|
||||||
"dev:hmr": "node scripts/dev-hmr.mjs",
|
"dev:hmr": "node scripts/dev-hmr.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"sync:fusion-skill": "node scripts/sync-fusion-skill-tools.mjs",
|
||||||
|
"sync:fusion-skill:check": "node scripts/sync-fusion-skill-tools.mjs --check",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"verify:workspace": "pnpm lint && pnpm test && pnpm build",
|
"verify:workspace": "pnpm lint && pnpm test && pnpm build",
|
||||||
"build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe",
|
"build:exe": "pnpm build && pnpm --filter @runfusion/fusion build:exe",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/bin.ts",
|
"dev": "tsx src/bin.ts",
|
||||||
|
"prebuild": "node ../../scripts/sync-fusion-skill-tools.mjs",
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"build:exe": "bun run build.ts",
|
"build:exe": "bun run build.ts",
|
||||||
"build:exe:all": "bun run build.ts --all",
|
"build:exe:all": "bun run build.ts --all",
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ Mission → Milestone → Slice → Feature → Task
|
|||||||
**Naming boundary:** The published skill surface always uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Internal engine runtime tools like `task_create`, `task_update`, `task_log`, and `task_done` are intentionally unprefixed and not part of this skill.
|
**Naming boundary:** The published skill surface always uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Internal engine runtime tools like `task_create`, `task_update`, `task_log`, and `task_done` are intentionally unprefixed and not part of this skill.
|
||||||
|
|
||||||
**Tool categories:**
|
**Tool categories:**
|
||||||
|
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
|
||||||
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
|
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
|
||||||
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
|
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
|
||||||
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`
|
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`
|
||||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
|
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
|
||||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||||
|
<!-- END: tool-categories -->
|
||||||
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
||||||
|
|
||||||
</essential_principles>
|
</essential_principles>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
||||||
import { resolve, dirname, relative, join } from "node:path";
|
import { resolve, dirname, relative, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const cliRoot = resolve(__dirname, "../..");
|
const cliRoot = resolve(__dirname, "../..");
|
||||||
@@ -194,6 +195,19 @@ describe("Skill-Extension Sync", () => {
|
|||||||
expect(dashboardCli).toContain("/fn");
|
expect(dashboardCli).toContain("/fn");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("SKILL.md tool-categories block matches the sync script output (no drift)", () => {
|
||||||
|
const repoRoot = resolve(cliRoot, "../..");
|
||||||
|
const script = resolve(repoRoot, "scripts/sync-fusion-skill-tools.mjs");
|
||||||
|
const result = spawnSync("node", [script, "--check"], {
|
||||||
|
encoding: "utf-8",
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`sync-fusion-skill-tools --check failed:\n${result.stderr || result.stdout}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("package.json includes skills in pi config and files array", () => {
|
it("package.json includes skills in pi config and files array", () => {
|
||||||
const pkg = JSON.parse(
|
const pkg = JSON.parse(
|
||||||
readFileSync(resolve(cliRoot, "package.json"), "utf-8"),
|
readFileSync(resolve(cliRoot, "package.json"), "utf-8"),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
validateCliAuth,
|
validateCliAuth,
|
||||||
killAllProcesses,
|
killAllProcesses,
|
||||||
} from "./src/process-manager.js";
|
} from "./src/process-manager.js";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
|
import { getCustomToolDefs, writeMcpConfig } from "./src/mcp-config.js";
|
||||||
|
|
||||||
// Kill all active Claude subprocesses on process exit to prevent orphans
|
// Kill all active Claude subprocesses on process exit to prevent orphans
|
||||||
@@ -20,49 +21,63 @@ process.on("exit", killAllProcesses);
|
|||||||
|
|
||||||
const PROVIDER_ID = "pi-claude-cli";
|
const PROVIDER_ID = "pi-claude-cli";
|
||||||
|
|
||||||
let mcpConfigPath: string | undefined;
|
let cachedMcpConfig: { hash: string; configPath: string } | undefined;
|
||||||
let mcpConfigResolved = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazily generate MCP config on first request (not at load time).
|
* Resolve the MCP config path for the current request, regenerating it when
|
||||||
* pi.getAllTools() fails during extension loading; this defers it
|
* the set of custom tools changes.
|
||||||
* until the pi runtime is fully initialized.
|
|
||||||
*
|
*
|
||||||
* Only locks (sets mcpConfigResolved) when getAllTools() returns a
|
* Why per-call instead of once-and-lock:
|
||||||
* real array — if it returns undefined/null (registry not ready),
|
* - The engine registers session-scoped custom tools (e.g. `fn_review_spec`,
|
||||||
* we retry on the next request. Once the registry is ready we
|
* `fn_review_step`) when it spawns triage/executor sessions. These appear
|
||||||
* commit to the result even if there are zero custom tools.
|
* in `pi.getAllTools()` only while that session is active.
|
||||||
|
* - A locked-on-first-call cache would freeze in the global tool set and
|
||||||
|
* silently drop session tools, so the Claude CLI subprocess would refuse
|
||||||
|
* to call them ("unknown tool fn_review_spec").
|
||||||
|
* - Hashing the tool defs lets us reuse the same temp files when the tool
|
||||||
|
* set is unchanged across calls, and produce fresh files (with the hash
|
||||||
|
* in the filename to avoid races) when it changes.
|
||||||
*
|
*
|
||||||
* Uses warn-don't-block: failure logs a warning but does not
|
* Uses warn-don't-block: failure logs a warning but does not prevent the
|
||||||
* prevent the provider from functioning (built-ins still work).
|
* provider from functioning (built-ins still work).
|
||||||
*/
|
*/
|
||||||
function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
|
function ensureMcpConfig(pi: ExtensionAPI): string | undefined {
|
||||||
if (mcpConfigResolved) return mcpConfigPath;
|
|
||||||
try {
|
try {
|
||||||
const allTools = pi.getAllTools();
|
const allTools = pi.getAllTools();
|
||||||
|
|
||||||
// Registry not ready yet — don't lock, retry on next call
|
// Registry not ready yet — fall back to whatever we last computed (if any)
|
||||||
if (!Array.isArray(allTools)) {
|
if (!Array.isArray(allTools)) {
|
||||||
return mcpConfigPath;
|
return cachedMcpConfig?.configPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry is ready — lock regardless of whether custom tools exist
|
|
||||||
mcpConfigResolved = true;
|
|
||||||
|
|
||||||
const toolDefs = getCustomToolDefs(pi);
|
const toolDefs = getCustomToolDefs(pi);
|
||||||
if (toolDefs.length > 0) {
|
if (toolDefs.length === 0) {
|
||||||
mcpConfigPath = writeMcpConfig(toolDefs);
|
cachedMcpConfig = undefined;
|
||||||
console.error(
|
return undefined;
|
||||||
`[pi-claude-cli] MCP config generated with ${toolDefs.length} custom tool(s)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hash = createHash("sha1")
|
||||||
|
.update(JSON.stringify(toolDefs))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 12);
|
||||||
|
|
||||||
|
if (cachedMcpConfig?.hash === hash) {
|
||||||
|
return cachedMcpConfig.configPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = writeMcpConfig(toolDefs, hash);
|
||||||
|
cachedMcpConfig = { hash, configPath };
|
||||||
|
console.error(
|
||||||
|
`[pi-claude-cli] MCP config refreshed with ${toolDefs.length} custom tool(s) (hash=${hash})`,
|
||||||
|
);
|
||||||
|
return configPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
|
"[pi-claude-cli] MCP config generation failed, custom tools unavailable:",
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
|
return cachedMcpConfig?.configPath;
|
||||||
}
|
}
|
||||||
return mcpConfigPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
|
|||||||
@@ -269,4 +269,26 @@ describe("writeMcpConfig", () => {
|
|||||||
expect(result).toMatch(/pi-claude-mcp-config/);
|
expect(result).toMatch(/pi-claude-mcp-config/);
|
||||||
expect(result).toMatch(/\.json$/);
|
expect(result).toMatch(/\.json$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes cacheKey in filenames when provided so distinct tool sets do not collide", () => {
|
||||||
|
const toolDefs: McpToolDef[] = [
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
description: "Search",
|
||||||
|
inputSchema: { type: "object" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pathA = writeMcpConfig(toolDefs, "aaaaaaaaaaaa");
|
||||||
|
const pathB = writeMcpConfig(toolDefs, "bbbbbbbbbbbb");
|
||||||
|
|
||||||
|
expect(pathA).toContain("aaaaaaaaaaaa");
|
||||||
|
expect(pathB).toContain("bbbbbbbbbbbb");
|
||||||
|
expect(pathA).not.toBe(pathB);
|
||||||
|
|
||||||
|
const schemaPathA = mocks.writeFileSync.mock.calls[0][0];
|
||||||
|
const schemaPathB = mocks.writeFileSync.mock.calls[2][0];
|
||||||
|
expect(schemaPathA).toContain("aaaaaaaaaaaa");
|
||||||
|
expect(schemaPathB).toContain("bbbbbbbbbbbb");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,13 +75,21 @@ export function getCustomToolDefs(pi: PiInstance): McpToolDef[] {
|
|||||||
* 2. Config file: MCP config pointing to the schema-only server
|
* 2. Config file: MCP config pointing to the schema-only server
|
||||||
*
|
*
|
||||||
* @param toolDefs - Array of custom tool definitions
|
* @param toolDefs - Array of custom tool definitions
|
||||||
|
* @param cacheKey - Optional suffix appended to filenames so that distinct
|
||||||
|
* tool sets (e.g. session-scoped tool registrations) get distinct files
|
||||||
|
* and don't race on a single shared path.
|
||||||
* @returns Path to the MCP config file
|
* @returns Path to the MCP config file
|
||||||
*/
|
*/
|
||||||
export function writeMcpConfig(toolDefs: McpToolDef[]): string {
|
export function writeMcpConfig(
|
||||||
|
toolDefs: McpToolDef[],
|
||||||
|
cacheKey?: string,
|
||||||
|
): string {
|
||||||
|
const suffix = cacheKey ? `${process.pid}-${cacheKey}` : `${process.pid}`;
|
||||||
|
|
||||||
// Write tool schemas to temp file
|
// Write tool schemas to temp file
|
||||||
const schemaFilePath = join(
|
const schemaFilePath = join(
|
||||||
tmpdir(),
|
tmpdir(),
|
||||||
`pi-claude-mcp-schemas-${process.pid}.json`,
|
`pi-claude-mcp-schemas-${suffix}.json`,
|
||||||
);
|
);
|
||||||
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
|
writeFileSync(schemaFilePath, JSON.stringify(toolDefs));
|
||||||
|
|
||||||
@@ -103,7 +111,7 @@ export function writeMcpConfig(toolDefs: McpToolDef[]): string {
|
|||||||
// Write config to temp file
|
// Write config to temp file
|
||||||
const configFilePath = join(
|
const configFilePath = join(
|
||||||
tmpdir(),
|
tmpdir(),
|
||||||
`pi-claude-mcp-config-${process.pid}.json`,
|
`pi-claude-mcp-config-${suffix}.json`,
|
||||||
);
|
);
|
||||||
writeFileSync(configFilePath, JSON.stringify(config));
|
writeFileSync(configFilePath, JSON.stringify(config));
|
||||||
|
|
||||||
|
|||||||
141
scripts/sync-fusion-skill-tools.mjs
Normal file
141
scripts/sync-fusion-skill-tools.mjs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Regenerates the `tool-categories` block in the Fusion skill SKILL.md from
|
||||||
|
* the canonical list of `fn_*` tools registered in `packages/cli/src/extension.ts`.
|
||||||
|
*
|
||||||
|
* Source of truth: extension.ts (each tool is registered with `name: "fn_..."`).
|
||||||
|
* Output: the `<!-- BEGIN: tool-categories ... -->` / `<!-- END: tool-categories -->`
|
||||||
|
* block inside `packages/cli/skill/fusion/SKILL.md`.
|
||||||
|
*
|
||||||
|
* The `.claude/skills/fusion` path is a symlink to `packages/cli/skill/fusion`,
|
||||||
|
* so it picks up the change automatically.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/sync-fusion-skill-tools.mjs # rewrite SKILL.md in place
|
||||||
|
* node scripts/sync-fusion-skill-tools.mjs --check # exit 1 if drift detected
|
||||||
|
*
|
||||||
|
* Wired into `packages/cli` build via the `prebuild` script so a fresh build
|
||||||
|
* always emits an up-to-date skill, and CI catches drift via `--check`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = resolve(__dirname, "..");
|
||||||
|
const extensionPath = resolve(repoRoot, "packages/cli/src/extension.ts");
|
||||||
|
const skillPath = resolve(repoRoot, "packages/cli/skill/fusion/SKILL.md");
|
||||||
|
|
||||||
|
const BEGIN_MARKER =
|
||||||
|
"<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
|
||||||
|
const END_MARKER = "<!-- END: tool-categories -->";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize a tool name into one of the published skill categories.
|
||||||
|
* The order of categories here defines the order of bullets in SKILL.md.
|
||||||
|
*/
|
||||||
|
function categorize(name) {
|
||||||
|
if (name.includes("github")) return "GitHub";
|
||||||
|
if (name.startsWith("fn_task_")) return "Task";
|
||||||
|
if (
|
||||||
|
name.startsWith("fn_mission_") ||
|
||||||
|
name.startsWith("fn_milestone_") ||
|
||||||
|
name.startsWith("fn_slice_") ||
|
||||||
|
name.startsWith("fn_feature_")
|
||||||
|
) {
|
||||||
|
return "Mission";
|
||||||
|
}
|
||||||
|
if (name.startsWith("fn_agent_")) return "Agent";
|
||||||
|
if (name.startsWith("fn_skills_")) return "Skills";
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Agent", "Skills", "Other"];
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
Task: "Task tools",
|
||||||
|
GitHub: "GitHub tools",
|
||||||
|
Mission: "Mission tools",
|
||||||
|
Agent: "Agent tools",
|
||||||
|
Skills: "Skills tools",
|
||||||
|
Other: "Other tools",
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractToolNames(source) {
|
||||||
|
const names = [];
|
||||||
|
for (const match of source.matchAll(/name:\s*"(fn_[a-z_]+)"/g)) {
|
||||||
|
names.push(match[1]);
|
||||||
|
}
|
||||||
|
if (names.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No fn_* tool registrations found in ${extensionPath} — refusing to wipe the skill.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlock(toolNames) {
|
||||||
|
const grouped = new Map(CATEGORY_ORDER.map((c) => [c, []]));
|
||||||
|
for (const name of toolNames) {
|
||||||
|
grouped.get(categorize(name)).push(name);
|
||||||
|
}
|
||||||
|
const lines = [BEGIN_MARKER];
|
||||||
|
for (const category of CATEGORY_ORDER) {
|
||||||
|
const tools = grouped.get(category);
|
||||||
|
if (tools.length === 0) continue;
|
||||||
|
const formatted = tools.map((t) => `\`${t}\``).join(", ");
|
||||||
|
lines.push(`- **${CATEGORY_LABELS[category]}** — ${formatted}`);
|
||||||
|
}
|
||||||
|
lines.push(END_MARKER);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceBlock(skillContent, newBlock) {
|
||||||
|
const beginIdx = skillContent.indexOf(BEGIN_MARKER);
|
||||||
|
const endIdx = skillContent.indexOf(END_MARKER);
|
||||||
|
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find BEGIN/END tool-categories markers in ${skillPath}. ` +
|
||||||
|
`Add them around the tool-categories bullet list.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const endLineEnd = endIdx + END_MARKER.length;
|
||||||
|
return (
|
||||||
|
skillContent.slice(0, beginIdx) + newBlock + skillContent.slice(endLineEnd)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const checkOnly = process.argv.includes("--check");
|
||||||
|
|
||||||
|
const source = readFileSync(extensionPath, "utf-8");
|
||||||
|
const skillContent = readFileSync(skillPath, "utf-8");
|
||||||
|
|
||||||
|
const toolNames = extractToolNames(source);
|
||||||
|
const newBlock = buildBlock(toolNames);
|
||||||
|
const updated = replaceBlock(skillContent, newBlock);
|
||||||
|
|
||||||
|
if (updated === skillContent) {
|
||||||
|
if (!checkOnly) {
|
||||||
|
console.log(
|
||||||
|
`[sync-fusion-skill-tools] SKILL.md tool-categories block is already up to date (${toolNames.length} tools).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkOnly) {
|
||||||
|
console.error(
|
||||||
|
`[sync-fusion-skill-tools] SKILL.md tool-categories block is out of date.\n` +
|
||||||
|
`Run \`node scripts/sync-fusion-skill-tools.mjs\` to regenerate it.`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(skillPath, updated);
|
||||||
|
console.log(
|
||||||
|
`[sync-fusion-skill-tools] Rewrote tool-categories block in ${skillPath} (${toolNames.length} tools).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user