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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 20 additions & 12 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ Output format: `namespace/slug version summary`
# Install to auto-detected Agent directory
skillhub install pdf-parser

# Choose install scope explicitly
skillhub install pdf-parser --scope user
skillhub install pdf-parser --scope project --agent codex

# Specify namespace (default: global)
skillhub install pdf-parser --namespace myspace

Expand All @@ -150,37 +154,41 @@ skillhub install pdf-parser --force

The CLI determines the installation location using the following logic:

1. If `--dir` is specified: Install to that directory, agent marked as `custom`
2. If `--agent` is specified: Install to the corresponding Agent's skills directory
3. If neither is specified: Auto-scan current directory to detect existing Agent config directories
- 1 Agent detected → Install directly
- Multiple Agents detected → Interactive selection (TTY mode) or error (non-interactive mode)
- No Agent detected → Fallback to `<cwd>/.agents/skills/`
1. If `--dir` is specified: Install to that directory, agent marked as `custom`. `--dir` is mutually exclusive with `--scope` and `--agent`.
2. If `--scope user|project` is specified: Limit detection to the chosen scope.
- With `--agent <profile>`: Install to that profile's user or project skills directory directly.
- Without `--agent`: Detect existing skills directories within the chosen scope only.
- No detected directory in the chosen scope → Fallback to `<home>/.agents/skills/` for `--scope user` or `<cwd>/.agents/skills/` for `--scope project`.
3. If `--agent` is specified (no `--scope`): Install to the corresponding Agent's skills directory (existing behaviour, unchanged).
4. If none of the above is specified:
- **Interactive mode** (stdin and stdout are both TTY, no `--json`): Prompt for `user` or `project` scope first, then continue per the `--scope` rule above.
- **Non-interactive mode**: Auto-scan current directory to detect existing Agent config directories. 1 Agent detected → install directly; multiple → error; none detected → fallback to `<cwd>/.agents/skills/`.

> `--dir` and `--agent` cannot be used together.
> `--dir` cannot be combined with `--scope` or `--agent`.

### Install Paths

Each Agent has both project-level and user-level skills directories:
Each Agent has both project-level and user-level skills directories. Use `--scope user|project` to control which one is used.

| Agent | Project-level Path | User-level Path |
|-------|-------------------|-----------------|
| `claude-code` | `<project>/.claude/skills/` | `~/.claude/skills/` |
| `codex` | `<project>/.codex/skills/` | `~/.codex/skills/` |
| `cursor` | `<project>/.cursor/skills/` | `~/.cursor/skills/` |
| `github-copilot` | `<project>/.github-copilot/skills/` | `~/.github-copilot/skills/` |
| `gemini-cli` | `<project>/.gemini-cli/skills/` | `~/.gemini-cli/skills/` |
| `gemini-cli` | `<project>/.gemini/skills/` | `~/.gemini/skills/` |
| `windsurf` | `<project>/.windsurf/skills/` | `~/.windsurf/skills/` |
| `kiro-cli` | `<project>/.kiro-cli/skills/` | `~/.kiro-cli/skills/` |
| `kiro-cli` | `<project>/.kiro/skills/` | `~/.kiro/skills/` |
| `roo` | `<project>/.roo/skills/` | `~/.roo/skills/` |
| `trae` | `<project>/.trae/skills/` | `~/.trae/skills/` |
| `trae-cn` | `<project>/.trae-cn/skills/` | `~/.trae-cn/skills/` |
| `openhands` | `<project>/.openhands/skills/` | `~/.openhands/skills/` |
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |

For Agents not in the list, use `--dir` to specify the installation path.
For Agents not in the list, use `--dir` to specify the installation path. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.

### File Structure After Installation

