FN-8986: stage computer-use skill only on Darwin

Stage the bundled computer-use discovery skill exclusively for supported macOS runtimes.

- Gate Claude and Grok session skill resolution and staging by platform.
- Install the Hermes discovery skill on Darwin while preserving user overrides and non-Darwin suppression.
- Document runtime discovery behavior, add coverage, and add a minor release changeset.

Files changed:
 .../darwin-only-computer-use-skill-staging.md      |   7 +
 docs/computer-use.md                               |  10 +-
 .../custom-provider-routes-hermes-additive.test.ts |   2 +-
 .../src/__tests__/skill-loader.test.ts             | 197 +++++++++++++++++++++
 .../src/skill-loader.ts                            | 145 ++++++++++-----
 .../src/__tests__/skill-loader.test.ts             |  51 +++++-
 .../fusion-plugin-grok-runtime/src/skill-loader.ts | 145 ++++++++++-----
 .../src/__tests__/fusion-skill-install.test.ts     |  62 ++++++-
 .../src/__tests__/index.test.ts                    |  37 +++-
 .../src/fusion-skill-install.ts                    | 151 ++++++++--------
 plugins/fusion-plugin-hermes-runtime/src/index.ts  |  21 ++-
 11 files changed, 649 insertions(+), 179 deletions(-)

Fusion-Task-Id: FN-8986

Fusion-Task-Lineage: 373d558a-1d0a-4737-99be-f53189a9aeaf

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 04:13:35 -07:00
parent 967e98194e
commit f038d04dc5
11 changed files with 652 additions and 182 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Let macOS runtime sessions automatically discover the computer-use skill.
category: feature
dev: Adds installer-boundary Darwin gating in Hermes and suppression-only staging in Claude, Grok, and Hermes loaders.

View File

