Files
hyperframes/packages/cli/src/utils/skillsTargets.test.ts
T
WaterrrForeverandClaude Opus 4.8 70a03f788b fix(cli): scope skill installs to relevant agents, not all ~70 via --all (#1748)
`hyperframes skills`, `skills update`, and `init` all shelled out to
`skills add ... --all`, which the upstream CLI expands to
`--skill '*' --agent '*' -y` — every skill into every one of the ~70 agent
conventions it knows about. A user who only runs Claude Code got skill
folders for Cursor, Codex, and 50+ tools they never touch.

Replace `--all` with a resolved target set (new resolveAgentTargets):

  1. If the project already has agent skill folders (`.claude/skills`,
     `.hermes/skills`, …), install ONLY to those — an existing folder is the
     strongest signal of intent, so honour it exactly.
  2. Otherwise (blank project):
     a. Running under Claude Code (CLAUDECODE) → just claude-code.
     b. Else probe PATH for installed agent CLIs (claude, hermes, droid,
        cursor, codex, opencode, gemini) — the gstack approach.
     c. Else fall back to claude-code + the shared `.agents` universal dir,
        which Cursor, Codex, OpenCode, Gemini, Copilot and ~14 others read
        from in project scope. Never `--agent '*'`.

Installs stay `--skill '*'` (all skills) + `--copy` (faithful, detectable by
`skills check`); only the agent fan-out is scoped. The dir<->key map is
explicit because dir names differ from upstream keys (`.factory`/droid,
`.hermes`/hermes-agent) and many agents share `.agents` (-> the single
`universal` key). Keys verified against vercel-labs/skills@v1.5.13.

Verified end-to-end in an isolated project + HOME: the new args land exactly
`.claude/skills` (19) + `.agents/skills` (19) and nothing else — 2 folders,
not 50+. Pure resolver covered by skillsTargets.test.ts; command-arg shape and
the "never --all" regression pinned in skills.test.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:45:33 +08:00

131 lines
4.9 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildSkillsAddArgs, resolveAgentTargets } from "./skillsTargets.js";
const tmpDirs: string[] = [];
function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tmpDirs.push(dir);
return dir;
}
/** A project root containing the given `<host>/skills` folders. */
function projectWith(...hostDirs: string[]): string {
const root = tempDir("hf-targets-proj-");
for (const host of hostDirs) mkdirSync(join(root, host, "skills"), { recursive: true });
return root;
}
/** A PATH-style string pointing at a dir that contains the given fake executables. */
function pathWith(...bins: string[]): string {
const dir = tempDir("hf-targets-bin-");
for (const bin of bins) writeFileSync(join(dir, bin), "");
return dir;
}
afterEach(() => {
for (const dir of tmpDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("resolveAgentTargets", () => {
const blank = { env: {}, pathStr: "", platform: "linux" as const };
// ── 1. Existing project folders win, mapped dir → upstream key ──────────────
it("honours an existing `.hermes/skills` folder, nothing else", () => {
const result = resolveAgentTargets({ ...blank, cwd: projectWith(".hermes") });
expect(result.agents).toEqual(["hermes-agent"]);
});
it("maps `.factory` → droid and `.kiro` → kiro-cli (dir names differ from keys)", () => {
const result = resolveAgentTargets({ ...blank, cwd: projectWith(".factory", ".kiro") });
expect(result.agents).toEqual(["droid", "kiro-cli"]);
});
it("maps the shared `.agents` dir to the single `universal` key", () => {
const result = resolveAgentTargets({ ...blank, cwd: projectWith(".agents") });
expect(result.agents).toEqual(["universal"]);
});
it("returns claude-code first across multiple existing folders", () => {
const result = resolveAgentTargets({ ...blank, cwd: projectWith(".agents", ".claude") });
expect(result.agents).toEqual(["claude-code", "universal"]);
});
it("existing folders take precedence over CLAUDECODE and PATH", () => {
const result = resolveAgentTargets({
cwd: projectWith(".hermes"),
env: { CLAUDECODE: "1" },
pathStr: pathWith("claude", "cursor"),
platform: "linux",
});
expect(result.agents).toEqual(["hermes-agent"]);
});
// ── 2a. Claude Code env on a blank project ──────────────────────────────────
it("targets just claude-code when running under Claude Code", () => {
const result = resolveAgentTargets({
...blank,
cwd: projectWith(),
env: { CLAUDECODE: "1" },
});
expect(result.agents).toEqual(["claude-code"]);
});
// ── 2b. gstack route: installed agent CLIs on PATH ──────────────────────────
it("detects installed agent CLIs on PATH (blank project, no CLAUDECODE)", () => {
const result = resolveAgentTargets({
cwd: projectWith(),
env: {},
pathStr: pathWith("claude", "hermes"),
platform: "linux",
});
expect(result.agents).toEqual(["claude-code", "hermes-agent"]);
});
it("collapses universal-bucket CLIs (cursor/codex/…) to a single `universal`", () => {
const result = resolveAgentTargets({
cwd: projectWith(),
env: {},
pathStr: pathWith("cursor", "codex", "gemini"),
platform: "linux",
});
expect(result.agents).toEqual(["universal"]);
});
// ── 2c. Floor ───────────────────────────────────────────────────────────────
it("falls back to claude-code + universal (.claude + .agents) when nothing is found", () => {
const result = resolveAgentTargets({ ...blank, cwd: projectWith() });
expect(result.agents).toEqual(["claude-code", "universal"]);
});
// ── Invariant: never the `--all` spray ──────────────────────────────────────
it("never returns the `'*'` wildcard agent", () => {
for (const cwd of [projectWith(), projectWith(".hermes"), projectWith(".claude")]) {
const result = resolveAgentTargets({ ...blank, cwd });
expect(result.agents).not.toContain("*");
expect(result.agents.length).toBeGreaterThan(0);
}
});
});
describe("buildSkillsAddArgs", () => {
it("installs every skill to the given agents, non-interactive — not `--all`", () => {
expect(buildSkillsAddArgs(["claude-code", "universal"])).toEqual([
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
]);
});
});