291 lines
9.7 KiB
TypeScript
291 lines
9.7 KiB
TypeScript
/*
|
|
FNXC:GrokAcp 2026-07-11-14:00:
|
|
Stage Fusion + session skills so Grok ACP discovers them the same way pi does.
|
|
Grok loads skills from trusted `--plugin-dir` / `_meta.pluginDirs` plugins
|
|
(skills/ SKILL.md tree). We materialize a session-scoped plugin directory with:
|
|
- the bundled Fusion skill (fn_* tool catalog + workflows)
|
|
- skills from engine additionalSkillPaths / skill roots
|
|
Requested skill names are also listed in runtime context rules so the agent
|
|
still sees the selection when a skill file cannot be resolved on disk.
|
|
|
|
FNXC:GrokAcp 2026-07-12-06:15:
|
|
Packaged `@runfusion/fusion` publishes `skill/**` (not only monorepo
|
|
`packages/cli/skill/fusion`). Expand fusion-skill candidates so CLI installs under
|
|
`dist/plugins/fusion-plugin-grok-runtime/` still resolve `skill/fusion` at the
|
|
package root, via parent walks, createRequire of `@runfusion/fusion/package.json`,
|
|
and optional `FUSION_SKILL_SOURCE`. Missing fusion skill must not fail session
|
|
create — rules still list requested skills via buildGrokSkillRules.
|
|
*/
|
|
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const FUSION_SKILL_NAME = "fusion";
|
|
|
|
export interface GrokSkillStagingResult {
|
|
pluginDir: string;
|
|
skillNames: string[];
|
|
dispose: () => void;
|
|
}
|
|
|
|
function isSkillDir(dir: string): boolean {
|
|
return existsSync(join(dir, "SKILL.md"));
|
|
}
|
|
|
|
function pushUnique(out: string[], candidate: string | null | undefined): void {
|
|
if (!candidate) return;
|
|
const resolved = resolve(candidate);
|
|
if (!out.includes(resolved)) out.push(resolved);
|
|
}
|
|
|
|
function pushSkillLayoutsAtRoot(out: string[], root: string): void {
|
|
pushUnique(out, join(root, "skill", FUSION_SKILL_NAME));
|
|
pushUnique(out, join(root, "packages", "cli", "skill", FUSION_SKILL_NAME));
|
|
}
|
|
|
|
function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void {
|
|
let dir = startDir;
|
|
for (let i = 0; i < maxParents; i++) {
|
|
pushSkillLayoutsAtRoot(out, dir);
|
|
const parent = dirname(dir);
|
|
if (parent === dir) break;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function pushPackageRequireCandidates(out: string[], from: string): void {
|
|
try {
|
|
const require = createRequire(from);
|
|
const pkgJson = require.resolve("@runfusion/fusion/package.json");
|
|
pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME));
|
|
} catch {
|
|
// Package not resolvable from this origin (plugin-only tree, tests, etc.).
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ordered candidate directories for the bundled Fusion skill (`skill/fusion` with SKILL.md).
|
|
* First existing skill dir wins in resolveBundledFusionSkillSource.
|
|
*/
|
|
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
|
|
const candidates: string[] = [];
|
|
const envSource = process.env.FUSION_SKILL_SOURCE?.trim();
|
|
if (envSource) {
|
|
pushUnique(candidates, envSource);
|
|
}
|
|
|
|
const here = fileURLToPath(moduleUrl);
|
|
const moduleDir = dirname(here);
|
|
|
|
// Monorepo source checkout relative to plugins/fusion-plugin-grok-runtime/src
|
|
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME));
|
|
// Relative siblings used in various dist layouts
|
|
pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME));
|
|
pushUnique(candidates, resolve(moduleDir, "..", "skill", FUSION_SKILL_NAME));
|
|
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME));
|
|
/*
|
|
FNXC:GrokAcp 2026-07-12-06:15:
|
|
Published package layout: dist/plugins/fusion-plugin-grok-runtime/* → ../../../skill/fusion
|
|
at the @runfusion/fusion package root (files includes skill/**).
|
|
*/
|
|
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME));
|
|
pushUnique(candidates, resolve(moduleDir, "../../../skill", FUSION_SKILL_NAME));
|
|
|
|
walkAncestorSkillCandidates(candidates, moduleDir, 8);
|
|
pushPackageRequireCandidates(candidates, moduleUrl);
|
|
|
|
const argv1 = typeof process.argv[1] === "string" ? process.argv[1].trim() : "";
|
|
if (argv1) {
|
|
try {
|
|
const argvPath = resolve(argv1);
|
|
pushPackageRequireCandidates(candidates, argvPath);
|
|
walkAncestorSkillCandidates(candidates, dirname(argvPath), 8);
|
|
} catch {
|
|
// ignore bad argv paths
|
|
}
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null {
|
|
for (const candidate of getFusionSkillSourceCandidates(moduleUrl)) {
|
|
if (isSkillDir(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function installSkillDir(sourceDir: string, targetDir: string): boolean {
|
|
if (!isSkillDir(sourceDir)) return false;
|
|
mkdirSync(dirname(targetDir), { recursive: true });
|
|
if (existsSync(targetDir)) {
|
|
rmSync(targetDir, { recursive: true, force: true });
|
|
}
|
|
try {
|
|
symlinkSync(sourceDir, targetDir, "dir");
|
|
return true;
|
|
} catch {
|
|
try {
|
|
cpSync(sourceDir, targetDir, { recursive: true });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectSkillsFromRoot(root: string, out: Map<string, string>): void {
|
|
if (!existsSync(root)) return;
|
|
// Root may itself be a skill (…/skills/foo with SKILL.md) or a skills container.
|
|
if (isSkillDir(root)) {
|
|
out.set(basename(root), root);
|
|
return;
|
|
}
|
|
let entries: string[] = [];
|
|
try {
|
|
entries = readdirSync(root);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const child = join(root, entry);
|
|
if (isSkillDir(child)) {
|
|
out.set(entry, child);
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface StageGrokSkillsOptions {
|
|
/** Engine-requested skill names (skillSelection / skills). */
|
|
requestedSkillNames?: string[];
|
|
/** Extra skill roots (plugin skill dirs, CE install roots, etc.). */
|
|
additionalSkillPaths?: string[];
|
|
/** Always include the bundled Fusion skill (default true). */
|
|
includeFusionSkill?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Build a session-scoped Grok plugin directory with Fusion + requested skills.
|
|
*/
|
|
export function stageGrokSessionSkills(options: StageGrokSkillsOptions = {}): GrokSkillStagingResult {
|
|
const pluginDir = mkdtempSync(join(tmpdir(), "fusion-grok-plugin-"));
|
|
const skillsDir = join(pluginDir, "skills");
|
|
mkdirSync(skillsDir, { recursive: true });
|
|
|
|
const installed = new Map<string, string>();
|
|
const includeFusion = options.includeFusionSkill !== false;
|
|
|
|
if (includeFusion) {
|
|
const fusionSource = resolveBundledFusionSkillSource();
|
|
if (fusionSource && installSkillDir(fusionSource, join(skillsDir, FUSION_SKILL_NAME))) {
|
|
installed.set(FUSION_SKILL_NAME, fusionSource);
|
|
}
|
|
}
|
|
|
|
for (const root of options.additionalSkillPaths ?? []) {
|
|
if (typeof root !== "string" || !root.trim()) continue;
|
|
collectSkillsFromRoot(root.trim(), installed);
|
|
}
|
|
|
|
// Re-install collected skills (may overwrite with higher-priority roots).
|
|
for (const [name, source] of installed) {
|
|
if (name === FUSION_SKILL_NAME && includeFusion) continue; // already installed
|
|
installSkillDir(source, join(skillsDir, name));
|
|
}
|
|
|
|
// Second pass: additionalSkillPaths may have added fusion under a different name path.
|
|
for (const [name, source] of installed) {
|
|
if (!existsSync(join(skillsDir, name))) {
|
|
installSkillDir(source, join(skillsDir, name));
|
|
}
|
|
}
|
|
|
|
writeFileSync(
|
|
join(pluginDir, "plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "fusion-session-skills",
|
|
version: "0.1.0",
|
|
description: "Session-scoped Fusion skills for Grok ACP",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
const skillNames = Array.from(
|
|
new Set([
|
|
...installed.keys(),
|
|
...(options.requestedSkillNames ?? []).filter((n) => typeof n === "string" && n.trim().length > 0),
|
|
]),
|
|
);
|
|
|
|
return {
|
|
pluginDir,
|
|
skillNames,
|
|
dispose: () => {
|
|
try {
|
|
rmSync(pluginDir, { recursive: true, force: true });
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a short rules block listing requested skills and reminding Grok to use
|
|
* Fusion tools/MCP when available.
|
|
*/
|
|
export function buildGrokSkillRules(options: {
|
|
skillNames: string[];
|
|
toolMode?: string;
|
|
fusionToolCount?: number;
|
|
operatorMcpCount?: number;
|
|
}): string {
|
|
const lines = [
|
|
"Fusion runtime context for this session:",
|
|
`- Tool mode: ${options.toolMode ?? "coding"}`,
|
|
];
|
|
if (options.skillNames.length > 0) {
|
|
lines.push(`- Loaded / requested skills: ${options.skillNames.join(", ")}`);
|
|
}
|
|
if (typeof options.fusionToolCount === "number") {
|
|
lines.push(`- Fusion custom tools (fn_*) available via MCP server "fusion-custom-tools": ${options.fusionToolCount}`);
|
|
}
|
|
if (typeof options.operatorMcpCount === "number" && options.operatorMcpCount > 0) {
|
|
lines.push(`- Operator MCP servers forwarded into this session: ${options.operatorMcpCount}`);
|
|
}
|
|
lines.push(
|
|
"- Prefer Fusion fn_* MCP tools for task board / coordination actions (e.g. fn_task_done, fn_task_list) when they are available.",
|
|
"- Use the Fusion skill workflows when planning or managing tasks.",
|
|
);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function extractRequestedSkillNames(options: {
|
|
skills?: unknown;
|
|
skillSelection?: unknown;
|
|
}): string[] {
|
|
const fromSkills = Array.isArray(options.skills)
|
|
? options.skills.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
: [];
|
|
const selection = options.skillSelection as { requestedSkillNames?: unknown } | undefined;
|
|
const fromSelection = Array.isArray(selection?.requestedSkillNames)
|
|
? selection.requestedSkillNames.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
: [];
|
|
return Array.from(new Set(fromSkills.length > 0 ? fromSkills : fromSelection));
|
|
}
|