feat(cli): add skills install command and init integration (#48)

* fix(ci): publish CLI from temp copy to avoid workspace mutation

Copy packages/cli to a temp directory before renaming to "hyperframes"
for publish. Avoids corrupting the workspace if the job fails mid-way.

Addresses review feedback on #47.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): resolve leftover conflict markers in publish.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): add skills install command

Adds `hyperframes skills install` to download and install HyperFrames
and GSAP skills globally to ~/.claude/skills/. Also adds
`hyperframes skills list` to show installed skills.

- HyperFrames skills: bundled in CLI dist, copied from dist/skills/
- GSAP skills: cloned from github.com/greensock/gsap-skills
- Cache: ~/.cache/hyperframes/gsap-skills/ (shallow clone, updated on install)
- Handles overwriting existing skills (removes before copy)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(cli): simplify skills to flat command

`hyperframes skills` directly installs + shows summary.
No subcommands needed — list was redundant since install
already prints all installed skills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): scope skills summary to only HyperFrames and GSAP skills

The summary now only lists skills installed by this command, grouped
by source (HyperFrames vs GSAP), instead of listing everything in
~/.claude/skills/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): correct dev path for bundled skills directory

Path needed 4 levels up from cli/src/commands/ to reach repo root,
not 3. Was resolving to packages/.claude/skills/ instead of
.claude/skills/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): support multiple AI coding tools for skills install

Install to Claude Code, Gemini CLI, and Codex CLI by default.
Use flags to target specific tools:

  hyperframes skills              # claude + gemini + codex
  hyperframes skills --cursor     # cursor only (project-level)
  hyperframes skills --claude     # claude only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): move skills to repo root, support multi-CLI install

- Move skills from .claude/skills/ to skills/ (tool-agnostic location)
- Install to Claude Code, Gemini CLI, Codex CLI by default
- Add --claude, --gemini, --codex, --cursor flags for targeting specific tools
- Update build script to copy from skills/ instead of .claude/skills/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add infographics skill for data visualization compositions

Professional infographic design and animation patterns:
- Typography hierarchy (hero stat, label, context)
- Layout rules (grid-aligned, generous whitespace, 2-color max)
- 5 infographic types: single stat, comparison, bar chart, progress, steps
- Animation patterns: count-up, bar growth, entrance choreography, exits
- Narration sync (stat appears when narrator says the number)
- Design constraints (no gradients, no shadows, no clip art)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add evals for infographics skill

Three eval scenarios testing design quality and animation correctness:
1. Single stat — count-up animation synced to narration
2. Comparison — before/after reveal with visual hierarchy
3. Process steps — sequential reveal with dimming

Each eval has PASS/FAIL criteria covering: composition structure,
design rules (typography, layout, color), animation patterns
(timing, easing, choreography), and anti-patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove infographics and transitions skills, add asset-management

Removed skills that don't add value beyond model knowledge:
- infographics: model already produces equivalent output without it
- transitions: patterns are derivable from compose-video constraints

Added:
- asset-management: organize user-uploaded files into assets/ and fonts/
- Typography section in compose-video: min font sizes, font loading
  (Google Fonts + local @font-face), weight pairing, font-display:block
- Assets section in compose-video: project structure with assets/ and
  fonts/ directories, path rules, CORS, "check before creating" rule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: consolidate skills to 3, split compose-video under 500 lines

Removed skills that don't add value beyond model knowledge:
- media: duplicates compose-video
- social-media: platform safe areas are the only unique content
- infographics: model produces equivalent output without it
- transitions: patterns derivable from compose-video

Remaining skills (3):
- compose-video: core framework contract (452 lines, under 500 limit)
  - patterns.md: PiP, title card, slideshow examples (loaded on demand)
  - typography-and-assets.md: font loading, sizes, asset paths (on demand)
- captions: tone-adaptive caption styling from script analysis
- asset-management: organize uploaded files into project directories

Rewrote captions skill to focus on style detection from transcript
content (per-word styling, tone mapping) rather than mechanical rules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): create assets/ and fonts/ directories on init

