diff --git a/skillhub-cli/src/commands/install.ts b/skillhub-cli/src/commands/install.ts index 8ca71a06..a20d9ef6 100644 --- a/skillhub-cli/src/commands/install.ts +++ b/skillhub-cli/src/commands/install.ts @@ -8,7 +8,7 @@ import { loadConfig } from "../core/config.js"; import { readToken } from "../core/auth-token.js"; import { discoverSkills } from "../core/skill-discovery.js"; import { installSkill } from "../core/installer.js"; -import { getAllAgents, detectInstalledAgents, getUniversalAgents, getNonUniversalAgents, isUniversalAgent } from "../core/agent-detector.js"; +import { getAllAgents, detectInstalledAgents, getUniversalAgents, getNonUniversalAgents, isUniversalForScope, getAgentTargetDir, type AgentInfo } from "../core/agent-detector.js"; import { parseSource, getCloneUrl } from "../core/source-parser.js"; import { addToLock } from "../core/skill-lock.js"; import { success, error, info, dim } from "../utils/logger.js"; @@ -107,10 +107,10 @@ async function selectInstallMode(): Promise<"symlink" | "copy" | null> { return result as "symlink" | "copy"; } -function buildAgentSummary(targetAgents: { key: string; name: string; skillsDir: string }[], mode: "symlink" | "copy"): string[] { +function buildAgentSummary(targetAgents: AgentInfo[], mode: "symlink" | "copy", isGlobal: boolean): string[] { const lines: string[] = []; - const universal = targetAgents.filter((a) => isUniversalAgent(a)); - const symlinked = targetAgents.filter((a) => !isUniversalAgent(a)); + const universal = targetAgents.filter((a) => isUniversalForScope(a, isGlobal)); + const symlinked = targetAgents.filter((a) => !isUniversalForScope(a, isGlobal)); if (mode === "symlink") { if (universal.length > 0) { @@ -400,9 +400,7 @@ async function installFromRegistry(slug: string, opts: Record - isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir - )); + const uniqueDirs = new Set(targetAgents.map((a) => getAgentTargetDir(a, isGlobal))); if (uniqueDirs.size <= 1) { // Single target directory — default to copy (no symlink needed) @@ -425,7 +423,7 @@ async function installFromRegistry(slug: string, opts: Record - isGlobal ? (a.globalSkillsDir || a.skillsDir) : a.skillsDir - )); + const uniqueDirs = new Set(targetAgents.map((a) => getAgentTargetDir(a, isGlobal))); if (uniqueDirs.size <= 1) { // Single target directory — default to copy (no symlink needed) @@ -684,7 +681,7 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou ? `~/.agents/skills/${skill.name}` : `./.agents/skills/${skill.name}`; summaryLines.push(`${pc.cyan(canonicalPath)}`); - for (const line of buildAgentSummary(targetAgents, mode)) { + for (const line of buildAgentSummary(targetAgents, mode, isGlobal)) { summaryLines.push(` ${line}`); } } @@ -716,9 +713,10 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou skill.dir, skill.name, agent.key, - isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir, + getAgentTargetDir(agent, isGlobal), mode, isGlobal, + agent, ); results.push({ skill: skill.name, diff --git a/skillhub-cli/src/commands/list.ts b/skillhub-cli/src/commands/list.ts index 8b24796b..7a4acaba 100644 --- a/skillhub-cli/src/commands/list.ts +++ b/skillhub-cli/src/commands/list.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { existsSync, readdirSync, lstatSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { getAllAgents, isUniversalAgent, getUniversalAgents, getNonUniversalAgents } from "../core/agent-detector.js"; +import { getAllAgents, getUniversalAgents, getNonUniversalAgents } from "../core/agent-detector.js"; import { info, dim } from "../utils/logger.js"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; import * as p from "@clack/prompts"; diff --git a/skillhub-cli/src/commands/uninstall.ts b/skillhub-cli/src/commands/uninstall.ts index a7249b6e..0675c2c7 100644 --- a/skillhub-cli/src/commands/uninstall.ts +++ b/skillhub-cli/src/commands/uninstall.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { existsSync, readdirSync, statSync, unlinkSync, rmdirSync, lstatSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -import { getAllAgents, isUniversalAgent, getUniversalAgents, getNonUniversalAgents, type AgentInfo } from "../core/agent-detector.js"; +import { getAllAgents, isUniversalForScope, getUniversalAgents, getNonUniversalAgents, type AgentInfo } from "../core/agent-detector.js"; import { success, info, dim } from "../utils/logger.js"; import { removeFromLock } from "../core/skill-lock.js"; import { searchMultiselect, cancelSymbol } from "../utils/search-multiselect.js"; @@ -34,7 +34,7 @@ async function uninstallSkill( let baseDir: string; if (scope === "global") { - if (isUniversalAgent(agent)) { + if (isUniversalForScope(agent, true)) { baseDir = join(home, ".agents/skills"); } else { baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); @@ -65,7 +65,7 @@ function getSkillPath(skillName: string, agent: AgentInfo, scope: "global" | "lo let baseDir: string; if (scope === "global") { - if (isUniversalAgent(agent)) { + if (isUniversalForScope(agent, true)) { baseDir = join(home, ".agents/skills"); } else { baseDir = join(home, agent.globalSkillsDir || agent.skillsDir); diff --git a/skillhub-cli/src/core/agent-detector.ts b/skillhub-cli/src/core/agent-detector.ts index 9ba153aa..e3a67f5b 100644 --- a/skillhub-cli/src/core/agent-detector.ts +++ b/skillhub-cli/src/core/agent-detector.ts @@ -7,15 +7,19 @@ export interface AgentInfo { name: string; skillsDir: string; globalSkillsDir?: string; + /** Whether to show this agent in the Universal section of interactive prompts. + * Agents with skillsDir === ".agents/skills" are universal by default, + * but some (like "replit" for cloud environments) should be hidden. */ + showInUniversalList?: boolean; } const home = homedir(); const AGENTS: AgentInfo[] = [ - // Universal agents (.agents/skills) + // Universal agents (.agents/skills) — share the canonical .agents/skills directory { key: "amp", name: "Amp", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" }, { key: "antigravity", name: "Antigravity", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/antigravity/skills" }, - { key: "cline", name: "Cline", skillsDir: ".agents/skills" }, + { key: "cline", name: "Cline", skillsDir: ".agents/skills", globalSkillsDir: ".agents/skills" }, { key: "codex", name: "Codex", skillsDir: ".agents/skills", globalSkillsDir: ".codex/skills" }, { key: "cursor", name: "Cursor", skillsDir: ".agents/skills", globalSkillsDir: ".cursor/skills" }, { key: "deepagents", name: "Deep Agents", skillsDir: ".agents/skills", globalSkillsDir: ".deepagents/agent/skills" }, @@ -23,40 +27,44 @@ const AGENTS: AgentInfo[] = [ { key: "gemini-cli", name: "Gemini CLI", skillsDir: ".agents/skills", globalSkillsDir: ".gemini/skills" }, { key: "github-copilot", name: "GitHub Copilot", skillsDir: ".agents/skills", globalSkillsDir: ".copilot/skills" }, { key: "kimi-cli", name: "Kimi Code CLI", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills" }, - { key: "kilo", name: "Kilo Code", skillsDir: ".agents/skills", globalSkillsDir: ".kilocode/skills" }, - { key: "mux", name: "Mux", skillsDir: ".agents/skills" }, { key: "opencode", name: "OpenCode", skillsDir: ".agents/skills", globalSkillsDir: ".config/opencode/skills" }, - { key: "replit", name: "Replit", skillsDir: ".agents/skills" }, - { key: "warp", name: "Warp", skillsDir: ".agents/skills" }, + { key: "warp", name: "Warp", skillsDir: ".agents/skills", globalSkillsDir: ".agents/skills" }, + // Universal agents hidden from the interactive list + { key: "replit", name: "Replit", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills", showInUniversalList: false }, + { key: "universal", name: "Universal", skillsDir: ".agents/skills", globalSkillsDir: ".config/agents/skills", showInUniversalList: false }, - // Agent-specific path agents + // Agent-specific path agents (non-universal) { key: "claude-code", name: "Claude Code", skillsDir: ".claude/skills", globalSkillsDir: ".claude/skills" }, - { key: "augment", name: "Augment", skillsDir: ".augment/skills" }, - { key: "bob", name: "IBM Bob", skillsDir: ".bob/skills" }, + { key: "augment", name: "Augment", skillsDir: ".augment/skills", globalSkillsDir: ".augment/skills" }, + { key: "bob", name: "IBM Bob", skillsDir: ".bob/skills", globalSkillsDir: ".bob/skills" }, { key: "openclaw", name: "OpenClaw", skillsDir: "skills", globalSkillsDir: ".openclaw/skills" }, - { key: "codebuddy", name: "CodeBuddy", skillsDir: ".codebuddy/skills" }, - { key: "continue", name: "Continue", skillsDir: ".continue/skills" }, + { key: "codebuddy", name: "CodeBuddy", skillsDir: ".codebuddy/skills", globalSkillsDir: ".codebuddy/skills" }, + { key: "command-code", name: "Command Code", skillsDir: ".commandcode/skills", globalSkillsDir: ".commandcode/skills" }, + { key: "continue", name: "Continue", skillsDir: ".continue/skills", globalSkillsDir: ".continue/skills" }, { key: "cortex", name: "Cortex Code", skillsDir: ".cortex/skills", globalSkillsDir: ".snowflake/cortex/skills" }, { key: "crush", name: "Crush", skillsDir: ".crush/skills", globalSkillsDir: ".config/crush/skills" }, - { key: "droid", name: "Droid", skillsDir: ".factory/skills" }, + { key: "droid", name: "Droid", skillsDir: ".factory/skills", globalSkillsDir: ".factory/skills" }, { key: "goose", name: "Goose", skillsDir: ".goose/skills", globalSkillsDir: ".config/goose/skills" }, - { key: "junie", name: "Junie", skillsDir: ".junie/skills" }, - { key: "iflow-cli", name: "iFlow CLI", skillsDir: ".iflow/skills" }, - { key: "kode", name: "Kode", skillsDir: ".kode/skills" }, - { key: "mcpjam", name: "MCPJam", skillsDir: ".mcpjam/skills" }, - { key: "mistral-vibe", name: "Mistral Vibe", skillsDir: ".vibe/skills" }, - { key: "openhands", name: "OpenHands", skillsDir: ".openhands/skills" }, + { key: "junie", name: "Junie", skillsDir: ".junie/skills", globalSkillsDir: ".junie/skills" }, + { key: "iflow-cli", name: "iFlow CLI", skillsDir: ".iflow/skills", globalSkillsDir: ".iflow/skills" }, + { key: "kilo", name: "Kilo Code", skillsDir: ".kilocode/skills", globalSkillsDir: ".kilocode/skills" }, + { key: "kiro-cli", name: "Kiro CLI", skillsDir: ".kiro/skills", globalSkillsDir: ".kiro/skills" }, + { key: "kode", name: "Kode", skillsDir: ".kode/skills", globalSkillsDir: ".kode/skills" }, + { key: "mcpjam", name: "MCPJam", skillsDir: ".mcpjam/skills", globalSkillsDir: ".mcpjam/skills" }, + { key: "mistral-vibe", name: "Mistral Vibe", skillsDir: ".vibe/skills", globalSkillsDir: ".vibe/skills" }, + { key: "mux", name: "Mux", skillsDir: ".mux/skills", globalSkillsDir: ".mux/skills" }, + { key: "openhands", name: "OpenHands", skillsDir: ".openhands/skills", globalSkillsDir: ".openhands/skills" }, { key: "pi", name: "Pi", skillsDir: ".pi/skills", globalSkillsDir: ".pi/agent/skills" }, - { key: "qoder", name: "Qoder", skillsDir: ".qoder/skills" }, - { key: "qwen-code", name: "Qwen Code", skillsDir: ".qwen/skills" }, - { key: "roo", name: "Roo Code", skillsDir: ".roo/skills" }, - { key: "trae", name: "Trae", skillsDir: ".trae/skills" }, + { key: "qoder", name: "Qoder", skillsDir: ".qoder/skills", globalSkillsDir: ".qoder/skills" }, + { key: "qwen-code", name: "Qwen Code", skillsDir: ".qwen/skills", globalSkillsDir: ".qwen/skills" }, + { key: "roo", name: "Roo Code", skillsDir: ".roo/skills", globalSkillsDir: ".roo/skills" }, + { key: "trae", name: "Trae", skillsDir: ".trae/skills", globalSkillsDir: ".trae/skills" }, { key: "trae-cn", name: "Trae CN", skillsDir: ".trae/skills", globalSkillsDir: ".trae-cn/skills" }, { key: "windsurf", name: "Windsurf", skillsDir: ".windsurf/skills", globalSkillsDir: ".codeium/windsurf/skills" }, - { key: "zencoder", name: "Zencoder", skillsDir: ".zencoder/skills" }, - { key: "neovate", name: "Neovate", skillsDir: ".neovate/skills" }, - { key: "pochi", name: "Pochi", skillsDir: ".pochi/skills" }, - { key: "adal", name: "AdaL", skillsDir: ".adal/skills" }, + { key: "zencoder", name: "Zencoder", skillsDir: ".zencoder/skills", globalSkillsDir: ".zencoder/skills" }, + { key: "neovate", name: "Neovate", skillsDir: ".neovate/skills", globalSkillsDir: ".neovate/skills" }, + { key: "pochi", name: "Pochi", skillsDir: ".pochi/skills", globalSkillsDir: ".pochi/skills" }, + { key: "adal", name: "AdaL", skillsDir: ".adal/skills", globalSkillsDir: ".adal/skills" }, ]; export function getAllAgents(): AgentInfo[] { @@ -74,16 +82,65 @@ export function getAgentByKey(key: string): AgentInfo | undefined { return AGENTS.find((a) => a.key === key); } -const UNIVERSAL_PATH = ".agents/skills"; +const CANONICAL_SKILLS_DIR = ".agents/skills"; +/** + * Check if an agent uses the canonical .agents/skills directory at the project level. + * Used for UI grouping (Universal section in interactive prompts). + */ export function isUniversalAgent(agent: AgentInfo): boolean { - return agent.skillsDir === UNIVERSAL_PATH; + return agent.skillsDir === CANONICAL_SKILLS_DIR; } +/** + * Get the target installation directory for an agent in the given scope. + * In global scope, uses globalSkillsDir if defined, otherwise falls back to skillsDir. + * In project scope, always uses skillsDir. + */ +export function getAgentTargetDir(agent: AgentInfo, isGlobal: boolean): string { + return isGlobal + ? (agent.globalSkillsDir || agent.skillsDir) + : agent.skillsDir; +} + +/** + * Dynamically determine if an agent is "universal" for the given scope. + * An agent is universal when its target installation directory equals the canonical + * .agents/skills directory — meaning no symlink is needed because the canonical + * location IS the agent's own directory. + * + * This differs from isUniversalAgent() which only checks project-level skillsDir. + * For example, Codex has skillsDir=".agents/skills" (universal at project level) + * but globalSkillsDir=".codex/skills" (NOT universal at global level — needs symlink). + */ +export function isUniversalForScope(agent: AgentInfo, isGlobal: boolean): boolean { + return getAgentTargetDir(agent, isGlobal) === CANONICAL_SKILLS_DIR; +} + +/** + * Returns universal agents that should appear in the interactive selection list. + * Excludes agents with showInUniversalList === false (e.g. replit, which is cloud-only). + */ export function getUniversalAgents(): AgentInfo[] { - return AGENTS.filter((a) => isUniversalAgent(a)); + return AGENTS.filter((a) => isUniversalAgent(a) && a.showInUniversalList !== false); } export function getNonUniversalAgents(): AgentInfo[] { return AGENTS.filter((a) => !isUniversalAgent(a)); -} \ No newline at end of file +} + +/** + * Ensure that all universal agents are included in the target agent list. + * This guarantees that skills are always installed to ~/.agents/skills (the canonical location), + * making them available to any agent that reads from that directory. + */ +export function ensureUniversalAgents(targetAgents: AgentInfo[]): AgentInfo[] { + const universalAgents = getUniversalAgents(); + const result = [...targetAgents]; + for (const ua of universalAgents) { + if (!result.some((a) => a.key === ua.key)) { + result.push(ua); + } + } + return result; +} diff --git a/skillhub-cli/src/core/installer.ts b/skillhub-cli/src/core/installer.ts index 890e9a50..20941f78 100644 --- a/skillhub-cli/src/core/installer.ts +++ b/skillhub-cli/src/core/installer.ts @@ -1,6 +1,7 @@ import { mkdirSync, symlinkSync, copyFileSync, readdirSync, lstatSync, unlinkSync, existsSync } from "node:fs"; import { join, dirname, relative } from "node:path"; import { homedir, platform } from "node:os"; +import { isUniversalForScope, type AgentInfo } from "./agent-detector.js"; export interface SkillInstallResult { skillName: string; @@ -11,15 +12,11 @@ export interface SkillInstallResult { error?: string; } -const UNIVERSAL_PATH = ".agents/skills"; - -function isUniversalAgent(skillsDir: string): boolean { - return skillsDir === UNIVERSAL_PATH; -} +const CANONICAL_SKILLS_DIR = ".agents/skills"; function getCanonicalBase(isGlobal: boolean, cwd: string): string { const home = homedir(); - return isGlobal ? join(home, UNIVERSAL_PATH) : join(cwd, UNIVERSAL_PATH); + return isGlobal ? join(home, CANONICAL_SKILLS_DIR) : join(cwd, CANONICAL_SKILLS_DIR); } function getAgentBaseDir(skillsDir: string, isGlobal: boolean, cwd: string): string { @@ -83,6 +80,7 @@ export function installSkill( targetDir: string, mode: "symlink" | "copy", isGlobal: boolean, + agent?: AgentInfo, ): SkillInstallResult { const cwd = process.cwd(); const canonicalBase = getCanonicalBase(isGlobal, cwd); @@ -90,7 +88,11 @@ export function installSkill( const agentBase = getAgentBaseDir(targetDir, isGlobal, cwd); const agentDir = join(agentBase, skillName); - const agentIsUniversal = isUniversalAgent(targetDir); + // Use dynamic scope-aware universal check if agent info is available, + // otherwise fall back to static targetDir check + const agentIsUniversal = agent + ? isUniversalForScope(agent, isGlobal) + : targetDir === CANONICAL_SKILLS_DIR; try { if (mode === "copy") {