refactor(cli): use dynamic isUniversalForScope instead of static isUniversalAgent

- Add isUniversalForScope(agent, isGlobal) and getAgentTargetDir(agent, isGlobal) to agent-detector.ts
- An agent is 'universal' when its target install dir equals the canonical .agents/skills directory
- This fixes incorrect symlink skipping for agents like Codex/Cursor whose globalSkillsDir
  differs from .agents/skills (e.g. .codex/skills, .cursor/skills)
- Update installer.ts to use dynamic check via optional AgentInfo parameter
- Update install.ts to use getAgentTargetDir and pass agent info to installSkill
- Update uninstall.ts to use isUniversalForScope for correct path resolution
- Clean up unused isUniversalAgent import in list.ts
This commit is contained in:
chenbaowang 2026-04-20 15:51:22 +08:00
parent ea4e206ad6
commit 32395ee5c2
5 changed files with 112 additions and 55 deletions

View file

@ -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<string, string | s
// Only prompt for install mode when there are multiple unique target directories.
// When all selected agents share the same skillsDir, symlink vs copy is meaningless.
const uniqueDirs = new Set(targetAgents.map((a) =>
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<string, string | s
? `~/.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}`);
}
}
@ -457,9 +455,10 @@ async function installFromRegistry(slug: string, opts: Record<string, string | s
skill.dir,
skill.name,
agent.key,
isGlobal ? agent.globalSkillsDir || agent.skillsDir : agent.skillsDir,
getAgentTargetDir(agent, isGlobal),
mode,
isGlobal,
agent,
);
results.push({
skill: skill.name,
@ -659,9 +658,7 @@ async function installFromGit(skillName: string, source: string, sourceType: Sou
// Only prompt for install mode when there are multiple unique target directories.
// When all selected agents share the same skillsDir, symlink vs copy is meaningless.
const uniqueDirs = new Set(targetAgents.map((a) =>
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,

View file

@ -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";

View file

@ -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);

View file

@ -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));
}
}
/**
* 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;
}

View file

@ -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") {