Expand Down Expand Up @@ -326,7 +334,7 @@ Update mechanism:
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
| `skillhub search <query> [--registry <url>] [--limit <n>] [--json]` | Search published skills |
| `skillhub install <slug> [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `skillhub install <slug> [--scope <user\|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--registry <url>] [--token <token>] [--json]` | Install a skill |
| `skillhub list [--agent <profile>] [--dir <path>] [--registry <url>] [--json]` | List installed skills |
| `skillhub remove <slug> [--agent <profile>] [--all] [--remote] [--hard] [--namespace <slug>] [--registry <url>] [--token <token>] [--json]` | Remove a skill |
| `skillhub doctor [--json]` | Scan project directory and rebuild local inventory |
Expand Down
103 changes: 89 additions & 14 deletions cli/src/agents/resolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { homedir } from 'node:os'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { pathExists } from '../platform/paths'
import type { AgentCandidate } from './types'
import { allProfiles, profileMap } from './detector'

Expand All @@ -9,20 +10,31 @@ export interface ResolveInstallTargetOptions {
home?: string | undefined
dir?: string | undefined
agents?: string[] | undefined
scope?: 'user' | 'project' | undefined
json: boolean
interactive: boolean
detected?: AgentCandidate[] | undefined
}

export async function resolveInstallTargets(options: ResolveInstallTargetOptions): Promise<AgentCandidate[]> {
if (options.dir && options.agents?.length) {
const agentList = options.agents ?? []

if (options.dir && agentList.length > 0) {
throw new CliError('--dir cannot be used with --agent', EXIT.usage)
}
if (options.dir && options.scope !== undefined) {
throw new CliError('--dir cannot be used with --scope', EXIT.usage)
}
if (options.dir) {
return [{ agent: 'custom', rootDir: options.dir, scope: 'user', source: 'explicit' }]
}
if (options.agents?.length) {
const resolved = await resolveExplicitAgents(options.agents, options.cwd, options.home ?? homedir())

if (options.scope !== undefined) {
return resolveScopedTargets(options, agentList)
}

if (agentList.length > 0) {
const resolved = await resolveExplicitAgents(agentList, options.cwd, options.home ?? homedir())
return dedupeByRoot(resolved)
}
const detected = options.detected ?? await detectAll(options.cwd, options.home ?? '')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

resolveInstallTargets 中,当 options.home 未定义时,detectAll 被传入了空字符串 ''。这会导致 detectAll 无法正确探测到用户目录(home directory)下的 Agent 技能目录(例如 ~/.claude/skills),因为 userRoots('') 会生成错误的路径(如 /.claude/skills)。建议统一使用 homedir() 作为默认值,这与同一文件中的其他逻辑(如第 37 行和第 59 行)保持一致。

Suggested change
const detected = options.detected ?? await detectAll(options.cwd, options.home ?? '')
const detected = options.detected ?? await detectAll(options.cwd, options.home ?? homedir())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

不采纳此建议。这里 detectAll 使用 options.home ?? ''有意保留的兼容性行为,不是 bug:

  • 当前 installCommand 调用 resolver 时不传 home,沿用 options.home ?? '',意味着裸 skillhub install foo(无任何参数)的非交互模式只探测 cwd 下的 agent 目录,无候选时 fallback 到 <cwd>/.agents/skills。这是 PR 之前的现有行为。
  • 如果按建议改为 ?? homedir(),会让裸 install 同时探测用户级目录(如 ~/.codex/skills),可能从"无候选 fallback"变成"探测到 user 候选直接安装"或"多候选报错",破坏现有非交互用户脚本。
  • spec 中明确将这一点列为兼容性原则:仅在显式 --scope 路径才使用真实 homedir()(见 resolver.ts:62 scopedHome = options.home ?? homedir()),而 scope === undefined 路径完全保持现状。
  • 注:第 37 行的 resolveExplicitAgents 沿用 options.home ?? homedir() 也是 PR 之前的现有行为,不是本 PR 引入。

建议保留当前实现。

Expand All @@ -39,6 +51,56 @@ export async function resolveInstallTargets(options: ResolveInstallTargetOptions
return [{ agent: 'generic', rootDir: `${options.cwd}/.agents/skills`, scope: 'project', source: 'fallback' }]
}

async function resolveScopedTargets(
options: ResolveInstallTargetOptions,
agentList: string[]
): Promise<AgentCandidate[]> {
const scope = options.scope!
const scopedHome = options.home ?? homedir()

let candidates: AgentCandidate[]
if (agentList.length > 0) {
candidates = await resolveExplicitAgents(agentList, options.cwd, scopedHome, scope)
} else if (options.detected !== undefined) {
candidates = options.detected.filter(c => c.scope === scope)
} else {
candidates = await generateScopedCandidates(scope, options.cwd, scopedHome)
}
candidates = dedupeByRoot(candidates)

if (candidates.length === 0) {
const fallbackRoot = scope === 'user'
? `${scopedHome}/.agents/skills`
: `${options.cwd}/.agents/skills`
return [{ agent: 'generic', rootDir: fallbackRoot, scope, source: 'fallback' }]
}
if (candidates.length === 1) return candidates
if (options.interactive && !options.json) {
return selectTargetsInteractively(candidates)
}
throw new CliError('multiple install targets detected', EXIT.usage, {
next: 'pass --agent or --dir',
candidates
})
}

async function generateScopedCandidates(
scope: 'user' | 'project',
cwd: string,
home: string
): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const profile of allProfiles) {
const roots = scope === 'user' ? profile.userRoots(home) : profile.projectRoots(cwd)
for (const root of roots) {
if (await pathExists(root)) {
results.push({ agent: profile.id, rootDir: root, scope, source: 'detected' })
}
}
}
return results
}

async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const profile of allProfiles) {
Expand All @@ -48,7 +110,12 @@ async function detectAll(cwd: string, home: string): Promise<AgentCandidate[]> {
return dedupeByRoot(results)
}

async function resolveExplicitAgents(agents: string[], cwd: string, home?: string): Promise<AgentCandidate[]> {
async function resolveExplicitAgents(
agents: string[],
cwd: string,
home: string,
scope?: 'user' | 'project'
): Promise<AgentCandidate[]> {
const results: AgentCandidate[] = []
for (const agentId of agents) {
const profile = profileMap.get(agentId)
Expand All @@ -57,18 +124,26 @@ async function resolveExplicitAgents(agents: string[], cwd: string, home?: strin
next: 'use a supported agent profile or pass --dir'
})
}
const userRoots = home ? profile.userRoots(home) : []
const roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd)
if (roots.length > 0) {
results.push(...roots.map(root => {
const scope: AgentCandidate['scope'] = root.startsWith(cwd) ? 'project' : 'user'
return {
let roots: string[]
if (scope === 'user') {
roots = profile.userRoots(home)
} else if (scope === 'project') {
roots = profile.projectRoots(cwd)
} else {
const userRoots = home ? profile.userRoots(home) : []
roots = userRoots.length > 0 ? userRoots : profile.projectRoots(cwd)
}
const userRootSet = new Set(home ? profile.userRoots(home) : [])
for (const root of roots) {
const candidateScope: AgentCandidate['scope'] = scope !== undefined
? scope
: (userRootSet.has(root) ? 'user' : 'project')
results.push({
agent: agentId,
rootDir: root,
scope,
source: 'explicit' as const
}
}))
scope: candidateScope,
source: 'explicit'
})
}
}
return results
Expand Down
8 changes: 6 additions & 2 deletions cli/src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ export const commands = {
},
install: {
summary: 'Install a skill locally',
usage: 'skillhub install <slug> [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
examples: ['skillhub install pdf-parser', 'skillhub install pdf-parser --agent codex']
usage: 'skillhub install <slug> [--scope <user|project>] [--namespace <slug>] [--version <v>] [--agent <profile>] [--dir <path>] [--force] [--json]',
examples: [
'skillhub install pdf-parser',
'skillhub install pdf-parser --scope user',
'skillhub install pdf-parser --scope project --agent codex'
]
},
list: {
summary: 'List local installs',
Expand Down
83 changes: 79 additions & 4 deletions cli/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,109 @@ import { CredentialsStore } from '../stores/credentials-store'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { installSkill } from '../services/install-service'
import { resolveInstallTargets } from '../agents/resolver'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'

export interface InstallCommandOptions {
namespace?: string | undefined
version?: string | undefined
agent?: string[] | undefined
dir?: string | undefined
scope?: string | undefined
force?: boolean | undefined
registry?: string | undefined
token?: string | undefined
json?: boolean | undefined
}

export async function installCommand(slug: string, options: InstallCommandOptions): Promise<string> {
export interface InstallCommandDeps {
promptScope?: () => Promise<'user' | 'project'>
resolveInstallTargets?: typeof resolveInstallTargets
installSkill?: typeof installSkill
isTTY?: () => boolean
}

export function computeStrictIsTTY(env: {
stdinIsTTY: boolean
stdoutIsTTY: boolean
json: boolean
}): boolean {
return env.stdinIsTTY && env.stdoutIsTTY && !env.json
}

export async function resolveEffectiveScope(
options: InstallCommandOptions,
env: { isTTY: boolean; promptScope: () => Promise<'user' | 'project'> }
): Promise<'user' | 'project' | undefined> {
if (options.scope !== undefined && options.scope !== 'user' && options.scope !== 'project') {
throw new CliError('--scope must be "user" or "project"', EXIT.usage)
}
const scope = options.scope as 'user' | 'project' | undefined
const agentList = options.agent ?? []

if (options.dir && scope !== undefined) {
throw new CliError('--dir cannot be used with --scope', EXIT.usage)
}
if (options.dir && agentList.length > 0) {
throw new CliError('--dir cannot be used with --agent', EXIT.usage)
}

if (scope !== undefined) return scope
if (options.dir || agentList.length > 0) return undefined
if (env.isTTY) return await env.promptScope()
return undefined
}

async function defaultPromptScope(): Promise<'user' | 'project'> {
const prompts = await import('prompts')
const { scope } = await prompts.default({
type: 'select',
name: 'scope',
message: 'Install for user or project?',
choices: [
{ title: 'User (install to user-level agent directory)', value: 'user' },
{ title: 'Project (install to project-level agent directory)', value: 'project' }
]
})
if (!scope) {
throw new CliError('installation cancelled', EXIT.usage)
}
return scope
}

export async function installCommand(
slug: string,
options: InstallCommandOptions,
deps: InstallCommandDeps = {}
): Promise<string> {
const isTTYFn = deps.isTTY ?? (() => computeStrictIsTTY({
stdinIsTTY: process.stdin.isTTY === true,
stdoutIsTTY: process.stdout.isTTY === true,
json: Boolean(options.json)
}))
const isTTY = isTTYFn()

const promptScope = deps.promptScope ?? defaultPromptScope
const effectiveScope = await resolveEffectiveScope(options, { isTTY, promptScope })

const configStore = new ConfigStore()
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const namespace = options.namespace ?? 'global'

const targets = await resolveInstallTargets({
const resolveTargets = deps.resolveInstallTargets ?? resolveInstallTargets
const targets = await resolveTargets({
cwd: process.cwd(),
scope: effectiveScope,
dir: options.dir,
agents: options.agent ?? [],
json: Boolean(options.json),
interactive: process.stdout.isTTY === true
interactive: isTTY
})

const result = await installSkill({
const installFn = deps.installSkill ?? installSkill
const result = await installFn({
registry, token, namespace, slug,
version: options.version,
targets,
Expand Down
1 change: 1 addition & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ cli
.command('install <slug>', 'Install a skill locally')
.option('--namespace <slug>', 'Namespace', { default: 'global' })
.option('--version <v>', 'Version')
.option('--scope <scope>', 'Install scope: user or project')
.option('--agent <profile>', 'Agent profile (repeatable)')
.option('--dir <path>', 'Install directory')
.option('--force', 'Overwrite existing')
Expand Down
Loading
Loading