@@ -151,13 +151,19 @@ No user timeout flag is provided. Default seam timeouts are: permission probe 5
## Orca prior art and Fusion differences
This surface was informed by [Orca](https://github.com/stablyai/orca) and its [computer-use CLI documentation](https://www.onorca.dev/docs/cli/computer-use). Orca uses two layers: a native-backed `orca computer` CLI and a thin skill that loads a version-matched guide. Fusion follows the CLI-plus-skill separation; the skill is deliberately out of scope for this command and is delivered separately.
This surface was informed by [Orca](https://github.com/stablyai/orca) and its [computer-use CLI documentation](https://www.onorca.dev/docs/cli/computer-use). Orca uses two layers: a native-backed `orca computer` CLI and a thin skill that loads a version-matched guide. Fusion follows the CLI-plus-skill separation; the thin discovery skill is shipped separately from this command surface.
Fusion deliberately differs by using macOS OS built-ins instead of native helpers; publishing non-mutating preflight permission checks, an `unknown` status, and the outcome matrix; documenting durable on-disk cross-process snapshots with identity-verified window/locator replay; distinguishing stale, missing, and unresolvable indexes; publishing deterministic failure precedence; and using a versioned envelope with a fixed append-only error enum. `paste-text` and `perform-secondary-action` are not implemented in this release; they remain absent from capabilities rather than being stubs.
## Use it from an agent
Fusion ships a thin `computer-use` skill alongside the Fusion skill. When Claude-compatible skill installation is configured, both are reconciled into project `.claude/skills/` directories; `fn init` also copies both into supported home skill directories.
<!-- FNXC:ComputerUseDiscovery 2026-08-11-09:23: FN-8984 requires runtime discovery only on macOS, where the CLI capability is supported. Runtime gates must remain authoritative so a requested name or additional bundled path cannot teach unsupported platforms an unreachable capability. -->
### How agents discover this
On macOS, Claude and Grok runtime sessions stage the shipped `computer-use` discovery skill in their session plugin directory, and Hermes installs it into `<hermes home>/skills/computer-use`. The gate is authoritative: no runtime option, requested skill name, or additional path can stage the bundled skill elsewhere, and the Hermes installer returns `skipped` without touching the Hermes home on other platforms. This avoids spending context or disk on an unsupported CLI capability.
Fusion ships the thin `computer-use` skill alongside the Fusion skill. When Claude-compatible skill installation is configured, both are reconciled into project `.claude/skills/` directories; `fn init` also copies both into supported home skill directories.
The discovery stub intentionally contains no command reference. An agent resolves one `fn` executable for its session and runs `fn skills get computer-use` before automation. That command renders its complete guide in-process from the same binary's command-surface descriptor and reports the same package version as `fn --version`.

View File

@@ -53,7 +53,7 @@ vi.mock("@fusion-plugin-examples/hermes-runtime/dist/cli-spawn.js", async () =>
});
vi.mock("@fusion-plugin-examples/hermes-runtime/dist/fusion-skill-install.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@fusion-plugin-examples/hermes-runtime/dist/fusion-skill-install.js");
return { ...actual, installFusionSkillIntoHermesHome: mockInstallFusionSkill };
return { ...actual, installComputerUseSkillIntoHermesHome: mockInstallComputerUseSkill, installFusionSkillIntoHermesHome: mockInstallFusionSkill };
});
function createMockGlobalSettingsStore(settings: GlobalSettings) {

View File

@@ -0,0 +1,197 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { mkdtempSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
buildClaudeSkillRules,
extractRequestedSkillNames,
getFusionSkillSourceCandidates,
resolveBundledComputerUseSkillSource,
resolveBundledFusionSkillSource,
stageClaudeSessionSkills,
} from "../skill-loader.js";
const disposers: Array<() => void> = [];
const envKeysTouched = new Set<string>();
afterEach(() => {
while (disposers.length > 0) disposers.pop()?.();
for (const key of envKeysTouched) {
delete process.env[key];
}
envKeysTouched.clear();
});
function setEnv(key: string, value: string): void {
process.env[key] = value;
envKeysTouched.add(key);
}
describe("skill-loader", () => {
it("resolves the bundled Fusion skill from the monorepo", () => {
const source = resolveBundledFusionSkillSource();
expect(source).toBeTruthy();
expect(existsSync(join(source!, "SKILL.md"))).toBe(true);
});
it("includes monorepo packages/cli/skill/fusion among candidates", () => {
const candidates = getFusionSkillSourceCandidates();
expect(
candidates.some((c) => c.endsWith(join("packages", "cli", "skill", "fusion")) || c.includes(`${join("packages", "cli", "skill", "fusion")}`)),
).toBe(true);
});
/*
FNXC:ClaudeAcp 2026-07-12-06:15:
Packaged @runfusion/fusion ships skill/** at package root next to dist/plugins/.
Candidate generation from a synthetic bundled.js URL must reach skill/fusion
without a monorepo tree.
*/
it("generates packaged-install candidates for bundled plugin layout", () => {
const packageRoot = mkdtempSync(join(tmpdir(), "fusion-pkg-"));
const pluginDist = join(packageRoot, "dist", "plugins", "fusion-plugin-claude-runtime");
mkdirSync(pluginDist, { recursive: true });
const bundledJs = join(pluginDist, "bundled.js");
writeFileSync(bundledJs, "// stub\n");
const skillDir = join(packageRoot, "skill", "fusion");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# fusion\n");
const moduleUrl = pathToFileURL(bundledJs).href;
const candidates = getFusionSkillSourceCandidates(moduleUrl);
const resolvedSkill = resolve(skillDir);
expect(candidates).toContain(resolvedSkill);
expect(resolveBundledFusionSkillSource(moduleUrl)).toBe(resolvedSkill);
});
it("walks ancestors for packages/cli/skill/fusion layout", () => {
const repoRoot = mkdtempSync(join(tmpdir(), "fusion-repo-"));
const pluginSrc = join(repoRoot, "plugins", "fusion-plugin-claude-runtime", "src");
mkdirSync(pluginSrc, { recursive: true });
const moduleFile = join(pluginSrc, "skill-loader.js");
writeFileSync(moduleFile, "// stub\n");
const skillDir = join(repoRoot, "packages", "cli", "skill", "fusion");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# fusion\n");
const moduleUrl = pathToFileURL(moduleFile).href;
const candidates = getFusionSkillSourceCandidates(moduleUrl);
expect(candidates).toContain(resolve(skillDir));
expect(resolveBundledFusionSkillSource(moduleUrl)).toBe(resolve(skillDir));
});
it("prefers FUSION_SKILL_SOURCE when set and valid", () => {
const root = mkdtempSync(join(tmpdir(), "fusion-env-skill-"));
const skillDir = join(root, "custom-fusion");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: fusion\n---\n# env\n");
setEnv("FUSION_SKILL_SOURCE", skillDir);
const candidates = getFusionSkillSourceCandidates(pathToFileURL(join(root, "missing", "mod.js")).href);
expect(candidates[0]).toBe(resolve(skillDir));
expect(resolveBundledFusionSkillSource(pathToFileURL(join(root, "missing", "mod.js")).href)).toBe(
resolve(skillDir),
);
});
it("stages fusion skill plus additional skill roots into a plugin dir", () => {
const extraRoot = mkdtempSync(join(tmpdir(), "extra-skills-"));
const skillDir = join(extraRoot, "ce-plan");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: ce-plan\n---\n# plan\n");
const staged = stageClaudeSessionSkills({
requestedSkillNames: ["fusion", "ce-plan"],
additionalSkillPaths: [extraRoot],
});
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true);
expect(existsSync(join(staged.pluginDir, "skills", "ce-plan", "SKILL.md"))).toBe(true);
expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "ce-plan"]));
});
it("still lists requested fusion skill in rules when staging cannot copy files", () => {
const staged = stageClaudeSessionSkills({
requestedSkillNames: ["fusion"],
includeFusionSkill: false,
});
disposers.push(staged.dispose);
expect(staged.skillNames).toContain("fusion");
const rules = buildClaudeSkillRules({ skillNames: staged.skillNames, fusionToolCount: 0 });
expect(rules).toContain("fusion");
expect(rules).toContain("Use the Fusion skill workflows");
});
it("extracts requested skill names from skills or skillSelection", () => {
expect(extractRequestedSkillNames({ skills: ["a", "b"] })).toEqual(["a", "b"]);
expect(
extractRequestedSkillNames({ skillSelection: { requestedSkillNames: ["fusion", "ce-plan"] } }),
).toEqual(["fusion", "ce-plan"]);
});
it("builds rules mentioning skills and tool counts", () => {
const rules = buildClaudeSkillRules({
skillNames: ["fusion"],
toolMode: "coding",
fusionToolCount: 3,
operatorMcpCount: 1,
});
expect(rules).toContain("fusion");
expect(rules).toContain("fusion-custom-tools");
expect(rules).toContain("Operator MCP servers");
});
it("stages bundled computer-use only on Darwin", () => {
const staged = stageClaudeSessionSkills({ platform: "darwin" });
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true);
expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true);
expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "computer-use"]));
});
it("keeps bundled computer-use absent on non-Darwin through every option path", () => {
const source = resolveBundledComputerUseSkillSource(import.meta.url, "darwin")!;
const cases = [
{ includeComputerUseSkill: true },
{ requestedSkillNames: ["computer-use"] },
{ additionalSkillPaths: [source] },
{ includeComputerUseSkill: true, requestedSkillNames: ["computer-use"], additionalSkillPaths: [source] },
];
for (const options of cases) {
const staged = stageClaudeSessionSkills({ ...options, platform: "linux" });
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "computer-use"))).toBe(false);
expect(staged.skillNames).not.toContain("computer-use");
}
});
it("keeps user-owned computer-use roots on non-Darwin and honors suppression", () => {
const root = mkdtempSync(join(tmpdir(), "user-computer-use-"));
const userSkill = join(root, "computer-use");
mkdirSync(userSkill, { recursive: true });
writeFileSync(join(userSkill, "SKILL.md"), "# user skill\n");
const staged = stageClaudeSessionSkills({ platform: "linux", additionalSkillPaths: [root] });
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true);
const overridden = stageClaudeSessionSkills({ platform: "darwin", additionalSkillPaths: [root] });
disposers.push(overridden.dispose);
expect(readFileSync(join(overridden.pluginDir, "skills", "computer-use", "SKILL.md"), "utf8")).toContain("# user skill");
const suppressed = stageClaudeSessionSkills({ platform: "darwin", includeComputerUseSkill: false });
disposers.push(suppressed.dispose);
expect(suppressed.skillNames).not.toContain("computer-use");
});
it("gates computer-use at source resolution and keeps rules as discovery-only", () => {
expect(resolveBundledComputerUseSkillSource(import.meta.url, "linux")).toBeNull();
expect(resolveBundledComputerUseSkillSource(import.meta.url, "darwin")).toBeTruthy();
const rules = buildClaudeSkillRules({ skillNames: ["fusion", "computer-use"] });
expect(rules).toContain("computer-use");
expect(rules).not.toMatch(/fn computer|--/i);
});
});

