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:
7
.changeset/darwin-only-computer-use-skill-staging.md
Normal file
7
.changeset/darwin-only-computer-use-skill-staging.md
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user