Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,12 +584,6 @@ async function scaffoldProject(
async function ensureSkillsCurrent(destDir: string): Promise<void> {
const { installAllSkills } = await import("./skills.js");
const { checkSkills } = await import("../utils/skillsManifest.js");
// --all pulls every skill (incl. ones not yet installed); --yes keeps it
// non-interactive. When Claude Code is driving, target its native dir so
// skills land in .claude/skills/.
const extraArgs = process.env["CLAUDECODE"]
? ["--all", "--agent", "claude-code", "--yes"]
: ["--all", "--yes"];

console.log();
console.log(c.bold("Checking AI coding skills against GitHub..."));
Expand All @@ -602,7 +596,12 @@ async function ensureSkillsCurrent(destDir: string): Promise<void> {
}

if (needsInstall) {
await installAllSkills({ cwd: destDir, extraArgs });
// installAllSkills resolves the agent target set from destDir + the
// environment (Claude Code → claude-code; otherwise installed CLIs, else a
// Claude-Code + `.agents` floor). A freshly-scaffolded project has no agent
// folders yet, so this lands skills where the running agent will read them
// rather than spraying to every agent convention.
await installAllSkills({ cwd: destDir });
} else {
console.log(c.success("AI coding skills are already up to date."));
}
Expand Down
55 changes: 50 additions & 5 deletions packages/cli/src/commands/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ vi.mock("../utils/skillsManifest.js", () => ({
checkSkills: vi.fn(async () => ({ skills: [] })),
}));

// Agent-target resolution probes the real cwd / PATH / env, which would make
// the spawned-args assertions environment-dependent. Pin it to a fixed result
// so these tests verify how the command BUILDS the spawn, not what's installed
// on the test host. The resolver's own decision tree is covered in
// skillsTargets.test.ts. buildSkillsAddArgs is reproduced (it's trivial) so the
// arg shape under test stays real.
vi.mock("../utils/skillsTargets.js", () => ({
resolveAgentTargets: vi.fn(() => ({ agents: ["claude-code", "universal"], reason: "test" })),
buildSkillsAddArgs: (agents: string[]) => ["--skill", "*", "--agent", ...agents, "--yes"],
}));

function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
value: platform,
Expand Down Expand Up @@ -109,13 +120,35 @@ describe("hyperframes skills", () => {
"linux",
"npx",
["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", "--all", "--copy"],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
"--copy",
],
],
[
"darwin",
"npx",
["--version"],
["skills", "add", "https://github.com/heygen-com/hyperframes", "--all", "--copy"],
[
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
"--copy",
],
],
[
"win32",
Expand All @@ -129,7 +162,12 @@ describe("hyperframes skills", () => {
"skills",
"add",
"https://github.com/heygen-com/hyperframes",
"--all",
"--skill",
"*",
"--agent",
"claude-code",
"universal",
"--yes",
"--copy",
],
],
Expand Down Expand Up @@ -162,9 +200,16 @@ describe("hyperframes skills", () => {
setPlatform("linux");
await runSkillsUpdate();
expect(process.exitCode).toBe(0);
const args = state.spawnCalls[0]?.args ?? [];
// pulls the full set straight from GitHub
expect(state.spawnCalls[0]?.args).toContain("https://github.com/heygen-com/hyperframes");
expect(state.spawnCalls[0]?.args).toContain("--all");
expect(args).toContain("https://github.com/heygen-com/hyperframes");
// every skill, but to a scoped agent set — never the `--all` (= `--agent '*'`) spray
expect(args).toContain("--skill");
expect(args).toContain("--agent");
expect(args).not.toContain("--all");
// `--agent` must be followed by a concrete key, never the `'*'` wildcard
const agentValue = args[args.indexOf("--agent") + 1];
expect(agentValue).not.toBe("*");
});

// `skills add --all` never deletes, so update must separately prune skills the
Expand Down
35 changes: 29 additions & 6 deletions packages/cli/src/commands/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type SkillDiff,
type SkillsCheckResult,
} from "../utils/skillsManifest.js";
import { buildSkillsAddArgs, resolveAgentTargets } from "../utils/skillsTargets.js";
import type { Example } from "./_examples.js";

export const examples: Example[] = [
Expand Down Expand Up @@ -57,14 +58,33 @@ function runSkillsAdd(
source: string,
opts: { cwd?: string; extraArgs?: string[] } = {},
): Promise<void> {
// Targeting: an explicit `extraArgs` wins (callers/tests that know exactly
// what they want); otherwise resolve which agents to install to. We must NOT
// use the upstream `--all` (= `--skill '*' --agent '*' -y`), which sprays the
// skills into every one of ~70 agent conventions on the machine. Instead we
// install every skill (`--skill '*'`) to a scoped agent set: the project's
// existing skill folders, else the agent running us / installed agent CLIs,
// else a Claude-Code + `.agents` floor. See resolveAgentTargets.
let extraArgs = opts.extraArgs;
if (!extraArgs) {
const targets = resolveAgentTargets({
cwd: opts.cwd ?? process.cwd(),
env: process.env,
pathStr: process.env["PATH"] ?? "",
platform: process.platform,
});
console.log(c.dim(`Installing to: ${targets.agents.join(", ")}${targets.reason}`));
extraArgs = buildSkillsAddArgs(targets.agents);
}

// `--copy` writes real files into each target agent's skills dir, instead of
// the upstream default (a canonical `.agents/skills` store + per-agent
// symlinks). That default re-serialises each SKILL.md's frontmatter, so an
// installed bundle no longer byte-matches the published manifest — `skills
// check` then reports a freshly-installed set as outdated, and the symlinked
// layout doesn't reliably land where the agent actually reads. Real copies
// keep the install faithful to the manifest and detectable by `skills check`.
return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? ["--all"]), "--copy"], opts);
return spawnNpx(["skills", "add", source, ...extraArgs, "--copy"], opts);
}