View File

@@ -23,6 +23,7 @@ import {
mkdirSync,
mkdtempSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -33,6 +34,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const FUSION_SKILL_NAME = "fusion";
export const COMPUTER_USE_SKILL_NAME = "computer-use";
export interface ClaudeSkillStagingResult {
pluginDir: string;
@@ -50,83 +52,102 @@ function pushUnique(out: string[], candidate: string | null | undefined): void {
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 pushSkillLayoutsAtRoot(out: string[], root: string, skillName: string): void {
pushUnique(out, join(root, "skill", skillName));
pushUnique(out, join(root, "packages", "cli", "skill", skillName));
}
function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void {
function walkAncestorSkillCandidates(out: string[], startDir: string, skillName: string, maxParents = 8): void {
let dir = startDir;
for (let i = 0; i < maxParents; i++) {
pushSkillLayoutsAtRoot(out, dir);
pushSkillLayoutsAtRoot(out, dir, skillName);
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
function pushPackageRequireCandidates(out: string[], from: string): void {
function pushPackageRequireCandidates(out: string[], from: string, skillName: string): void {
try {
const require = createRequire(from);
const pkgJson = require.resolve("@runfusion/fusion/package.json");
pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME));
pushUnique(out, join(dirname(pkgJson), "skill", skillName));
} 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[] {
/** Ordered candidate directories for a bundled skill. First existing directory wins. */
function getSkillSourceCandidates(skillName: string, 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-claude-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:ClaudeAcp 2026-07-12-06:15:
Published package layout: dist/plugins/fusion-plugin-claude-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));
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "..", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "../../../skill", skillName));
walkAncestorSkillCandidates(candidates, moduleDir, 8);
pushPackageRequireCandidates(candidates, moduleUrl);
walkAncestorSkillCandidates(candidates, moduleDir, skillName, 8);
pushPackageRequireCandidates(candidates, moduleUrl, skillName);
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);
pushPackageRequireCandidates(candidates, argvPath, skillName);
walkAncestorSkillCandidates(candidates, dirname(argvPath), skillName, 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;
}
/** Candidate ordering and FUSION_SKILL_SOURCE precedence remain fusion-only. */
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
const candidates: string[] = [];
const envSource = process.env.FUSION_SKILL_SOURCE?.trim();
if (envSource) pushUnique(candidates, envSource);
for (const candidate of getSkillSourceCandidates(FUSION_SKILL_NAME, moduleUrl)) pushUnique(candidates, candidate);
return candidates;
}
export function getComputerUseSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
return getSkillSourceCandidates(COMPUTER_USE_SKILL_NAME, moduleUrl);
}
function resolveSkillSource(candidates: string[]): string | null {
for (const candidate of candidates) if (isSkillDir(candidate)) return candidate;
return null;
}
export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null {
return resolveSkillSource(getFusionSkillSourceCandidates(moduleUrl));
}
export function shouldStageComputerUseSkill(platform: NodeJS.Platform = process.platform): boolean {
return platform === "darwin";
}
export function resolveBundledComputerUseSkillSource(
moduleUrl = import.meta.url,
platform: NodeJS.Platform = process.platform,
): string | null {
if (!shouldStageComputerUseSkill(platform)) return null;
return resolveSkillSource(getComputerUseSkillSourceCandidates(moduleUrl));
}
function resolvedRealPath(path: string | null): string | null {
if (!path) return null;
try {
return realpathSync(path);
} catch {
return null;
}
}
function installSkillDir(sourceDir: string, targetDir: string): boolean {
if (!isSkillDir(sourceDir)) return false;
mkdirSync(dirname(targetDir), { recursive: true });
@@ -174,6 +195,12 @@ export interface StageClaudeSkillsOptions {
additionalSkillPaths?: string[];
/** Always include the bundled Fusion skill (default true). */
includeFusionSkill?: boolean;
/** Platform injection keeps both platform branches testable on every host. */
platform?: NodeJS.Platform;
/** Suppression-only: it can disable computer-use, never override its Darwin gate. */
includeComputerUseSkill?: boolean;
/** Module URL override for packaged-layout resolution tests. */
moduleUrl?: string;
}
/**
@@ -185,31 +212,52 @@ export function stageClaudeSessionSkills(options: StageClaudeSkillsOptions = {})
mkdirSync(skillsDir, { recursive: true });
const installed = new Map<string, string>();
const platform = options.platform ?? process.platform;
const includeFusion = options.includeFusionSkill !== false;
const moduleUrl = options.moduleUrl ?? import.meta.url;
// FNXC:ClaudeAcp 2026-08-11-09:23: FN-8984 requires computer-use only on Darwin.
// Gate both source resolution and installation so no requested/additional option can force
// the FN-8961 discovery stub (and never its command flags/body) onto another platform.
const includeComputerUse = options.includeComputerUseSkill !== false && shouldStageComputerUseSkill(platform);
const bundledComputerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, "darwin");
const bundledComputerUseRealPath = resolvedRealPath(bundledComputerUseSource);
const installedBundledNames = new Set<string>();
if (includeFusion) {
const fusionSource = resolveBundledFusionSkillSource();
const fusionSource = resolveBundledFusionSkillSource(moduleUrl);
if (fusionSource && installSkillDir(fusionSource, join(skillsDir, FUSION_SKILL_NAME))) {
installed.set(FUSION_SKILL_NAME, fusionSource);
installedBundledNames.add(FUSION_SKILL_NAME);
}
}
if (includeComputerUse) {
const computerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, platform);
if (computerUseSource && installSkillDir(computerUseSource, join(skillsDir, COMPUTER_USE_SKILL_NAME))) {
installed.set(COMPUTER_USE_SKILL_NAME, computerUseSource);
installedBundledNames.add(COMPUTER_USE_SKILL_NAME);
}
}
for (const root of options.additionalSkillPaths ?? []) {
if (typeof root !== "string" || !root.trim()) continue;
collectSkillsFromRoot(root.trim(), installed);
const collected = new Map<string, string>();
collectSkillsFromRoot(root.trim(), collected);
for (const [name, source] of collected) {
if (!shouldStageComputerUseSkill(platform) && bundledComputerUseRealPath && resolvedRealPath(source) === bundledComputerUseRealPath) continue;
installed.set(name, source);
}
}
// Re-install collected skills (may overwrite with higher-priority roots).
// FNXC:ClaudeAcp 2026-08-11-09:41: Preserve Fusion's existing bundled precedence, but let a
// user-owned computer-use root override the Darwin bundle without reinstalling the same source.
for (const [name, source] of installed) {
if (name === FUSION_SKILL_NAME && includeFusion) continue; // already installed
if (name === FUSION_SKILL_NAME && includeFusion) continue;
if (name === COMPUTER_USE_SKILL_NAME && installedBundledNames.has(name) && source === bundledComputerUseSource) continue;
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));
}
if (!existsSync(join(skillsDir, name))) installSkillDir(source, join(skillsDir, name));
}
writeFileSync(
@@ -228,7 +276,10 @@ export function stageClaudeSessionSkills(options: StageClaudeSkillsOptions = {})
const skillNames = Array.from(
new Set([
...installed.keys(),
...(options.requestedSkillNames ?? []).filter((n) => typeof n === "string" && n.trim().length > 0),
...(options.requestedSkillNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0 &&
(n !== COMPUTER_USE_SKILL_NAME || shouldStageComputerUseSkill(platform) || installed.has(n)),
),
]),
);

View File

@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { mkdtempSync } from "node:fs";
@@ -8,6 +8,7 @@ import {
buildGrokSkillRules,
extractRequestedSkillNames,
getFusionSkillSourceCandidates,
resolveBundledComputerUseSkillSource,
resolveBundledFusionSkillSource,
stageGrokSessionSkills,
} from "../skill-loader.js";
@@ -145,4 +146,52 @@ describe("skill-loader", () => {
expect(rules).toContain("fusion-custom-tools");
expect(rules).toContain("Operator MCP servers");
});
it("stages bundled computer-use only on Darwin", () => {
const staged = stageGrokSessionSkills({ platform: "darwin" });
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "fusion", "SKILL.md"))).toBe(true);
expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true);
expect(staged.skillNames).toEqual(expect.arrayContaining(["fusion", "computer-use"]));
});
it("keeps bundled computer-use absent on non-Darwin through every option path", () => {
const source = resolveBundledComputerUseSkillSource(import.meta.url, "darwin")!;
const cases = [
{ includeComputerUseSkill: true },
{ requestedSkillNames: ["computer-use"] },
{ additionalSkillPaths: [source] },
{ includeComputerUseSkill: true, requestedSkillNames: ["computer-use"], additionalSkillPaths: [source] },
];
for (const options of cases) {
const staged = stageGrokSessionSkills({ ...options, platform: "linux" });
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "computer-use"))).toBe(false);
expect(staged.skillNames).not.toContain("computer-use");
}
});
it("keeps user-owned computer-use roots on non-Darwin and honors suppression", () => {
const root = mkdtempSync(join(tmpdir(), "user-computer-use-"));
const userSkill = join(root, "computer-use");
mkdirSync(userSkill, { recursive: true });
writeFileSync(join(userSkill, "SKILL.md"), "# user skill\n");
const staged = stageGrokSessionSkills({ platform: "linux", additionalSkillPaths: [root] });
disposers.push(staged.dispose);
expect(existsSync(join(staged.pluginDir, "skills", "computer-use", "SKILL.md"))).toBe(true);
const overridden = stageGrokSessionSkills({ platform: "darwin", additionalSkillPaths: [root] });
disposers.push(overridden.dispose);
expect(readFileSync(join(overridden.pluginDir, "skills", "computer-use", "SKILL.md"), "utf8")).toContain("# user skill");
const suppressed = stageGrokSessionSkills({ platform: "darwin", includeComputerUseSkill: false });
disposers.push(suppressed.dispose);
expect(suppressed.skillNames).not.toContain("computer-use");
});
it("gates computer-use at source resolution and keeps rules as discovery-only", () => {
expect(resolveBundledComputerUseSkillSource(import.meta.url, "linux")).toBeNull();
expect(resolveBundledComputerUseSkillSource(import.meta.url, "darwin")).toBeTruthy();
const rules = buildGrokSkillRules({ skillNames: ["fusion", "computer-use"] });
expect(rules).toContain("computer-use");
expect(rules).not.toMatch(/fn computer|--/i);
});
});

View File

@@ -23,6 +23,7 @@ import {
mkdirSync,
mkdtempSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
@@ -33,6 +34,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const FUSION_SKILL_NAME = "fusion";
export const COMPUTER_USE_SKILL_NAME = "computer-use";
export interface GrokSkillStagingResult {
pluginDir: string;
@@ -50,83 +52,102 @@ function pushUnique(out: string[], candidate: string | null | undefined): void {
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 pushSkillLayoutsAtRoot(out: string[], root: string, skillName: string): void {
pushUnique(out, join(root, "skill", skillName));
pushUnique(out, join(root, "packages", "cli", "skill", skillName));
}
function walkAncestorSkillCandidates(out: string[], startDir: string, maxParents = 8): void {
function walkAncestorSkillCandidates(out: string[], startDir: string, skillName: string, maxParents = 8): void {
let dir = startDir;
for (let i = 0; i < maxParents; i++) {
pushSkillLayoutsAtRoot(out, dir);
pushSkillLayoutsAtRoot(out, dir, skillName);
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
function pushPackageRequireCandidates(out: string[], from: string): void {
function pushPackageRequireCandidates(out: string[], from: string, skillName: string): void {
try {
const require = createRequire(from);
const pkgJson = require.resolve("@runfusion/fusion/package.json");
pushUnique(out, join(dirname(pkgJson), "skill", FUSION_SKILL_NAME));
pushUnique(out, join(dirname(pkgJson), "skill", skillName));
} 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[] {
/** Ordered candidate directories for a bundled skill. First existing directory wins. */
function getSkillSourceCandidates(skillName: string, 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));
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "packages", "cli", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "..", "..", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "..", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "..", "..", "..", "skill", skillName));
pushUnique(candidates, resolve(moduleDir, "../../../skill", skillName));
walkAncestorSkillCandidates(candidates, moduleDir, 8);
pushPackageRequireCandidates(candidates, moduleUrl);
walkAncestorSkillCandidates(candidates, moduleDir, skillName, 8);
pushPackageRequireCandidates(candidates, moduleUrl, skillName);
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);
pushPackageRequireCandidates(candidates, argvPath, skillName);
walkAncestorSkillCandidates(candidates, dirname(argvPath), skillName, 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;
}
/** Candidate ordering and FUSION_SKILL_SOURCE precedence remain fusion-only. */
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
const candidates: string[] = [];
const envSource = process.env.FUSION_SKILL_SOURCE?.trim();
if (envSource) pushUnique(candidates, envSource);
for (const candidate of getSkillSourceCandidates(FUSION_SKILL_NAME, moduleUrl)) pushUnique(candidates, candidate);
return candidates;
}
export function getComputerUseSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
return getSkillSourceCandidates(COMPUTER_USE_SKILL_NAME, moduleUrl);
}
function resolveSkillSource(candidates: string[]): string | null {
for (const candidate of candidates) if (isSkillDir(candidate)) return candidate;
return null;
}
export function resolveBundledFusionSkillSource(moduleUrl = import.meta.url): string | null {
return resolveSkillSource(getFusionSkillSourceCandidates(moduleUrl));
}
export function shouldStageComputerUseSkill(platform: NodeJS.Platform = process.platform): boolean {
return platform === "darwin";
}
export function resolveBundledComputerUseSkillSource(
moduleUrl = import.meta.url,
platform: NodeJS.Platform = process.platform,
): string | null {
if (!shouldStageComputerUseSkill(platform)) return null;
return resolveSkillSource(getComputerUseSkillSourceCandidates(moduleUrl));
}
function resolvedRealPath(path: string | null): string | null {
if (!path) return null;
try {
return realpathSync(path);
} catch {
return null;
}
}
function installSkillDir(sourceDir: string, targetDir: string): boolean {
if (!isSkillDir(sourceDir)) return false;
mkdirSync(dirname(targetDir), { recursive: true });
@@ -174,6 +195,12 @@ export interface StageGrokSkillsOptions {
additionalSkillPaths?: string[];
/** Always include the bundled Fusion skill (default true). */
includeFusionSkill?: boolean;
/** Platform injection keeps both platform branches testable on every host. */
platform?: NodeJS.Platform;
/** Suppression-only: it can disable computer-use, never override its Darwin gate. */
includeComputerUseSkill?: boolean;
/** Module URL override for packaged-layout resolution tests. */
moduleUrl?: string;
}
/**
@@ -185,31 +212,52 @@ export function stageGrokSessionSkills(options: StageGrokSkillsOptions = {}): Gr
mkdirSync(skillsDir, { recursive: true });
const installed = new Map<string, string>();
const platform = options.platform ?? process.platform;
const includeFusion = options.includeFusionSkill !== false;
const moduleUrl = options.moduleUrl ?? import.meta.url;
// FNXC:GrokAcp 2026-08-11-09:23: FN-8984 requires computer-use only on Darwin.
// Gate both source resolution and installation so no requested/additional option can force
// the FN-8961 discovery stub (and never its command flags/body) onto another platform.
const includeComputerUse = options.includeComputerUseSkill !== false && shouldStageComputerUseSkill(platform);
const bundledComputerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, "darwin");
const bundledComputerUseRealPath = resolvedRealPath(bundledComputerUseSource);
const installedBundledNames = new Set<string>();
if (includeFusion) {
const fusionSource = resolveBundledFusionSkillSource();
const fusionSource = resolveBundledFusionSkillSource(moduleUrl);
if (fusionSource && installSkillDir(fusionSource, join(skillsDir, FUSION_SKILL_NAME))) {
installed.set(FUSION_SKILL_NAME, fusionSource);
installedBundledNames.add(FUSION_SKILL_NAME);
}
}
if (includeComputerUse) {
const computerUseSource = resolveBundledComputerUseSkillSource(moduleUrl, platform);
if (computerUseSource && installSkillDir(computerUseSource, join(skillsDir, COMPUTER_USE_SKILL_NAME))) {
installed.set(COMPUTER_USE_SKILL_NAME, computerUseSource);
installedBundledNames.add(COMPUTER_USE_SKILL_NAME);
}
}
for (const root of options.additionalSkillPaths ?? []) {
if (typeof root !== "string" || !root.trim()) continue;
collectSkillsFromRoot(root.trim(), installed);
const collected = new Map<string, string>();
collectSkillsFromRoot(root.trim(), collected);
for (const [name, source] of collected) {
if (!shouldStageComputerUseSkill(platform) && bundledComputerUseRealPath && resolvedRealPath(source) === bundledComputerUseRealPath) continue;
installed.set(name, source);
}
}
// Re-install collected skills (may overwrite with higher-priority roots).
// FNXC:GrokAcp 2026-08-11-09:41: Preserve Fusion's existing bundled precedence, but let a
// user-owned computer-use root override the Darwin bundle without reinstalling the same source.
for (const [name, source] of installed) {
if (name === FUSION_SKILL_NAME && includeFusion) continue; // already installed
if (name === FUSION_SKILL_NAME && includeFusion) continue;
if (name === COMPUTER_USE_SKILL_NAME && installedBundledNames.has(name) && source === bundledComputerUseSource) continue;
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));
}
if (!existsSync(join(skillsDir, name))) installSkillDir(source, join(skillsDir, name));
}
writeFileSync(
@@ -228,7 +276,10 @@ export function stageGrokSessionSkills(options: StageGrokSkillsOptions = {}): Gr
const skillNames = Array.from(
new Set([
...installed.keys(),
...(options.requestedSkillNames ?? []).filter((n) => typeof n === "string" && n.trim().length > 0),
...(options.requestedSkillNames ?? []).filter(
(n) => typeof n === "string" && n.trim().length > 0 &&
(n !== COMPUTER_USE_SKILL_NAME || shouldStageComputerUseSkill(platform) || installed.has(n)),
),
]),
);

View File

@@ -1,11 +1,13 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
getFusionSkillSourceCandidates,
installComputerUseSkillIntoHermesHome,
installFusionSkillIntoHermesHome,
resolveBundledFusionSkillSourceFromCandidates,
shouldInstallComputerUseSkill,
} from "../fusion-skill-install.js";
const tempDirs: string[] = [];
@@ -16,9 +18,9 @@ function tempDir(prefix: string): string {
return dir;
}
function makeSkillSource(dir: string): void {
function makeSkillSource(dir: string, body = "# Fusion Skill\n"): void {
mkdirSync(dir, { recursive: true });
writeFileSync(path.join(dir, "SKILL.md"), "# Fusion Skill\n");
writeFileSync(path.join(dir, "SKILL.md"), body);
}
afterEach(() => {
@@ -116,4 +118,58 @@ describe("fusion skill installer", () => {
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
expect(result.outcome).toBe("replaced");
});
it("installs computer-use on Darwin and preserves repeat/profile outcomes", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("computer-source-"), "computer-use");
makeSkillSource(source, "# computer-use skill\n");
process.env.HERMES_HOME = home;
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("installed");
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("already-installed");
const profiled = installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin", profile: "work" });
expect(profiled.targetDir).toBe(path.join(home, "profiles", "work", "skills", "computer-use"));
});
it("replaces stale computer-use installs but protects user targets", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("computer-source-"), "computer-use");
const old = path.join(tempDir("computer-old-"), "computer-use");
makeSkillSource(source, "# computer-use skill\n");
makeSkillSource(old, "# computer-use skill\n");
process.env.HERMES_HOME = home;
const target = path.join(home, "skills", "computer-use");
makeSkillSource(target, "# computer-use skill\n");
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("replaced");
rmSync(target, { recursive: true, force: true });
symlinkSync(old, target, "dir");
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("replaced");
rmSync(target, { recursive: true, force: true });
mkdirSync(target, { recursive: true });
writeFileSync(path.join(target, "README.md"), "mine");
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "darwin" }).outcome).toBe("skipped");
});
it("enforces the platform gate before touching Hermes home", () => {
const home = tempDir("hermes-home-");
const source = path.join(tempDir("computer-source-"), "computer-use");
makeSkillSource(source, "# computer-use skill\n");
process.env.HERMES_HOME = home;
expect(shouldInstallComputerUseSkill("darwin")).toBe(true);
expect(shouldInstallComputerUseSkill("linux")).toBe(false);
expect(shouldInstallComputerUseSkill("win32")).toBe(false);
for (const platform of ["linux", "win32"] as const) {
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform }).outcome).toBe("skipped");
expect(existsSync(path.join(home, "skills"))).toBe(false);
}
const stale = path.join(home, "skills", "computer-use");
makeSkillSource(stale, "# computer-use skill\nold");
expect(installComputerUseSkillIntoHermesHome({ sourceDir: source, platform: "linux" }).outcome).toBe("skipped");
expect(readFileSync(path.join(stale, "SKILL.md"), "utf8")).toContain("old");
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("installed");
});
it("warns for a missing computer-use source on Darwin", () => {
process.env.HERMES_HOME = tempDir("hermes-home-");
expect(installComputerUseSkillIntoHermesHome({ sourceDir: null, platform: "darwin" }).outcome).toBe("warning");
});
});

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
const { mockResolveCli, mockInstallComputerUseSkill, mockInstallFusionSkill, mockShouldInstallComputerUseSkill } = vi.hoisted(() => ({
mockResolveCli: vi.fn().mockReturnValue({
binaryPath: "hermes",
model: undefined,
@@ -9,6 +9,12 @@ const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
yolo: false,
cliTimeoutMs: 300_000,
}),
mockInstallComputerUseSkill: vi.fn().mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/computer-use",
}),
mockShouldInstallComputerUseSkill: vi.fn().mockReturnValue(false),
mockInstallFusionSkill: vi.fn().mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
@@ -30,7 +36,9 @@ vi.mock("../fusion-skill-install.js", async () => {
);
return {
...actual,
installComputerUseSkillIntoHermesHome: mockInstallComputerUseSkill,
installFusionSkillIntoHermesHome: mockInstallFusionSkill,
shouldInstallComputerUseSkill: mockShouldInstallComputerUseSkill,
};
});
@@ -59,6 +67,12 @@ describe("hermes-runtime plugin", () => {
cliTimeoutMs: 300_000,
profile: undefined,
});
mockShouldInstallComputerUseSkill.mockReturnValue(false);
mockInstallComputerUseSkill.mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/computer-use",
});
mockInstallFusionSkill.mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
@@ -104,6 +118,12 @@ describe("hermes-runtime plugin", () => {
});
it("onLoad warns but continues when skill install warns", async () => {
mockShouldInstallComputerUseSkill.mockReturnValue(false);
mockInstallComputerUseSkill.mockReturnValue({
outcome: "installed",
sourceDir: "/tmp/source",
targetDir: "/tmp/computer-use",
});
mockInstallFusionSkill.mockReturnValue({
outcome: "warning",
sourceDir: null,
@@ -121,6 +141,21 @@ describe("hermes-runtime plugin", () => {
});
});
it("installs computer-use only when the caller platform gate permits it", async () => {
mockShouldInstallComputerUseSkill.mockReturnValue(true);
const ctx = createMockContext();
await plugin.hooks!.onLoad!(ctx as any);
expect(mockInstallComputerUseSkill).toHaveBeenCalledWith({ profile: undefined });
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("computerUseSkill=installed"));
vi.clearAllMocks();
mockShouldInstallComputerUseSkill.mockReturnValue(false);
await plugin.hooks!.onLoad!(ctx as any);
expect(mockInstallFusionSkill).toHaveBeenCalledWith({ profile: undefined });
expect(mockInstallComputerUseSkill).not.toHaveBeenCalled();
expect(ctx.logger.info).toHaveBeenCalledWith(expect.not.stringContaining("computerUseSkill="));
});
it("factory returns a HermesRuntimeAdapter", async () => {
const ctx = createMockContext({ binaryPath: "hermes" });
const runtime = (await hermesRuntimeFactory(ctx as any)) as HermesRuntimeAdapter;

View File

@@ -14,6 +14,7 @@ import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const FUSION_SKILL_NAME = "fusion";
export const COMPUTER_USE_SKILL_NAME = "computer-use";
export type HermesFusionSkillInstallOutcome =
| "installed"
@@ -35,55 +36,66 @@ export function resolveHermesHome(profile?: string): string {
return join(base, "profiles", profile);
}
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
function getSkillSourceCandidates(skillName: string, moduleUrl = import.meta.url): string[] {
const here = fileURLToPath(moduleUrl);
const moduleDir = dirname(here);
return [
resolve(moduleDir, "..", "..", "..", "..", "packages", "cli", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "..", "skill", FUSION_SKILL_NAME),
resolve(moduleDir, "..", "..", "..", "..", "packages", "cli", "skill", skillName),
resolve(moduleDir, "..", "..", "..", "skill", skillName),
resolve(moduleDir, "..", "..", "skill", skillName),
resolve(moduleDir, "..", "..", "..", "..", "skill", skillName),
];
}
export function resolveBundledFusionSkillSource(): string | null {
const candidates = getFusionSkillSourceCandidates();
export function getFusionSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
return getSkillSourceCandidates(FUSION_SKILL_NAME, moduleUrl);
}
export function getComputerUseSkillSourceCandidates(moduleUrl = import.meta.url): string[] {
return getSkillSourceCandidates(COMPUTER_USE_SKILL_NAME, moduleUrl);
}
function resolveBundledSkillSourceFromCandidates(candidates: string[]): string | null {
for (const candidate of candidates) {
if (existsSync(join(candidate, "SKILL.md"))) return candidate;
}
return null;
}
export function resolveBundledFusionSkillSource(): string | null {
return resolveBundledSkillSourceFromCandidates(getFusionSkillSourceCandidates());
}
export function resolveBundledComputerUseSkillSource(): string | null {
return resolveBundledSkillSourceFromCandidates(getComputerUseSkillSourceCandidates());
}
export function resolveBundledFusionSkillSourceFromCandidates(candidates: string[]): string | null {
for (const candidate of candidates) {
if (existsSync(join(candidate, "SKILL.md"))) return candidate;
}
return null;
return resolveBundledSkillSourceFromCandidates(candidates);
}
export function installFusionSkillIntoHermesHome(options: {
export function shouldInstallComputerUseSkill(platform: NodeJS.Platform = process.platform): boolean {
return platform === "darwin";
}
interface InstallSkillOptions {
profile?: string;
sourceDir?: string | null;
} = {}): HermesFusionSkillInstallResult {
const sourceDir = options.sourceDir ?? resolveBundledFusionSkillSource();
const targetDir = join(resolveHermesHome(options.profile), "skills", FUSION_SKILL_NAME);
}
function installSkillIntoHermesHome(
skillName: string,
options: InstallSkillOptions,
resolveSource: () => string | null,
): HermesFusionSkillInstallResult {
const sourceDir = options.sourceDir ?? resolveSource();
const targetDir = join(resolveHermesHome(options.profile), "skills", skillName);
if (!sourceDir) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: "bundled Fusion skill source directory not found",
};
return { outcome: "warning", sourceDir, targetDir, reason: `bundled ${skillName} skill source directory not found` };
}
try {
mkdirSync(dirname(targetDir), { recursive: true });
let replaced = false;
if (existsSync(targetDir) || isBrokenSymlink(targetDir)) {
const stat = lstatSync(targetDir);
@@ -92,93 +104,82 @@ export function installFusionSkillIntoHermesHome(options: {
if (currentTarget && resolve(dirname(targetDir), currentTarget) === resolve(sourceDir)) {
return { outcome: "already-installed", sourceDir, targetDir };
}
if (!looksLikeFusionSkillTarget(resolve(dirname(targetDir), currentTarget ?? ""))) {
return {
outcome: "skipped",
sourceDir,
targetDir,
reason: "existing symlink does not look like a Fusion skill install",
};
if (!looksLikeFusionSkillTarget(resolve(dirname(targetDir), currentTarget ?? ""), skillName)) {
return { outcome: "skipped", sourceDir, targetDir, reason: "existing symlink does not look like a Fusion skill install" };
}
unlinkSync(targetDir);
replaced = true;
} else {
if (!looksLikePriorFusionInstall(targetDir)) {
return {
outcome: "skipped",
sourceDir,
targetDir,
reason: "existing directory does not look like a Fusion skill install",
};
if (!looksLikePriorFusionInstall(targetDir, skillName)) {
return { outcome: "skipped", sourceDir, targetDir, reason: "existing directory does not look like a Fusion skill install" };
}
rmSync(targetDir, { recursive: true, force: true });
replaced = true;
}
}
try {
symlinkSync(sourceDir, targetDir, "dir");
} catch (error) {
const symlinkReason = error instanceof Error ? error.message : String(error);
try {
cpSync(sourceDir, targetDir, { recursive: true });
return {
outcome: replaced ? "replaced" : "installed",
sourceDir,
targetDir,
reason: `symlink failed (${symlinkReason}); copied files instead`,
};
return { outcome: replaced ? "replaced" : "installed", sourceDir, targetDir, reason: `symlink failed (${symlinkReason}); copied files instead` };
} catch (copyError) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: copyError instanceof Error ? copyError.message : String(copyError),
};
return { outcome: "warning", sourceDir, targetDir, reason: copyError instanceof Error ? copyError.message : String(copyError) };
}
}
return { outcome: replaced ? "replaced" : "installed", sourceDir, targetDir };
} catch (error) {
return { outcome: "warning", sourceDir, targetDir, reason: error instanceof Error ? error.message : String(error) };
}
}
export function installFusionSkillIntoHermesHome(options: InstallSkillOptions = {}): HermesFusionSkillInstallResult {
return installSkillIntoHermesHome(FUSION_SKILL_NAME, options, resolveBundledFusionSkillSource);
}
/*
FNXC:HermesRuntime 2026-08-11-09:23:
FN-8984 makes computer-use Darwin-only because the CLI capability is macOS-only. This persistent,
public installer self-defends before any filesystem access: callers cannot override its platform gate
and it must never clobber a user-owned ~/.hermes skill target.
*/
export function installComputerUseSkillIntoHermesHome(options: InstallSkillOptions & {
platform?: NodeJS.Platform;
} = {}): HermesFusionSkillInstallResult {
const platform = options.platform ?? process.platform;
if (!shouldInstallComputerUseSkill(platform)) {
return {
outcome: "warning",
sourceDir,
targetDir,
reason: error instanceof Error ? error.message : String(error),
outcome: "skipped",
sourceDir: options.sourceDir ?? null,
// Do not resolve Hermes home here: the gate must perform no filesystem reads.
targetDir: "",
reason: `computer-use installation is gated to darwin (current platform: ${platform})`,
};
}
return installSkillIntoHermesHome(COMPUTER_USE_SKILL_NAME, options, resolveBundledComputerUseSkillSource);
}
function safeReadlink(path: string): string | null {
try {
return readlinkSync(path);
} catch {
return null;
}
try { return readlinkSync(path); } catch { return null; }
}
function isBrokenSymlink(path: string): boolean {
try {
const stat = lstatSync(path);
return stat.isSymbolicLink() && !existsSync(path);
} catch {
return false;
}
try { const stat = lstatSync(path); return stat.isSymbolicLink() && !existsSync(path); } catch { return false; }
}
function looksLikePriorFusionInstall(path: string): boolean {
function looksLikePriorFusionInstall(path: string, skillName: string): boolean {
const skillMd = join(path, "SKILL.md");
if (!existsSync(skillMd)) return false;
try {
const body = readFileSync(skillMd, "utf-8");
return /\bfusion\b/i.test(body) && /\bskill\b/i.test(body);
} catch {
return false;
}
return skillName === FUSION_SKILL_NAME
? /\bfusion\b/i.test(body) && /\bskill\b/i.test(body)
: /computer[- ]use|fn computer/i.test(body);
} catch { return false; }
}
function looksLikeFusionSkillTarget(path: string): boolean {
function looksLikeFusionSkillTarget(path: string, skillName: string): boolean {
if (!path) return false;
if (basename(path).toLowerCase() === FUSION_SKILL_NAME) return true;
return existsSync(join(path, "SKILL.md"));
return basename(path).toLowerCase() === skillName || existsSync(join(path, "SKILL.md"));
}

View File

@@ -9,7 +9,11 @@
import { definePlugin } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { installFusionSkillIntoHermesHome } from "./fusion-skill-install.js";
import {
installComputerUseSkillIntoHermesHome,
installFusionSkillIntoHermesHome,
shouldInstallComputerUseSkill,
} from "./fusion-skill-install.js";
import { HermesRuntimeAdapter } from "./runtime-adapter.js";
import type {
FusionPlugin,
@@ -66,6 +70,9 @@ const plugin: FusionPlugin = definePlugin({
onLoad: (ctx: PluginContext) => {
const settings = resolveCliSettings(ctx.settings);
const skillInstall = installFusionSkillIntoHermesHome({ profile: settings.profile });
const computerUseInstall = shouldInstallComputerUseSkill()
? installComputerUseSkillIntoHermesHome({ profile: settings.profile })
: null;
if (skillInstall.outcome === "warning") {
ctx.logger.warn(
@@ -77,8 +84,13 @@ const plugin: FusionPlugin = definePlugin({
);
}
if (computerUseInstall?.outcome === "warning" || computerUseInstall?.outcome === "skipped") {
ctx.logger.warn(
`Hermes Runtime Plugin: computer-use skill auto-install ${computerUseInstall.outcome}: ${computerUseInstall.reason ?? "unknown"}`,
);
}
ctx.logger.info(
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}`,
`Hermes Runtime Plugin loaded — binary=${settings.binaryPath} model=${settings.model ?? "(default)"} fusionSkill=${skillInstall.outcome}${computerUseInstall ? ` computerUseSkill=${computerUseInstall.outcome}` : ""}`,
);
ctx.emitEvent("hermes-runtime:loaded", {
runtimeId: HERMES_RUNTIME_ID,
@@ -109,9 +121,14 @@ export {
listHermesProfiles,
} from "./cli-spawn.js";
export {
COMPUTER_USE_SKILL_NAME,
getComputerUseSkillSourceCandidates,
installComputerUseSkillIntoHermesHome,
installFusionSkillIntoHermesHome,
resolveBundledComputerUseSkillSource,
resolveBundledFusionSkillSource,
resolveHermesHome,
shouldInstallComputerUseSkill,
} from "./fusion-skill-install.js";
export type { HermesCliSettings, HermesCliResult, HermesProfileSummary } from "./cli-spawn.js";