- scaffoldProject now creates assets/ and fonts/ directories
- Video files are placed in assets/ instead of project root
- Template __VIDEO_SRC__ placeholders resolve to assets/filename

Aligns with the compose-video skill's project structure convention
where user-provided media goes in assets/ and fonts in fonts/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(cli): fetch skills from GitHub repos instead of bundling

Skills are now fetched directly from their source repos at runtime:
- HyperFrames skills: github.com/heygen-com/hyperframes (skills/ dir)
- GSAP skills: github.com/greensock/gsap-skills (skills/ dir)

Both cached in ~/.cache/hyperframes/ and updated on each run.
Removed skills bundling from build:copy step.

Requires the hyperframes repo to be public for HyperFrames skills
to install. GSAP skills work immediately (public repo).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): install skills automatically during init

After scaffolding a new project, `hyperframes init` now runs
`hyperframes skills` to install HyperFrames and GSAP skills.
Best-effort — if skill installation fails (no git, no network),
project creation still succeeds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): show skills install feedback during init

Added installAllSkills() export for programmatic use by init.
Init now shows a spinner and result message:
  "11 AI skills installed (Claude Code, Gemini CLI, Codex CLI)"

Falls back gracefully if git or network unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(cli): let users select which AI tools to install skills for

Interactive init now shows a multi-select prompt:
  "Install AI coding skills for: Claude Code, Gemini CLI, Codex CLI, Cursor"
Users can deselect tools they don't use or add Cursor (off by default).
Non-interactive mode still installs to all default targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): prevent git credential prompt from hanging skills install

Set GIT_TERMINAL_PROMPT=0 so git clone/pull fails immediately on
private repos instead of hanging for username/password input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): show skipped skill sources and accurate counts

Skills install now reports which sources failed (e.g., private repo)
and only counts skills that actually installed. Prompt text simplified
to "Install skills for:".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): clean up skills command output

- Skipped sources shown as dim text, not error with full command
- Summary shows "Skipped: HyperFrames (repo not accessible)"
- Outro says "ready" not "installed"
- Shows "No skills installed" if everything failed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): clarify partial skill install in outro message

When some sources fail, the outro now says which skills are ready
and which are unavailable, instead of a misleading total count.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove planning docs and test scaffolding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove eval projects and test examples

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove remaining test project data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): use generic source names in skills install outro message

Replace hardcoded "GSAP skills ready. HyperFrames skills unavailable."
with a dynamic message listing which sources succeeded and which were
skipped, so the message stays correct as sources change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): address review feedback on skills install

- Lazy process.cwd() for Cursor target (getter, not module-load)
- Track overwritten skills (logged in install output)
- Add --skip-skills flag to init for agent-friendly non-interactive use
- Guard both interactive and non-interactive paths with skipSkills

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): remove assets/ and fonts/ directories from init

Video files go to project root, not assets/. Removes assetsDir,
fonts/ directory creation, and assets/ prefix from video path.
Flat project root convention consistent with compose-video skill.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): address review items 5, 6, 13 — execFileSync, resilient cache, clear counting