// Skill names are kebab-case directory names. Refuse anything that isn't one
Expand Down Expand Up @@ -244,12 +264,15 @@ const updateCommand = defineCommand({
const dir = args.dir;
const source = args.source;

// `skills add --all` re-fetches every skill to the latest AND installs ones
// not yet present — so "update" pulls the full set, not just what is already
// The install re-fetches every skill to the latest AND installs ones not yet
// present — so "update" pulls the full set, not just what is already
// installed. This is where `init` and the stale-skills nudge both lead.
// runSkillsAdd resolves the agent target set itself (existing project
// folders → installed CLIs → a Claude-Code + `.agents` floor); we no longer
// spray to every agent via `--all`.
//
// Note: the upstream `skills add` CLI has no `--dir` flag (it installs into
// detected agent dirs), so `--dir` here scopes only the *prune* detection
// the resolved agent dirs), so `--dir` here scopes only the *prune* detection
// below, not the install. `--source` likewise drives where the prune's
// "latest" manifest comes from; the install always targets the canonical
// HyperFrames repo so `update` reliably pulls the published set.
Expand All @@ -259,14 +282,14 @@ const updateCommand = defineCommand({
// fails (no npx, `skills add` exits non-zero) it must exit non-zero too —
// otherwise the `||` chain passes while nothing actually changed.
try {
await installAllSkills({ extraArgs: ["--all", "--yes"], strict: true });
await installAllSkills({ strict: true });
} catch (err) {
clack.log.error(c.error(`Update failed: ${(err as Error).message}`));
process.exitCode = 1;
return;
}

// `skills add --all` never deletes, so a skill renamed or dropped upstream
// `skills add` never deletes, so a skill renamed or dropped upstream
// (e.g. graphic-overlays → talking-head-recut) would linger forever. Prune
// skills the lock attributes to our source that the manifest no longer
// ships, so `check || update` fully reconciles the install to the manifest.
Expand Down
130 changes: 130 additions & 0 deletions packages/cli/src/utils/skillsTargets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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",
]);
});
});
Loading
Loading