diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index c0de88c13f..89a2c79a8c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -263,6 +263,16 @@ // utils/compositionServer.ts; the remaining clone is per-command logging text // (different labels/help lines) — extracting it would over-abstract. "packages/cli/src/commands/present.ts", + // skillsManifest.test.ts: parallel arrange/act/assert cases for locateInstall + // (project vs global scope, per-agent host conventions, claude-code priority). + // Each case seeds a dir then asserts the resolved location/agent; collapsing + // the shared seed/assert shape would obscure what each scope/host verifies. + "packages/cli/src/utils/skillsManifest.test.ts", + // skills.test.ts: parallel prune cases (removed-in-global vs project, non-slug + // rejection, --source/--dir plumbing) share a mock-checkSkills → runSkillsUpdate + // → assert-remove-spawn shape; each verifies a distinct prune behavior, so + // extracting the shared scaffold would obscure what each case asserts. + "packages/cli/src/commands/skills.test.ts", ], }, "health": { @@ -298,6 +308,11 @@ // body is linear validation that reads clearly inline. "packages/cli/src/commands/play.ts", "packages/cli/src/commands/present.ts", + // sync-agent-dirs.ts: a build-time codegen that regex-parses upstream + // agents.ts. parseAgents/resolveGlobalExpr are branchy by nature (literal + // vs base-var args, validation throws) but small and well-tested via the + // generated table's shape test; this is dev tooling, not shipped runtime. + "packages/cli/scripts/sync-agent-dirs.ts", ], }, } diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d2088ac21..8ab7217771 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,7 +23,8 @@ "build:beat-analyzer": "node scripts/build-beat-analyzer.mjs", "build:copy": "node scripts/build-copy.mjs", "typecheck": "tsc --noEmit", - "gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts" + "gen:skills-manifest": "tsx scripts/gen-skills-manifest.ts", + "gen:agent-dirs": "tsx scripts/sync-agent-dirs.ts" }, "dependencies": { "@hono/node-server": "^1.8.0", diff --git a/packages/cli/scripts/sync-agent-dirs.ts b/packages/cli/scripts/sync-agent-dirs.ts new file mode 100644 index 0000000000..6460758d9a --- /dev/null +++ b/packages/cli/scripts/sync-agent-dirs.ts @@ -0,0 +1,169 @@ +// Generate (or verify) packages/cli/src/utils/agentDirs.generated.ts — the +// home-relative GLOBAL skills directory for every agent the upstream +// vercel-labs/skills CLI knows about, plus a marker dir that means "this agent +// is installed on this machine". +// +// bun run --cwd packages/cli gen:agent-dirs # write/update (fetches upstream) +// bun run --cwd packages/cli gen:agent-dirs --check # verify only (CI / pre-commit) +// bun packages/cli/scripts/sync-agent-dirs.ts --src # offline +// +// Why generated, not hand-maintained: `skills add --global` installs into the +// per-agent dirs encoded in upstream's `src/agents.ts` (~70 agents). We mirror +// the canonical store into those same dirs, so the list must track upstream. The +// `skills` npm package exports nothing importable (CLI-only, bundled dist), so +// we parse the source at a PINNED tag and commit the result — deterministic at +// runtime, no network at install time. Bump SKILLS_REF and re-run on upgrade. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Pin to the upstream release whose dir layout we install against. Bump this +// (and re-run) when the bundled `skills` version moves. +const SKILLS_REPO = "vercel-labs/skills"; +const SKILLS_REF = "v1.5.13"; +const AGENTS_TS_URL = `https://raw.githubusercontent.com/${SKILLS_REPO}/${SKILLS_REF}/src/agents.ts`; + +const here = dirname(fileURLToPath(import.meta.url)); +const outPath = join(here, "..", "src", "utils", "agentDirs.generated.ts"); + +const isCheck = process.argv.includes("--check"); +const srcFlag = process.argv.indexOf("--src"); +const srcPath = srcFlag !== -1 ? process.argv[srcFlag + 1] : undefined; + +// The base directories agents.ts builds globalSkillsDir from. Each is an env +// override with a documented default — the mirror resolves them at runtime so a +// machine with XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR set lands in the +// same place the agent actually reads. We store the base NAME + suffix here +// rather than a frozen path so that resolution stays faithful to upstream. +const BASE_VARS = [ + "home", + "configHome", + "codexHome", + "claudeHome", + "vibeHome", + "hermesHome", + "autohandHome", +] as const; +type BaseVar = (typeof BASE_VARS)[number]; + +interface AgentGlobalDir { + agent: string; + base: BaseVar; + sub: string; +} + +function posixJoin(parts: string[]): string { + return parts + .flatMap((p) => p.split("/")) + .filter((s) => s && s !== ".") + .join("/"); +} + +/** Resolve a globalSkillsDir expression to { base, sub }, or null if none. */ +function resolveGlobalExpr(expr: string): { base: BaseVar; sub: string } | null { + const e = expr.trim(); + if (e === "undefined") return null; // agent defines no global skills dir + // openclaw's helper falls back to ~/.openclaw/skills when no variant is present. + if (e.startsWith("getOpenClawGlobalSkillsDir")) return { base: "home", sub: ".openclaw/skills" }; + const m = e.match(/^join\(([\s\S]+)\)$/); + if (!m) throw new Error(`Unparseable globalSkillsDir: ${expr}`); + const args = m[1]!.split(",").map((a) => a.trim()); + const first = args[0]!; + if (!(BASE_VARS as readonly string[]).includes(first)) { + throw new Error(`globalSkillsDir does not start with a known base var: ${expr}`); + } + const segs: string[] = []; + for (const raw of args.slice(1)) { + const lit = raw.match(/^['"]([^'"]*)['"]$/); + if (!lit) throw new Error(`Non-literal segment "${raw}" in globalSkillsDir: ${expr}`); + segs.push(lit[1]!); + } + return { base: first as BaseVar, sub: posixJoin(segs) }; +} + +function parseAgents(source: string): AgentGlobalDir[] { + const re = /^ {2}(?:"([a-z0-9-]+)"|'([a-z0-9-]+)'|([a-z0-9-]+)):\s*\{/gm; + const blocks: { key: string; pos: number }[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(source))) blocks.push({ key: m[1] || m[2] || m[3]!, pos: m.index }); + if (blocks.length < 60) { + throw new Error(`Parsed only ${blocks.length} agent blocks — upstream layout likely changed`); + } + + const out: AgentGlobalDir[] = []; + for (let i = 0; i < blocks.length; i++) { + const seg = source.slice(blocks[i]!.pos, blocks[i + 1] ? blocks[i + 1]!.pos : source.length); + const gd = seg.match(/globalSkillsDir:\s*([\s\S]+?),\n/); + const expr = gd ? gd[1]!.trim().replace(/\s+/g, " ") : "undefined"; + const resolved = resolveGlobalExpr(expr); + if (resolved === null) continue; // no global dir (e.g. eve, promptscript) + out.push({ agent: blocks[i]!.key, base: resolved.base, sub: resolved.sub }); + } + return out; +} + +function render(rows: AgentGlobalDir[]): string { + const lines = rows.map( + (r) => + ` { agent: ${JSON.stringify(r.agent)}, base: ${JSON.stringify(r.base)}, sub: ${JSON.stringify(r.sub)} },`, + ); + return `// @generated by packages/cli/scripts/sync-agent-dirs.ts — DO NOT EDIT. +// Source: ${SKILLS_REPO}@${SKILLS_REF} (src/agents.ts). Regenerate with: +// bun run --cwd packages/cli gen:agent-dirs +// +// Each entry is one agent the upstream \`skills\` CLI installs to. The agent's +// GLOBAL skills directory is \`join(, )\`, where \`base\` is one of the +// env-overridable home dirs below (resolved at runtime by skillsMirror.ts, so +// XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR are honored). Agents with no +// global skills dir upstream (eve, promptscript) are omitted. + +/** Env-overridable base dirs, matching upstream agents.ts. */ +export type AgentDirBase = +${BASE_VARS.map((b) => ` | ${JSON.stringify(b)}`).join("\n")}; + +export interface AgentGlobalDir { + /** Upstream agent key. */ + agent: string; + /** Base directory the global skills dir is rooted at. */ + base: AgentDirBase; + /** POSIX suffix joined onto the resolved base. */ + sub: string; +} + +export const AGENT_GLOBAL_DIRS: readonly AgentGlobalDir[] = [ +${lines.join("\n")} +]; +`; +} + +async function loadAgentsSource(): Promise { + if (srcPath) return readFileSync(srcPath, "utf8"); + const res = await fetch(AGENTS_TS_URL); + if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${AGENTS_TS_URL}`); + return res.text(); +} + +const source = await loadAgentsSource(); +const rows = parseAgents(source); +const next = render(rows); + +if (isCheck) { + let current = ""; + try { + current = readFileSync(outPath, "utf8"); + } catch { + /* missing → drift */ + } + if (current !== next) { + console.error( + `agentDirs.generated.ts is out of date (parsed ${rows.length} agents from ${SKILLS_REPO}@${SKILLS_REF}).\n` + + `Run: bun run --cwd packages/cli gen:agent-dirs`, + ); + process.exit(1); + } + console.log(`agentDirs.generated.ts is up to date (${rows.length} agents).`); +} else { + writeFileSync(outPath, next, "utf8"); + console.log(`Wrote ${rows.length} agents → ${outPath}`); +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 78f14afb2c..03b611974d 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -574,12 +574,14 @@ async function scaffoldProject( } /** - * Ensure the project's AI coding skills are present and current. Checks the - * installed skills against the latest published on GitHub and only (re)installs - * when something is outdated or missing — so re-running `init` on an already - * up-to-date project is a no-op. Best-effort: if the version check can't reach - * GitHub, it installs anyway. The install itself (`installAllSkills`) pulls the - * full set straight from the GitHub repo. + * Ensure the AI coding skills are present and current. Checks the installed + * skills against the latest published on GitHub and only (re)installs when + * something is outdated or missing — so re-running `init` on an up-to-date + * machine is a no-op. Best-effort: if the version check can't reach GitHub, it + * installs anyway. The install itself (`installAllSkills`) installs the full set + * once GLOBALLY (~/.claude/skills + ~/.agents/skills) and mirrors it into every + * other installed agent, so it is project-independent — the check is global-first + * to match. */ async function ensureSkillsCurrent(destDir: string): Promise { const { installAllSkills } = await import("./skills.js"); @@ -596,11 +598,9 @@ async function ensureSkillsCurrent(destDir: string): Promise { } if (needsInstall) { - // 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. + // installAllSkills installs the full set once globally and mirrors it into + // every installed agent's global dir — project-independent, so a freshly + // scaffolded project doesn't need any agent folders yet. await installAllSkills({ cwd: destDir }); } else { console.log(c.success("AI coding skills are already up to date.")); diff --git a/packages/cli/src/commands/skills.test.ts b/packages/cli/src/commands/skills.test.ts index b080ae191a..0ca3042c35 100644 --- a/packages/cli/src/commands/skills.test.ts +++ b/packages/cli/src/commands/skills.test.ts @@ -54,19 +54,31 @@ vi.mock("@clack/prompts", () => ({ // default returns nothing removed, and the prune test overrides per-call. vi.mock("../utils/skillsManifest.js", () => ({ checkSkills: vi.fn(async () => ({ skills: [] })), + // installAllSkills resolves the HyperFrames skill names (lock-attributed) to + // scope the mirror; pin it so these arg-shape tests don't read a real lock. + hyperframesSkillNames: vi.fn(() => ["hyperframes"]), })); -// 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"], +// The install fans out to other agents via mirrorGlobalSkills, which touches +// the real $HOME. Stub it so these arg-shape tests never create symlinks in the +// dev machine's agent dirs — the mirror has its own isolated-HOME unit tests. +vi.mock("../utils/skillsMirror.js", () => ({ + mirrorGlobalSkills: vi.fn(() => ({ source: null, mirrored: [] })), })); +// The global install command this CLI runs (after `skills add `). +const GLOBAL_ARGS = [ + "--skill", + "*", + "--global", + "--agent", + "claude-code", + "universal", + "--copy", + "--full-depth", + "--yes", +] as const; + function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, "platform", { value: platform, @@ -101,7 +113,7 @@ describe("hyperframes skills", () => { process.exitCode = prevExitCode; }); - it("sets GIT_CLONE_PROTECTION_ACTIVE=0 on the spawned skills CLI child (GH #316)", async () => { + it("sets clone-safe env on the spawned skills CLI child (GH #316 + LFS skip)", async () => { setPlatform("linux"); const { default: skillsCmd } = await import("./skills.js"); @@ -113,6 +125,8 @@ describe("hyperframes skills", () => { expect(first!.args).toContain("skills"); expect(first!.args).toContain("add"); expect(first!.env?.GIT_CLONE_PROTECTION_ACTIVE).toBe("0"); + // --full-depth clones the repo; skip LFS so we don't drag in unrelated blobs. + expect(first!.env?.GIT_LFS_SKIP_SMUDGE).toBe("1"); }); it.each([ @@ -120,35 +134,13 @@ describe("hyperframes skills", () => { "linux", "npx", ["--version"], - [ - "skills", - "add", - "https://github.com/heygen-com/hyperframes", - "--skill", - "*", - "--agent", - "claude-code", - "universal", - "--yes", - "--copy", - ], + ["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], ], [ "darwin", "npx", ["--version"], - [ - "skills", - "add", - "https://github.com/heygen-com/hyperframes", - "--skill", - "*", - "--agent", - "claude-code", - "universal", - "--yes", - "--copy", - ], + ["skills", "add", "https://github.com/heygen-com/hyperframes", ...GLOBAL_ARGS], ], [ "win32", @@ -162,13 +154,7 @@ describe("hyperframes skills", () => { "skills", "add", "https://github.com/heygen-com/hyperframes", - "--skill", - "*", - "--agent", - "claude-code", - "universal", - "--yes", - "--copy", + ...GLOBAL_ARGS, ], ], ] as const)( @@ -201,11 +187,12 @@ describe("hyperframes skills", () => { await runSkillsUpdate(); expect(process.exitCode).toBe(0); const args = state.spawnCalls[0]?.args ?? []; - // pulls the full set straight from GitHub + // pulls the full set straight from GitHub, globally, as a faithful clone 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).toContain("--global"); + expect(args).toContain("--copy"); + expect(args).toContain("--full-depth"); + // never the `--all` (= `--agent '*'`) spray expect(args).not.toContain("--all"); // `--agent` must be followed by a concrete key, never the `'*'` wildcard const agentValue = args[args.indexOf("--agent") + 1]; diff --git a/packages/cli/src/commands/skills.ts b/packages/cli/src/commands/skills.ts index f3b2b093ce..c74e4aff8c 100644 --- a/packages/cli/src/commands/skills.ts +++ b/packages/cli/src/commands/skills.ts @@ -6,11 +6,12 @@ import { buildNpxCommand } from "../utils/npxCommand.js"; import { withMeta } from "../utils/updateCheck.js"; import { checkSkills, + hyperframesSkillNames, SKILLS_CLI_LOCK_PATHS_VERIFIED_AT, type SkillDiff, type SkillsCheckResult, } from "../utils/skillsManifest.js"; -import { buildSkillsAddArgs, resolveAgentTargets } from "../utils/skillsTargets.js"; +import { mirrorGlobalSkills } from "../utils/skillsMirror.js"; import type { Example } from "./_examples.js"; export const examples: Example[] = [ @@ -35,15 +36,25 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { return new Promise((resolve, reject) => { const child = spawn(npx.command, npx.args, { stdio: "inherit", - timeout: 120_000, + // We install with --full-depth (a full `git clone` of the repo, the only + // path that bypasses the laggy skills.sh blob — see GLOBAL_INSTALL_ARGS), + // which is heavier than the blob fetch, so allow more headroom. + timeout: 300_000, cwd: opts.cwd, - // GH #316 — the upstream `skills` CLI shells out to `git clone`. - // When Git's clone-hook protection is active (shipped on by default in - // 2.45.1, reverted in 2.45.2, still present on many corporate and CI - // setups), a globally-registered `git lfs install` post-checkout hook - // aborts the clone. The args reaching this function are hardcoded — no - // user input reaches the spawn — so opting out here is safe. - env: { ...process.env, GIT_CLONE_PROTECTION_ACTIVE: "0" }, + env: { + ...process.env, + // GH #316 — the upstream `skills` CLI shells out to `git clone`. When + // Git's clone-hook protection is active (default in 2.45.1, reverted in + // 2.45.2, still present on many corporate/CI setups), a globally + // registered `git lfs install` post-checkout hook aborts the clone. The + // args reaching this function are hardcoded (no user input), so opting + // out is safe. + GIT_CLONE_PROTECTION_ACTIVE: "0", + // Skills are text; the repo's LFS objects are unrelated binary assets. + // Skip the smudge so --full-depth doesn't drag down (or fail on) large + // LFS blobs the install doesn't need. + GIT_LFS_SKIP_SMUDGE: "1", + }, }); child.on("close", (code, signal) => { if (code === 0) resolve(); @@ -54,37 +65,37 @@ function spawnNpx(args: string[], opts: { cwd?: string } = {}): Promise { }); } +// One faithful global install: --copy lands real files in Claude Code's global +// store (~/.claude/skills, which Claude Code reads at global priority) plus the +// shared universal store (~/.agents/skills). mirrorGlobalSkills then fans that +// store out to every OTHER installed agent's global dir. Skills are +// framework-general knowledge, so installing once globally beats copying a full +// set into every project — and avoids the ~70-agent `--all` spray entirely. +// +// Why --copy: real files (not the upstream symlink default, which re-serialises +// each SKILL.md's frontmatter) so an installed bundle byte-matches the published +// manifest and `skills check` reads it as current. Why --full-depth: it forces a +// full `git clone` of HEAD; without it even a full-URL `skills add` fetches the +// skills.sh registry blob, which lags GitHub main by hours, so a fresh install +// would read as several skills "outdated" (verified: blob → ~9 outdated; +// --full-depth → all current). +const GLOBAL_INSTALL_ARGS = [ + "--skill", + "*", + "--global", + "--agent", + "claude-code", + "universal", + "--copy", + "--full-depth", + "--yes", +]; + function runSkillsAdd( source: string, opts: { cwd?: string; extraArgs?: string[] } = {}, ): Promise { - // 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, ...extraArgs, "--copy"], opts); + return spawnNpx(["skills", "add", source, ...(opts.extraArgs ?? GLOBAL_INSTALL_ARGS)], opts); } // Skill names are kebab-case directory names. Refuse anything that isn't one @@ -108,12 +119,34 @@ function runSkillsRemove(names: string[], opts: { global: boolean }): Promise 0) { + console.log( + c.dim(`Linked skills into ${n} other agent ${n === 1 ? "directory" : "directories"}.`), + ); + } + } catch { + // best-effort + } +} + export async function installAllSkills( opts: { cwd?: string; extraArgs?: string[]; strict?: boolean } = {}, ): Promise { @@ -138,6 +171,8 @@ export async function installAllSkills( console.log(c.dim(`${source.name} skills skipped`)); } } + + mirrorToInstalledAgents(); } // ── check ──────────────────────────────────────────────────────────────────── diff --git a/packages/cli/src/utils/agentDirs.generated.ts b/packages/cli/src/utils/agentDirs.generated.ts new file mode 100644 index 0000000000..a769c53169 --- /dev/null +++ b/packages/cli/src/utils/agentDirs.generated.ts @@ -0,0 +1,101 @@ +// @generated by packages/cli/scripts/sync-agent-dirs.ts — DO NOT EDIT. +// Source: vercel-labs/skills@v1.5.13 (src/agents.ts). Regenerate with: +// bun run --cwd packages/cli gen:agent-dirs +// +// Each entry is one agent the upstream `skills` CLI installs to. The agent's +// GLOBAL skills directory is `join(, )`, where `base` is one of the +// env-overridable home dirs below (resolved at runtime by skillsMirror.ts, so +// XDG_CONFIG_HOME / CODEX_HOME / CLAUDE_CONFIG_DIR are honored). Agents with no +// global skills dir upstream (eve, promptscript) are omitted. + +/** Env-overridable base dirs, matching upstream agents.ts. */ +export type AgentDirBase = + | "home" + | "configHome" + | "codexHome" + | "claudeHome" + | "vibeHome" + | "hermesHome" + | "autohandHome"; + +export interface AgentGlobalDir { + /** Upstream agent key. */ + agent: string; + /** Base directory the global skills dir is rooted at. */ + base: AgentDirBase; + /** POSIX suffix joined onto the resolved base. */ + sub: string; +} + +export const AGENT_GLOBAL_DIRS: readonly AgentGlobalDir[] = [ + { agent: "aider-desk", base: "home", sub: ".aider-desk/skills" }, + { agent: "amp", base: "configHome", sub: "agents/skills" }, + { agent: "antigravity", base: "home", sub: ".gemini/antigravity/skills" }, + { agent: "antigravity-cli", base: "home", sub: ".gemini/antigravity-cli/skills" }, + { agent: "astrbot", base: "home", sub: ".astrbot/data/skills" }, + { agent: "autohand-code", base: "autohandHome", sub: "skills" }, + { agent: "augment", base: "home", sub: ".augment/skills" }, + { agent: "bob", base: "home", sub: ".bob/skills" }, + { agent: "claude-code", base: "claudeHome", sub: "skills" }, + { agent: "openclaw", base: "home", sub: ".openclaw/skills" }, + { agent: "cline", base: "home", sub: ".agents/skills" }, + { agent: "codearts-agent", base: "home", sub: ".codeartsdoer/skills" }, + { agent: "codebuddy", base: "home", sub: ".codebuddy/skills" }, + { agent: "codemaker", base: "home", sub: ".codemaker/skills" }, + { agent: "codestudio", base: "home", sub: ".codestudio/skills" }, + { agent: "codex", base: "codexHome", sub: "skills" }, + { agent: "command-code", base: "home", sub: ".commandcode/skills" }, + { agent: "continue", base: "home", sub: ".continue/skills" }, + { agent: "cortex", base: "home", sub: ".snowflake/cortex/skills" }, + { agent: "crush", base: "home", sub: ".config/crush/skills" }, + { agent: "cursor", base: "home", sub: ".cursor/skills" }, + { agent: "deepagents", base: "home", sub: ".deepagents/agent/skills" }, + { agent: "devin", base: "configHome", sub: "devin/skills" }, + { agent: "dexto", base: "home", sub: ".agents/skills" }, + { agent: "droid", base: "home", sub: ".factory/skills" }, + { agent: "firebender", base: "home", sub: ".firebender/skills" }, + { agent: "forgecode", base: "home", sub: ".forge/skills" }, + { agent: "gemini-cli", base: "home", sub: ".gemini/skills" }, + { agent: "github-copilot", base: "home", sub: ".copilot/skills" }, + { agent: "goose", base: "configHome", sub: "goose/skills" }, + { agent: "hermes-agent", base: "hermesHome", sub: "skills" }, + { agent: "inference-sh", base: "home", sub: ".inferencesh/skills" }, + { agent: "jazz", base: "home", sub: ".jazz/skills" }, + { agent: "junie", base: "home", sub: ".junie/skills" }, + { agent: "iflow-cli", base: "home", sub: ".iflow/skills" }, + { agent: "kilo", base: "home", sub: ".kilocode/skills" }, + { agent: "kimi-code-cli", base: "home", sub: ".agents/skills" }, + { agent: "kiro-cli", base: "home", sub: ".kiro/skills" }, + { agent: "kode", base: "home", sub: ".kode/skills" }, + { agent: "lingma", base: "home", sub: ".lingma/skills" }, + { agent: "loaf", base: "home", sub: ".agents/skills" }, + { agent: "mcpjam", base: "home", sub: ".mcpjam/skills" }, + { agent: "mistral-vibe", base: "vibeHome", sub: "skills" }, + { agent: "moxby", base: "home", sub: ".moxby/skills" }, + { agent: "mux", base: "home", sub: ".mux/skills" }, + { agent: "opencode", base: "configHome", sub: "opencode/skills" }, + { agent: "openhands", base: "home", sub: ".openhands/skills" }, + { agent: "ona", base: "home", sub: ".ona/skills" }, + { agent: "pi", base: "home", sub: ".pi/agent/skills" }, + { agent: "qoder", base: "home", sub: ".qoder/skills" }, + { agent: "qoder-cn", base: "home", sub: ".qoder-cn/skills" }, + { agent: "qwen-code", base: "home", sub: ".qwen/skills" }, + { agent: "replit", base: "configHome", sub: "agents/skills" }, + { agent: "reasonix", base: "home", sub: ".reasonix/skills" }, + { agent: "rovodev", base: "home", sub: ".rovodev/skills" }, + { agent: "roo", base: "home", sub: ".roo/skills" }, + { agent: "tabnine-cli", base: "home", sub: ".tabnine/agent/skills" }, + { agent: "terramind", base: "home", sub: ".terramind/skills" }, + { agent: "tinycloud", base: "home", sub: ".tinycloud/skills" }, + { agent: "trae", base: "home", sub: ".trae/skills" }, + { agent: "trae-cn", base: "home", sub: ".trae-cn/skills" }, + { agent: "warp", base: "home", sub: ".agents/skills" }, + { agent: "windsurf", base: "home", sub: ".codeium/windsurf/skills" }, + { agent: "zed", base: "home", sub: ".agents/skills" }, + { agent: "zencoder", base: "home", sub: ".zencoder/skills" }, + { agent: "zenflow", base: "home", sub: ".zencoder/skills" }, + { agent: "neovate", base: "home", sub: ".neovate/skills" }, + { agent: "pochi", base: "home", sub: ".pochi/skills" }, + { agent: "adal", base: "home", sub: ".adal/skills" }, + { agent: "universal", base: "configHome", sub: "agents/skills" }, +]; diff --git a/packages/cli/src/utils/skillsManifest.test.ts b/packages/cli/src/utils/skillsManifest.test.ts index ecf7cbc6b0..6a8cdad947 100644 --- a/packages/cli/src/utils/skillsManifest.test.ts +++ b/packages/cli/src/utils/skillsManifest.test.ts @@ -191,18 +191,21 @@ describe("checkSkills install detection", () => { expect(res.agent).toBe(agent); }); - it("prefers project scope over global, regardless of convention order", async () => { + it("prefers global scope over project (matches how agents load skills)", async () => { const project = join(root, "project"); const home = join(root, "home"); mkdirSync(project, { recursive: true }); mkdirSync(home, { recursive: true }); const source = writeManifest(root); - installSkill(join(home, ".claude/skills"), "alpha"); // global, higher-priority host - installSkill(join(project, ".hermes/skills"), "alpha"); // project, lower-priority host + installSkill(join(home, ".claude/skills"), "alpha"); // global — what the agent actually loads + installSkill(join(project, ".hermes/skills"), "alpha"); // project — overridden by the global copy + // Claude Code (and most agents) give the personal/global scope priority over + // the project scope, and HyperFrames installs globally — so check reports on + // the global copy the agent will really use, not a stale project copy. const res = await checkSkills({ source, cwd: project, home }); - expect(res.location).toBe(join(project, ".hermes/skills")); - expect(res.agent).toBe("hermes"); + expect(res.location).toBe(join(home, ".claude/skills")); + expect(res.agent).toBe("claude-code"); }); it("reports no location and an available update when nothing is installed", async () => { diff --git a/packages/cli/src/utils/skillsManifest.ts b/packages/cli/src/utils/skillsManifest.ts index b2d2c75903..f6a9dc18ee 100644 --- a/packages/cli/src/utils/skillsManifest.ts +++ b/packages/cli/src/utils/skillsManifest.ts @@ -252,7 +252,13 @@ function scopeForDir(dir: string, home: string, cwd: string): "project" | "globa * Find the first skill root that actually contains HyperFrames skills. A * `--dir` override (if given) is treated as a `.../skills` directory directly; * its scope is inferred (see scopeForDir) so removed-detection reads the right - * lock. Otherwise scan project (cwd) then global ($HOME), auto-discovering hosts. + * lock. Otherwise scan global ($HOME) then project (cwd), auto-discovering hosts. + * + * Global is checked FIRST to match how agents actually load skills: Claude Code + * (and most others) give the personal/global scope priority over the project + * scope, and HyperFrames now installs globally. Checking global-first means + * `check` reports on the copy the agent will really use — not a stale project + * copy that a newer global install silently overrides. */ function locateInstall( skillNames: string[], @@ -268,8 +274,8 @@ function locateInstall( : null; } const roots = [ - ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project"), ...discoverSkillRoots(opts.home ?? homedir(), "global"), + ...discoverSkillRoots(opts.cwd ?? process.cwd(), "project"), ]; for (const root of roots) { if (skillNames.some((n) => existsSync(join(root.dir, n, "SKILL.md")))) return root; @@ -400,6 +406,22 @@ function readSkillLock(path: string): SkillLock | null { } } +/** + * The skill names the upstream lock attributes to HyperFrames, for a scope. + * The mirror MUST scope by this — never by listing `~/.claude/skills`, which is + * shared across sources, so a directory listing would fan a user's gstack / + * personal / company skills out to every agent. Same source-attribution the + * prune uses. Empty when the lock is absent (we can't attribute → mirror none). + */ +export function hyperframesSkillNames(opts: { + scope: "project" | "global"; + cwd?: string; + home?: string; +}): string[] { + const lockPath = lockPathForScope(opts.scope, { cwd: opts.cwd, home: opts.home }); + return skillsAttributedToSource(readSkillLock(lockPath), DEFAULT_REPO_SLUG); +} + interface RemovedResult { removed: SkillDiff[]; /** The lock was absent at the expected path — removed-detection silently no-ops. */ diff --git a/packages/cli/src/utils/skillsMirror.test.ts b/packages/cli/src/utils/skillsMirror.test.ts new file mode 100644 index 0000000000..b98d02efc7 --- /dev/null +++ b/packages/cli/src/utils/skillsMirror.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readlinkSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { mirrorGlobalSkills } from "./skillsMirror.js"; +import { AGENT_GLOBAL_DIRS } from "./agentDirs.generated.js"; + +const tmpDirs: string[] = []; + +// Resolve agent dirs under the isolated HOME with default (unset) env, so the +// dev machine's real XDG_CONFIG_HOME / CODEX_HOME never leak into the test. +const ENV: NodeJS.ProcessEnv = {}; + +function makeHome(): string { + const home = mkdtempSync(join(tmpdir(), "mirror-home-")); + tmpDirs.push(home); + return home; +} + +/** Seed real skill bundles under ~/.claude/skills (the canonical global store). */ +function seedStore(home: string, skills: string[]): void { + for (const name of skills) { + const dir = join(home, ".claude", "skills", name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `# ${name}\n`, "utf8"); + } +} + +/** Pretend an agent is installed by creating its marker dir. */ +function installMarker(home: string, marker: string): void { + mkdirSync(join(home, ...marker.split("/")), { recursive: true }); +} + +afterEach(() => { + for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe("mirrorGlobalSkills", () => { + it("no-ops when there is no global Claude store", () => { + const home = makeHome(); + const result = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); + expect(result.source).toBeNull(); + expect(result.mirrored).toEqual([]); + }); + + it("mirrors the store into installed agents as relative symlinks (Unix)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes", "hyperframes-core"]); + installMarker(home, ".cursor"); // cursor present + installMarker(home, ".config/goose"); // goose present (XDG base) + // windsurf NOT installed (no ~/.codeium/windsurf) + + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes", "hyperframes-core"], + home, + platform: "linux", + env: ENV, + }); + const agents = mirrored.map((m) => m.agent); + expect(agents).toContain("cursor"); + expect(agents).toContain("goose"); + expect(agents).not.toContain("windsurf"); + + const link = join(home, ".cursor", "skills", "hyperframes"); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(isAbsolute(readlinkSync(link))).toBe(false); // relative target + expect(realpathSync(link)).toBe(realpathSync(join(home, ".claude", "skills", "hyperframes"))); + expect(existsSync(join(link, "SKILL.md"))).toBe(true); + + // goose lands in the XDG config dir (~/.config/goose), not ~/.goose + expect( + existsSync(join(home, ".config", "goose", "skills", "hyperframes-core", "SKILL.md")), + ).toBe(true); + }); + + // The blocker Magi flagged: ~/.claude/skills is shared, so a user's gstack / + // personal / company skills live there too. The mirror must fan out ONLY + // HyperFrames' own skills (the lock-attributed allow-list), never everything + // in the store — and must not remove/replace a same-named skill already in + // another agent's dir. + it("only mirrors the allow-listed skills, never other sources' (gstack)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes", "gstack"]); // gstack is a foreign skill in the store + installMarker(home, ".cursor"); + // cursor already has its OWN gstack skill from another source — must survive. + const foreign = join(home, ".cursor", "skills", "gstack"); + mkdirSync(foreign, { recursive: true }); + writeFileSync(join(foreign, "SKILL.md"), "# gstack (cursor's own, not ours)\n", "utf8"); + + mirrorGlobalSkills({ skills: ["hyperframes"], home, platform: "linux", env: ENV }); + + // our skill got linked + expect(lstatSync(join(home, ".cursor", "skills", "hyperframes")).isSymbolicLink()).toBe(true); + // gstack was NOT mirrored from the store... + expect(existsSync(join(home, ".claude", "skills", "gstack"))).toBe(true); // still in store + // ...and cursor's pre-existing gstack was neither replaced with a symlink nor removed + expect(lstatSync(foreign).isSymbolicLink()).toBe(false); + expect(readFileSync(join(foreign, "SKILL.md"), "utf8")).toContain("cursor's own"); + }); + + it("honors XDG_CONFIG_HOME for config-based agents", () => { + const home = makeHome(); + const xdg = makeHome(); // a separate absolute XDG config root + seedStore(home, ["hyperframes"]); + mkdirSync(join(xdg, "goose"), { recursive: true }); // goose marker under XDG + + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: { XDG_CONFIG_HOME: xdg }, + }); + expect(mirrored.map((m) => m.agent)).toContain("goose"); + expect(existsSync(join(xdg, "goose", "skills", "hyperframes", "SKILL.md"))).toBe(true); + expect(existsSync(join(home, ".config", "goose", "skills"))).toBe(false); + }); + + it("copies instead of symlinking on Windows", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".cursor"); + + mirrorGlobalSkills({ skills: ["hyperframes"], home, platform: "win32", env: ENV }); + const target = join(home, ".cursor", "skills", "hyperframes"); + expect(lstatSync(target).isSymbolicLink()).toBe(false); + expect(lstatSync(target).isDirectory()).toBe(true); + expect(existsSync(join(target, "SKILL.md"))).toBe(true); + }); + + it("never mirrors onto the install-owned stores (.claude / .agents)", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".agents"); // .agents present (the universal install creates it) + + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); + expect(mirrored.map((m) => m.agent)).not.toContain("claude-code"); + // the .agents-family agents (cline/dexto/…) map to .agents/skills and are skipped + expect(mirrored.map((m) => m.agent)).not.toContain("cline"); + // ~/.agents/skills is the real universal store — must stay untouched (no link created) + expect(existsSync(join(home, ".agents", "skills"))).toBe(false); + }); + + it("is idempotent and refreshes stale entries", () => { + const home = makeHome(); + seedStore(home, ["hyperframes"]); + installMarker(home, ".cursor"); + + mirrorGlobalSkills({ skills: ["hyperframes"], home, platform: "linux", env: ENV }); + // second run must not throw and must leave a valid link + const { mirrored } = mirrorGlobalSkills({ + skills: ["hyperframes"], + home, + platform: "linux", + env: ENV, + }); + expect(mirrored.map((m) => m.agent)).toContain("cursor"); + const link = join(home, ".cursor", "skills", "hyperframes"); + expect(realpathSync(link)).toBe(realpathSync(join(home, ".claude", "skills", "hyperframes"))); + }); +}); + +describe("AGENT_GLOBAL_DIRS (generated table)", () => { + it("is a non-trivial, well-formed table", () => { + const validBases = new Set([ + "home", + "configHome", + "codexHome", + "claudeHome", + "vibeHome", + "hermesHome", + "autohandHome", + ]); + expect(AGENT_GLOBAL_DIRS.length).toBeGreaterThan(50); + for (const e of AGENT_GLOBAL_DIRS) { + expect(validBases.has(e.base)).toBe(true); + expect(e.sub.endsWith("skills")).toBe(true); + expect(e.sub.startsWith("/")).toBe(false); // a suffix, not an absolute path + } + }); + + it("covers the major agents at their real bases", () => { + const byAgent = new Map(AGENT_GLOBAL_DIRS.map((e) => [e.agent, e])); + expect(byAgent.get("claude-code")).toMatchObject({ base: "claudeHome", sub: "skills" }); + expect(byAgent.get("cursor")).toMatchObject({ base: "home", sub: ".cursor/skills" }); + expect(byAgent.get("codex")).toMatchObject({ base: "codexHome", sub: "skills" }); + expect(byAgent.get("goose")).toMatchObject({ base: "configHome", sub: "goose/skills" }); + expect(byAgent.get("windsurf")).toMatchObject({ + base: "home", + sub: ".codeium/windsurf/skills", + }); + expect(byAgent.get("droid")).toMatchObject({ base: "home", sub: ".factory/skills" }); + // bare-dir-in-project agents become namespaced globally (no footgun) + expect(byAgent.get("openclaw")).toMatchObject({ base: "home", sub: ".openclaw/skills" }); + // agents with no upstream global dir are omitted + expect(byAgent.has("eve")).toBe(false); + expect(byAgent.has("promptscript")).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/skillsMirror.ts b/packages/cli/src/utils/skillsMirror.ts new file mode 100644 index 0000000000..e44cef472b --- /dev/null +++ b/packages/cli/src/utils/skillsMirror.ts @@ -0,0 +1,135 @@ +// Fan the canonical global skills store out to every OTHER installed agent. +// +// `skills add --global --agent claude-code universal --copy` writes REAL files +// to two global stores: the Claude store (~/.claude/skills — what Claude Code +// reads, at global priority) and the shared universal store (~/.agents/skills, +// which Cursor/Codex/… read in PROJECT scope and the .agents-family agents read +// globally). But every other agent reads its OWN global dir (~/.cursor/skills, +// goose → ~/.config/goose/skills, …), which upstream's --global does NOT +// populate. +// +// So we mirror the canonical Claude store into each of those per-agent dirs, but +// only for agents the machine actually has (their marker dir exists). On Unix +// each skill is a relative symlink back into the store (one source of truth, +// near-zero size, auto-fresh on update); on Windows it's a copy, because +// symlinks there need admin / Developer Mode and otherwise silently dangle — +// the same fallback the upstream `skills` CLI and gstack both make. +// +// Agent dirs are resolved through the same env-overridable base dirs upstream +// uses (XDG_CONFIG_HOME, CODEX_HOME, CLAUDE_CONFIG_DIR, …), so a machine with +// those set mirrors into the exact dir the agent reads. + +import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { AGENT_GLOBAL_DIRS, type AgentDirBase } from "./agentDirs.generated.js"; + +export interface MirrorResult { + /** The store mirrored from, or null when no global Claude store was found. */ + source: string | null; + /** Agents whose global dir was (re)populated. */ + mirrored: { agent: string; dir: string }[]; +} + +/** Resolve each env-overridable base dir exactly as upstream agents.ts does. */ +function resolveBases(home: string, env: NodeJS.ProcessEnv): Record { + const xdg = env["XDG_CONFIG_HOME"]?.trim(); + return { + home, + configHome: xdg && isAbsolute(xdg) ? xdg : join(home, ".config"), + codexHome: env["CODEX_HOME"]?.trim() || join(home, ".codex"), + claudeHome: env["CLAUDE_CONFIG_DIR"]?.trim() || join(home, ".claude"), + vibeHome: env["VIBE_HOME"]?.trim() || join(home, ".vibe"), + hermesHome: env["HERMES_HOME"]?.trim() || join(home, ".hermes"), + autohandHome: env["AUTOHAND_HOME"]?.trim() || join(home, ".autohand"), + }; +} + +/** Skill bundle names directly under a store (a dir/symlink with a SKILL.md). */ +function listSkillDirs(store: string): string[] { + return readdirSync(store, { withFileTypes: true }) + .filter( + (e) => (e.isDirectory() || e.isSymbolicLink()) && existsSync(join(store, e.name, "SKILL.md")), + ) + .map((e) => e.name); +} + +/** + * Point `targetSkill` at `sourceSkill`. Any prior entry (our symlink, a stale + * copy, or a previous install) is removed first so the mirror always reflects + * the canonical store — that's the whole point of "update". + */ +function linkOrCopy(sourceSkill: string, targetSkill: string, platform: NodeJS.Platform): void { + rmSync(targetSkill, { recursive: true, force: true }); + if (platform === "win32") { + cpSync(sourceSkill, targetSkill, { recursive: true }); + } else { + symlinkSync(relative(dirname(targetSkill), sourceSkill), targetSkill); + } +} + +/** + * Populate one agent's global dir from the store. Best-effort and idempotent; + * per-skill failures don't abort the others. Returns false if the dir couldn't + * be created at all. + */ +function mirrorInto( + targetDir: string, + source: string, + skills: string[], + platform: NodeJS.Platform, +): boolean { + try { + mkdirSync(targetDir, { recursive: true }); + } catch { + return false; + } + for (const skill of skills) { + try { + linkOrCopy(join(source, skill), join(targetDir, skill), platform); + } catch { + // best-effort per skill + } + } + return true; +} + +/** + * Mirror the global Claude store into every installed agent's global skills + * dir. Best-effort and idempotent: a no-op when the store is absent, and per + * skill failures (permissions, races) don't abort the rest. + */ +export function mirrorGlobalSkills(opts: { + skills: readonly string[]; + home?: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; +}): MirrorResult { + const home = opts.home ?? homedir(); + const platform = opts.platform ?? process.platform; + const bases = resolveBases(home, opts.env ?? process.env); + + // The two stores the global --copy install writes as real files. The mirror + // reads from the Claude store and must never link/copy onto either of them. + const source = join(bases.claudeHome, "skills"); + const universalStore = join(home, ".agents", "skills"); + if (!existsSync(source)) return { source: null, mirrored: [] }; + + // Mirror ONLY HyperFrames' own skills (by name), NEVER everything in the + // store: ~/.claude/skills is shared, so a user's gstack / personal / company + // skills live there too and must not be fanned out to (or overwrite) other + // agents. `opts.skills` is the lock-attributed HyperFrames set (see + // hyperframesSkillNames). + const allowed = new Set(opts.skills); + const skills = listSkillDirs(source).filter((name) => allowed.has(name)); + if (skills.length === 0) return { source, mirrored: [] }; + + const mirrored: { agent: string; dir: string }[] = []; + for (const { agent, base, sub } of AGENT_GLOBAL_DIRS) { + const targetDir = join(bases[base], ...sub.split("/").filter(Boolean)); + if (targetDir === source || targetDir === universalStore) continue; // install-owned + if (!existsSync(dirname(targetDir))) continue; // agent not installed (no marker) + if (mirrorInto(targetDir, source, skills, platform)) mirrored.push({ agent, dir: targetDir }); + } + return { source, mirrored }; +} diff --git a/packages/cli/src/utils/skillsTargets.test.ts b/packages/cli/src/utils/skillsTargets.test.ts deleted file mode 100644 index 5d4f6fcbb2..0000000000 --- a/packages/cli/src/utils/skillsTargets.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -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 `/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", - ]); - }); -}); diff --git a/packages/cli/src/utils/skillsTargets.ts b/packages/cli/src/utils/skillsTargets.ts deleted file mode 100644 index 1e12b38e35..0000000000 --- a/packages/cli/src/utils/skillsTargets.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Decide WHICH agents a `skills add` should install to, so HyperFrames never -// sprays its skills into every one of the ~70 agent conventions the upstream -// `skills` CLI knows about (its `--all` is shorthand for `--agent '*'`). -// -// The policy, in priority order: -// 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 — honour it exactly, add nothing else. -// 2. Otherwise (blank project), pick targets from the machine: -// 2a. Running under Claude Code (`CLAUDECODE`) → just claude-code. -// 2b. Else probe the PATH for installed agent CLIs (the gstack approach: -// an executable on PATH means that agent is actually installed here). -// 2c. Else fall back to the floor: claude-code + the shared `.agents` -// universal dir (which Cursor, Codex, OpenCode, Gemini, Copilot and a -// dozen others read from in project scope). -// -// All paths are PROJECT-scoped (the default for `skills add` without `--global`), -// which is why the dir map below is the project-scope layout. - -import { existsSync, statSync } from "node:fs"; -import { delimiter, join } from "node:path"; - -/** - * Project-scope host directory → the upstream `skills` `--agent` key that - * installs into it. The dir name deliberately differs from the key for several - * agents (`.factory` ↔ `droid`, `.hermes` ↔ `hermes-agent`), and many agents - * share the `.agents` universal dir, so the mapping is explicit rather than - * derived. Keys verified against vercel-labs/skills@v1.5.13. - */ -const DIR_TO_KEY: Readonly> = { - ".claude": "claude-code", - ".agents": "universal", - ".hermes": "hermes-agent", - ".factory": "droid", - ".kiro": "kiro-cli", -}; - -/** - * Agent CLIs we probe for on PATH, paired with the project-scope host dir each - * installs into. Several (Cursor, Codex, OpenCode, Gemini) share `.agents`, so - * detecting any of them resolves — via DIR_TO_KEY — to the single `universal` - * key and one write to `.agents/skills`. OpenClaw is intentionally absent: its - * project skills dir is a bare `skills/`, which collides with common project - * layouts, so we never auto-target it (an existing folder or explicit `--agent` - * still works upstream). - */ -const DETECTABLE: ReadonlyArray<{ bin: string; dir: string }> = [ - { bin: "claude", dir: ".claude" }, - { bin: "hermes", dir: ".hermes" }, - { bin: "droid", dir: ".factory" }, - { bin: "cursor", dir: ".agents" }, - { bin: "codex", dir: ".agents" }, - { bin: "opencode", dir: ".agents" }, - { bin: "gemini", dir: ".agents" }, -]; - -export interface ResolveTargetsInput { - /** Project root the install targets (cwd, or the init destination). */ - cwd: string; - /** Process env — read for the `CLAUDECODE` signal. */ - env: NodeJS.ProcessEnv; - /** PATH string for the on-PATH binary probe. */ - pathStr: string; - /** Platform — selects the executable extensions probed on Windows. */ - platform: NodeJS.Platform; -} - -export interface ResolvedTargets { - /** Upstream `--agent` keys to install to (never `'*'`). */ - agents: string[]; - /** Short human-readable explanation of why these were chosen. */ - reason: string; -} - -function isDir(path: string): boolean { - try { - return existsSync(path) && statSync(path).isDirectory(); - } catch { - return false; - } -} - -/** True if `bin` resolves on any PATH entry (Windows also tries .exe/.cmd/.bat). */ -function isOnPath(bin: string, pathStr: string, platform: NodeJS.Platform): boolean { - const exts = platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""]; - for (const dir of pathStr.split(delimiter)) { - if (!dir) continue; - for (const ext of exts) { - try { - if (existsSync(join(dir, bin + ext))) return true; - } catch { - // Unreadable PATH entry — skip it. - } - } - } - return false; -} - -/** Map a set of host dirs to deduped `--agent` keys, claude-code first (DIR_TO_KEY order). */ -function keysForDirs(dirs: ReadonlySet): string[] { - return Object.entries(DIR_TO_KEY) - .filter(([dir]) => dirs.has(dir)) - .map(([, key]) => key); -} - -/** Agent skill folders that already exist under the project root. */ -function existingProjectAgents(cwd: string): string[] { - const dirs = new Set(); - for (const dir of Object.keys(DIR_TO_KEY)) { - if (isDir(join(cwd, dir, "skills"))) dirs.add(dir); - } - return keysForDirs(dirs); -} - -/** Agent CLIs installed on this machine (by PATH probe), as `--agent` keys. */ -function detectInstalledAgents(pathStr: string, platform: NodeJS.Platform): string[] { - const dirs = new Set(); - for (const { bin, dir } of DETECTABLE) { - if (isOnPath(bin, pathStr, platform)) dirs.add(dir); - } - return keysForDirs(dirs); -} - -/** - * Resolve the `--agent` targets for an install. See the file header for the - * full policy. Pure: all inputs are passed in, so it is fully unit-testable. - */ -export function resolveAgentTargets(input: ResolveTargetsInput): ResolvedTargets { - // 1. Honour what the project already has — nothing more. - const existing = existingProjectAgents(input.cwd); - if (existing.length > 0) { - return { agents: existing, reason: `existing project skill folders (${existing.join(", ")})` }; - } - - // 2a. Strongest live signal: the agent running this command. - if (input.env["CLAUDECODE"]) { - return { agents: ["claude-code"], reason: "running under Claude Code" }; - } - - // 2b. gstack approach: agent CLIs actually installed on this machine. - const detected = detectInstalledAgents(input.pathStr, input.platform); - if (detected.length > 0) { - return { agents: detected, reason: `installed agent CLIs (${detected.join(", ")})` }; - } - - // 2c. Floor: Claude Code + the shared `.agents` universal dir. Never `--agent '*'`. - return { agents: ["claude-code", "universal"], reason: "default (.claude + .agents)" }; -} - -/** - * Build the `skills add` arguments for a resolved target set: every skill - * (`--skill '*'`) to the chosen agents only, non-interactive. This replaces the - * upstream `--all` (= `--skill '*' --agent '*' -y`) so the agent fan-out is - * scoped instead of universal. - */ -export function buildSkillsAddArgs(agents: string[]): string[] { - return ["--skill", "*", "--agent", ...agents, "--yes"]; -} diff --git a/skills-manifest.json b/skills-manifest.json index 394cf9c845..8296669ac4 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,11 +2,11 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "d5561ac019e72ed5", + "hash": "7fa25370f9fbb4b3", "files": 144 }, "faceless-explainer": { - "hash": "e50186bf73e5092e", + "hash": "8f89def06adfe745", "files": 17 }, "general-video": { @@ -14,7 +14,7 @@ "files": 1 }, "hyperframes": { - "hash": "00ad363cda72268d", + "hash": "1b35d1424ca18261", "files": 1 }, "hyperframes-animation": { @@ -22,7 +22,7 @@ "files": 115 }, "hyperframes-cli": { - "hash": "ed24d781c2ae462b", + "hash": "4eda382550997fe8", "files": 7 }, "hyperframes-core": { @@ -46,19 +46,19 @@ "files": 19 }, "motion-graphics": { - "hash": "5a256811f730f36e", + "hash": "be1d1f159d5eb0e4", "files": 23 }, "music-to-video": { - "hash": "abea9101f4322954", + "hash": "c188d0d159b926c2", "files": 132 }, "pr-to-video": { - "hash": "f8f7691af263bf03", + "hash": "f2014a51c7a052bd", "files": 21 }, "product-launch-video": { - "hash": "dedfc10e340df06c", + "hash": "5341508d185c091d", "files": 18 }, "remotion-to-hyperframes": { diff --git a/skills/embedded-captions/SKILL.md b/skills/embedded-captions/SKILL.md index 2cabe9f7bc..45df255e20 100644 --- a/skills/embedded-captions/SKILL.md +++ b/skills/embedded-captions/SKILL.md @@ -105,7 +105,7 @@ Read the samples. Refuse if: ## Pipeline — 5 steps ``` -1. hyperframes init --non-interactive --video --skip-skills +1. hyperframes init --non-interactive --video 2. bash scripts/prepare.sh # matte ∥ transcribe (parallel) → safe-zones. One command. # → frames_fg/ transcript.json safe-zones.json 3. [AGENT STEP — the only creative step] author a small JSON; see below by mode @@ -117,6 +117,8 @@ Read the samples. Refuse if: + _postfx.sh; the deliverable is final_fx.mp4, final.mp4 is pre-plate-reaction) ``` +Step 1's `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. + Step 3 differs by mode: ### Step 3 — Cinematic mode (pure embed) diff --git a/skills/embedded-captions/references/bespoke-vs-presets.md b/skills/embedded-captions/references/bespoke-vs-presets.md index 6e52516dfb..d6f8fd4406 100644 --- a/skills/embedded-captions/references/bespoke-vs-presets.md +++ b/skills/embedded-captions/references/bespoke-vs-presets.md @@ -135,7 +135,7 @@ For a new video that's clearly similar to an existing canonical example: ```bash # 1. Scaffold the project -hyperframes init --non-interactive --video --skip-skills +hyperframes init --non-interactive --video # 2. Matte + transcribe node scripts/matte.cjs diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index a14f15c18d..28b84de747 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -23,7 +23,7 @@ Goal: Lock the core video brief and create the HyperFrames project if needed. Initialize only if `hyperframes.json` is missing. Name `` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp. -`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` +`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../hyperframes-media` → Preflight for the canonical guidance. diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index f84776d0da..f893d9fd56 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -9,7 +9,7 @@ Everything runs through `npx hyperframes` unless project instructions specify a ## Workflow -1. **Scaffold** — `npx hyperframes init my-video` (or `capture` from a URL) +1. **Scaffold** — `npx hyperframes init my-video` (or `capture` from a URL). `init` also checks the installed skills against the latest on GitHub and updates the global set if any are out of date — keep it on (don't pass `--skip-skills`) so each new project pulls our latest skills. 2. **Write** — author HTML composition (see the `hyperframes-core` skill) 3. **Lint** — `npx hyperframes lint` 4. **Validate** — `npx hyperframes validate` (runtime errors + contrast) diff --git a/skills/hyperframes/SKILL.md b/skills/hyperframes/SKILL.md index 409efb5bde..3845b785c1 100644 --- a/skills/hyperframes/SKILL.md +++ b/skills/hyperframes/SKILL.md @@ -85,7 +85,7 @@ After they run it, re-read the workflow's skill and continue. ## Keeping skills current -HyperFrames skills are versioned. `npx hyperframes init` checks the installed skills against the latest on GitHub and installs/refreshes the **full** set whenever anything is out of date or missing — so a freshly init'd project always has the complete, latest set (and re-running init on an up-to-date project is a no-op). The check is a quick GitHub round-trip; offline (or rate-limited) it falls back to installing after a short timeout, so init never hard-fails on a network hiccup. Opt out entirely with `init --skip-skills`. +HyperFrames skills are versioned. `npx hyperframes init` checks the installed skills against the latest on GitHub and installs/refreshes the **full** set whenever anything is out of date or missing — so a freshly init'd project always has the complete, latest set (and re-running init on an up-to-date project is a no-op). The check is a quick GitHub round-trip; offline (or rate-limited) it falls back to installing after a short timeout, so init never hard-fails on a network hiccup. The creation workflows scaffold with `init` (no `--skip-skills`), so starting a new project always runs this check and pulls our latest skills from GitHub when they're stale. Opt out only by adding `--skip-skills`. If a task is behaving unexpectedly, or before a long build, confirm the installed skills are current: diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 39fba133b8..a9593aef28 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -83,9 +83,11 @@ Only when `$PROJECT_DIR/hyperframes.json` is absent: ```bash PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/}" mkdir -p "$(dirname "$PROJECT_DIR")" -npx hyperframes init "$PROJECT_DIR" --non-interactive --skip-skills --example=blank +npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank ``` +`init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. + **Constraints:** never `hyperframes init` in the workspace root; never nest another `hyperframes/` inside `PROJECT_DIR`; every Bash command (master + subagents) is a `(cd "$PROJECT_DIR" && ...)` subshell — never bare `cd`. ### Step 1 — Plan (subagent: Director Part 1) diff --git a/skills/music-to-video/SKILL.md b/skills/music-to-video/SKILL.md index c37e46468e..e7f28c5281 100644 --- a/skills/music-to-video/SKILL.md +++ b/skills/music-to-video/SKILL.md @@ -26,10 +26,10 @@ Goal: Establish the music source, create the HyperFrames project, and note any u The **music is the spine** — establish one track before anything else. This skill is tuned for **fast, high-energy BGM**: a strong beat grid drives the cuts (calm tracks work, but pace by phrase rather than beat). If the user gave you audio — a music file, or a video to pull the audio from — use it. If not, generate one: choose the mood from the user's description (e.g. "driving synthwave", "trap beat", "upbeat corporate") and produce a track via `/hyperframes-media` (`references/bgm.md` — HeyGen retrieval when credentialed, else local Lyria / MusicGen; ElevenLabs or another generator also works). Before generating, run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it)** — it shows whether BGM comes from HeyGen or local MusicGen and, if not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or continue offline with local MusicGen — before generating the track**; don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `/hyperframes-media` → Preflight for the canonical guidance. Either way the track lands at `assets/bgm.mp3`. Stage any user-supplied images or videos so frames can weave them in on the beat grid; otherwise typography carries the whole video. -Initialize only if `hyperframes.json` is missing. Name `` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. +Initialize only if `hyperframes.json` is missing. Name `` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. ```bash -npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank +npx hyperframes init "videos/" --non-interactive --example=blank mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders" cp "" "$PROJECT_DIR/assets/bgm.mp3" # extract from a video first if needed # only if the user gave you images/videos: diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index ba6c0f1454..dc51b00e31 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -42,7 +42,7 @@ State the basis in one phrase when you propose it (e.g. "~40s — small change, Initialize only if `hyperframes.json` is missing. Name `` from the PR in kebab-case, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp. -`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` +`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../hyperframes-media` → Preflight for the canonical guidance. diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 98e88e7407..b1038c7900 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -23,7 +23,7 @@ Goal: Lock the core video brief and create the HyperFrames project if needed. Initialize only if `hyperframes.json` is missing. Name `` from the brand or domain in kebab-case, such as `acme-promo`; never use workspace name or timestamp. -`npx hyperframes init "videos/" --non-interactive --skip-skills --example=blank` +`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. **Show sign-in status before the brief** — run `npx hyperframes auth status` and **relay its output verbatim (don't paraphrase or rewrite it).** It reports whether voice/BGM will use HeyGen or local engines and, when not signed in, how to sign in. **If not signed in, STOP and wait for the user to choose — sign in, or say "go"/"offline" to continue with local engines — before asking the brief or anything else.** Treat it as a real decision point, not a passing note; don't fold the choice into the brief question, and don't write keys into a per-repo `.env`. (In autonomous mode, note the status and continue offline.) See `../hyperframes-media` → Preflight for the canonical guidance.