- Replace all execSync with execFileSync for git commands (prevent injection)
- On git pull failure, reuse stale cache if skills dir exists instead of nuking
- Extract gitClone() helper for consistent clone calls
- Use explicit counted flag instead of confusing target === targets[0]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-03-26 11:01:27 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 71e00b4533
commit 5fceab9279
3 changed files with 397 additions and 0 deletions
+1
View File
@@ -24,6 +24,7 @@ const subCommands = {
compositions: () => import("./commands/compositions.js").then((m) => m.default),
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
skills: () => import("./commands/install-skills.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
+72
View File
@@ -16,6 +16,68 @@ import { c } from "../ui/colors.js";
import { TEMPLATES, type TemplateId } from "../templates/generators.js";
import { trackInitTemplate } from "../telemetry/events.js";
// ---------------------------------------------------------------------------
// Install skills silently after scaffolding
// ---------------------------------------------------------------------------
async function installSkills(interactive: boolean): Promise<void> {
try {
const { installAllSkills, TARGETS } = await import("./install-skills.js");
let selectedTargets: string[] | undefined;
if (interactive) {
const choices = await clack.multiselect({
message: "Install skills for:",
options: TARGETS.map((t) => ({
value: t.flag,
label: t.name,
hint: t.dir,
})),
initialValues: TARGETS.filter((t) => t.defaultEnabled).map((t) => t.flag),
required: false,
});
if (clack.isCancel(choices)) {
return;
}
selectedTargets = choices as string[];
if (selectedTargets.length === 0) {
clack.log.info(c.dim("Skipping skills installation"));
return;
}
}
const spin = interactive ? clack.spinner() : null;
spin?.start("Installing AI coding skills...");
const result = await installAllSkills(selectedTargets);
if (result.count > 0) {
const msg = `${result.count} skills installed (${result.targets.join(", ")})`;
if (spin) {
spin.stop(c.success(msg));
} else {
console.log(c.success(msg));
}
if (result.skipped.length > 0) {
const skipMsg = `Skipped: ${result.skipped.join(", ")} (repo not accessible)`;
if (interactive) {
clack.log.warn(c.dim(skipMsg));
} else {
console.log(c.dim(` ${skipMsg}`));
}
}
} else {
spin?.stop(c.dim("No skills installed"));
}
} catch {
if (interactive) {
clack.log.warn(c.dim("Skills install skipped (no git or network)"));
}
}
}
const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id);
interface VideoMeta {
@@ -343,10 +405,12 @@ export default defineCommand({
alias: "t",
},
video: { type: "string", description: "Path to a source video file", alias: "V" },
"skip-skills": { type: "boolean", description: "Skip AI skills installation" },
},
async run({ args }) {
const templateFlag = args.template;
const videoFlag = args.video;
const skipSkills = args["skip-skills"] === true;
// -----------------------------------------------------------------------
// Non-interactive mode: flags provided
@@ -382,6 +446,9 @@ export default defineCommand({
scaffoldProject(destDir, basename(destDir), templateId, localVideoName);
trackInitTemplate(templateId);
if (!skipSkills) {
await installSkills(false);
}
console.log(c.success(`\nCreated ${c.accent(name + "/")}`));
for (const f of readdirSync(destDir)) {
@@ -503,6 +570,11 @@ export default defineCommand({
scaffoldProject(destDir, name, templateId, localVideoName);
trackInitTemplate(templateId);
// 5. Install AI coding skills
if (!skipSkills) {
await installSkills(true);
}
const files = readdirSync(destDir);
clack.note(files.map((f) => c.accent(f)).join("\n"), c.success(`Created ${name}/`));
+324
View File
@@ -0,0 +1,324 @@
import { defineCommand } from "citty";
import { existsSync, mkdirSync, readdirSync, rmSync, cpSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { execFileSync } from "node:child_process";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
// ---------------------------------------------------------------------------
// Target CLI tools — each has a global skills directory
// ---------------------------------------------------------------------------
interface Target {
name: string;
flag: string;
dir: string;
defaultEnabled: boolean;
}
const TARGETS: Target[] = [
{
name: "Claude Code",
flag: "claude",
dir: join(homedir(), ".claude", "skills"),
defaultEnabled: true,
},
{
name: "Gemini CLI",
flag: "gemini",
dir: join(homedir(), ".gemini", "skills"),
defaultEnabled: true,
},
{
name: "Codex CLI",
flag: "codex",
dir: join(homedir(), ".codex", "skills"),
defaultEnabled: true,
},
{
name: "Cursor",
flag: "cursor",
get dir() {
return join(process.cwd(), ".cursor", "skills");
},
defaultEnabled: false,
},
];
// ---------------------------------------------------------------------------
// Skill sources — all fetched from GitHub
// ---------------------------------------------------------------------------
interface SkillSource {
name: string;
repo: string;
/** Subdirectory within the repo that contains skill folders */
skillsPath: string;
cache: string;
}
const SOURCES: SkillSource[] = [
{
name: "HyperFrames",
repo: "https://github.com/heygen-com/hyperframes.git",
skillsPath: "skills",
cache: join(homedir(), ".cache", "hyperframes", "hyperframes-skills"),
},
{
name: "GSAP",
repo: "https://github.com/greensock/gsap-skills.git",
skillsPath: "skills",
cache: join(homedir(), ".cache", "hyperframes", "gsap-skills"),
},
];
// ---------------------------------------------------------------------------
// Git helpers
// ---------------------------------------------------------------------------
function hasGit(): boolean {
try {
execFileSync("git", ["--version"], { stdio: "ignore", timeout: 5000 });
return true;
} catch {
return false;
}
}
// Suppress git credential prompts — fail fast instead of hanging
const GIT_ENV = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
function gitClone(repo: string, dest: string): void {
execFileSync("git", ["clone", "--depth", "1", repo, dest], {
stdio: "ignore",
timeout: 60_000,
env: GIT_ENV,
});
}
function fetchRepo(source: SkillSource): string {
if (existsSync(source.cache)) {
try {
execFileSync("git", ["pull", "--ff-only"], {
cwd: source.cache,
stdio: "ignore",
timeout: 30_000,
env: GIT_ENV,
});
} catch {
// Pull failed — use stale cache if valid
const skillsDir = join(source.cache, source.skillsPath);
if (existsSync(skillsDir)) {
return skillsDir;
}
// Cache is broken — re-clone
rmSync(source.cache, { recursive: true, force: true });
gitClone(source.repo, source.cache);
}
} else {
mkdirSync(dirname(source.cache), { recursive: true });
gitClone(source.repo, source.cache);
}
return join(source.cache, source.skillsPath);
}
// ---------------------------------------------------------------------------
// Install logic
// ---------------------------------------------------------------------------
interface InstalledSkill {
name: string;
source: string;
overwritten: boolean;
}
function installSkillsFromDir(
sourceDir: string,
targetDir: string,
sourceName: string,
): InstalledSkill[] {
const installed: InstalledSkill[] = [];
if (!existsSync(sourceDir)) return installed;
const entries = readdirSync(sourceDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillFile = join(sourceDir, entry.name, "SKILL.md");
if (!existsSync(skillFile)) continue;
const destDir = join(targetDir, entry.name);
const overwritten = existsSync(destDir);
if (overwritten) rmSync(destDir, { recursive: true, force: true });
mkdirSync(destDir, { recursive: true });
cpSync(join(sourceDir, entry.name), destDir, { recursive: true });
installed.push({ name: entry.name, source: sourceName, overwritten });
}
return installed;
}
// ---------------------------------------------------------------------------
// Programmatic API — used by init command
// ---------------------------------------------------------------------------
export { TARGETS };
export async function installAllSkills(
targetNames?: string[],
): Promise<{ count: number; targets: string[]; skipped: string[] }> {
if (!hasGit()) return { count: 0, targets: [], skipped: SOURCES.map((s) => s.name) };
const targets = targetNames
? TARGETS.filter((t) => targetNames.includes(t.flag))
: TARGETS.filter((t) => t.defaultEnabled);
let totalCount = 0;
const skipped: string[] = [];
// Fetch sources
const fetched: { source: SkillSource; skillsDir: string }[] = [];
for (const source of SOURCES) {
try {
const skillsDir = fetchRepo(source);
if (existsSync(skillsDir)) {
fetched.push({ source, skillsDir });
} else {
skipped.push(source.name);
}
} catch {
skipped.push(source.name);
}
}
// Install to first target and count, then install to remaining targets
const [firstTarget, ...remainingTargets] = targets;
if (firstTarget) {
mkdirSync(firstTarget.dir, { recursive: true });
for (const { skillsDir, source } of fetched) {
const skills = installSkillsFromDir(skillsDir, firstTarget.dir, source.name);
totalCount += skills.length;
}
}
for (const target of remainingTargets) {
mkdirSync(target.dir, { recursive: true });
for (const { skillsDir, source } of fetched) {
installSkillsFromDir(skillsDir, target.dir, source.name);
}
}
return { count: totalCount, targets: targets.map((t) => t.name), skipped };
}
// ---------------------------------------------------------------------------
// Command
// ---------------------------------------------------------------------------
function resolveTargets(args: Record<string, unknown>): Target[] {
const hasAnyFlag = TARGETS.some((t) => args[t.flag] === true);
if (hasAnyFlag) {
return TARGETS.filter((t) => args[t.flag] === true);
}
return TARGETS.filter((t) => t.defaultEnabled);
}
async function runInstall({ args }: { args: Record<string, unknown> }): Promise<void> {
clack.intro(c.bold("hyperframes skills"));
if (!hasGit()) {
clack.log.error(c.error("git is required to install skills. Install git and retry."));
clack.outro(c.warn("No skills installed."));
return;
}
const targets = resolveTargets(args);
// 1. Fetch all skill sources
const fetched: { source: SkillSource; skillsDir: string }[] = [];
for (const source of SOURCES) {
const spinner = clack.spinner();
spinner.start(`Fetching ${source.name} skills...`);
try {
const skillsDir = fetchRepo(source);
if (existsSync(skillsDir)) {
fetched.push({ source, skillsDir });
spinner.stop(c.success(`${source.name} skills fetched`));
} else {
spinner.stop(c.warn(`${source.name}: no skills directory found`));
}
} catch {
spinner.stop(c.dim(`${source.name} skills skipped (repo not accessible)`));
}
}
// 2. Install to each target
const allInstalled: InstalledSkill[] = [];
let counted = false;
for (const target of targets) {
const spinner = clack.spinner();
spinner.start(`Installing to ${target.name}...`);
mkdirSync(target.dir, { recursive: true });
let count = 0;
for (const { source, skillsDir } of fetched) {
const skills = installSkillsFromDir(skillsDir, target.dir, source.name);
count += skills.length;
if (!counted) allInstalled.push(...skills);
}
counted = true;
spinner.stop(c.success(`${count} skills → ${target.name} ${c.dim(target.dir)}`));
}
// 3. Summary
console.log();
for (const source of SOURCES) {
const names = allInstalled.filter((s) => s.source === source.name).map((s) => s.name);
if (names.length > 0) {
const label = `${source.name}:`.padEnd(14);
console.log(` ${c.dim(label)} ${names.map((s) => c.accent(s)).join(", ")}`);
}
}
console.log(` ${c.dim("Targets:")} ${targets.map((t) => t.name).join(", ")}`);
console.log();
const skippedSources = SOURCES.filter((s) => !fetched.some((f) => f.source.name === s.name));
if (skippedSources.length > 0) {
console.log(
` ${c.dim("Skipped:")} ${skippedSources.map((s) => s.name).join(", ")} (repo not accessible)`,
);
}
console.log();
if (allInstalled.length > 0 && skippedSources.length > 0) {
const readySources = fetched.map((f) => f.source.name).join(", ");
const skippedNames = skippedSources.map((s) => s.name).join(", ");
clack.outro(
c.warn(
`${allInstalled.length} skills ready (${readySources}). Unavailable: ${skippedNames}.`,
),
);
} else if (allInstalled.length > 0) {
clack.outro(c.success(`${allInstalled.length} skills ready.`));
} else {
clack.outro(c.warn("No skills installed."));
}
}
export default defineCommand({
meta: {
name: "skills",
description: "Install HyperFrames and GSAP skills for AI coding tools",
},
args: {
claude: { type: "boolean", description: "Install to Claude Code (~/.claude/skills/)" },
gemini: { type: "boolean", description: "Install to Gemini CLI (~/.gemini/skills/)" },
codex: { type: "boolean", description: "Install to Codex CLI (~/.codex/skills/)" },
cursor: {
type: "boolean",
description: "Install to Cursor (.cursor/skills/ in current project)",
},
},
run: runInstall,
});