mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #440 from iflytek/feat/cli-install-scope
feat(cli): add --scope option to install command
This commit is contained in:
commit
1cf2e481ee
12 changed files with 719 additions and 60 deletions
|
|
@ -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
|
||||
|
||||
|
|
@ -150,18 +154,21 @@ 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 |
|
||||
|-------|-------------------|-----------------|
|
||||
|
|
@ -169,9 +176,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `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/` |
|
||||
|
|
@ -179,8 +186,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `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
|
||||
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
@ -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 ?? '')
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -809,3 +809,154 @@ describe('install command — multi-agent & auto-detect', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0 — --scope flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — --scope', () => {
|
||||
test('--scope project --agent codex installs to <cwd>/.codex/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'project', '--agent', 'codex',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const metaPath = join(env.cwd, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.slug).toBe('foo')
|
||||
})
|
||||
|
||||
test('--scope user --agent codex installs to <home>/.codex/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'user', '--agent', 'codex',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const metaPath = join(env.home, '.codex', 'skills', 'foo', '.skillhub', 'metadata.json')
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.slug).toBe('foo')
|
||||
})
|
||||
|
||||
test('--scope user clean env falls back to <home>/.agents/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'user',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const metaPath = join(env.home, '.agents', 'skills', 'foo', '.skillhub', 'metadata.json')
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.slug).toBe('foo')
|
||||
})
|
||||
|
||||
test('--scope project --agent codex --json output omits scope field on installed entries', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'project', '--agent', 'codex', '--json',
|
||||
'--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
expect(parsed).toMatchObject({ ok: true, namespace: 'global', slug: 'foo' })
|
||||
expect(parsed.installed[0]).toHaveProperty('agent')
|
||||
expect(parsed.installed[0]).toHaveProperty('dir')
|
||||
expect(parsed.installed[0]).not.toHaveProperty('scope')
|
||||
})
|
||||
|
||||
test('--scope invalid returns exit code 5 with usage error', async () => {
|
||||
const result = await runCli(['install', 'foo', '--scope', 'invalid'])
|
||||
expect(result.exitCode).toBe(5)
|
||||
expect(result.stderr).toMatch(/user.+project|"user".+"project"/)
|
||||
})
|
||||
|
||||
test('--scope invalid --json returns JSON error shape', async () => {
|
||||
const result = await runCli(['install', 'foo', '--scope', 'invalid', '--json'])
|
||||
expect(result.exitCode).toBe(5)
|
||||
const parsed = JSON.parse(result.stderr)
|
||||
expect(parsed.ok).toBe(false)
|
||||
expect(parsed.exitCode).toBe(5)
|
||||
expect(parsed.message).toMatch(/user.+project/)
|
||||
})
|
||||
|
||||
test('--dir + --scope returns usage error', async () => {
|
||||
const result = await runCli(['install', 'foo', '--dir', '/tmp/x', '--scope', 'user'])
|
||||
expect(result.exitCode).toBe(5)
|
||||
expect(result.stderr).toMatch(/--dir cannot be used with --scope/)
|
||||
})
|
||||
|
||||
test('--dir + --scope --json returns JSON usage error', async () => {
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--dir', '/tmp/x', '--scope', 'user', '--json']
|
||||
)
|
||||
expect(result.exitCode).toBe(5)
|
||||
const parsed = JSON.parse(result.stderr)
|
||||
expect(parsed.ok).toBe(false)
|
||||
expect(parsed.message).toMatch(/--dir cannot be used with --scope/)
|
||||
})
|
||||
|
||||
test('help install includes --scope usage and examples', async () => {
|
||||
const result = await runCli(['help', 'install'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toMatch(/--scope/)
|
||||
expect(result.stdout).toMatch(/--scope user/)
|
||||
expect(result.stdout).toMatch(/--scope project --agent codex/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,6 +44,18 @@ describe('resolveInstallTargets', () => {
|
|||
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
|
||||
})
|
||||
|
||||
test('explicit agent without scope labels root by userRoots membership when cwd === home', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/home/u',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
|
||||
})
|
||||
|
||||
test('deduplicates repeated explicit agents by target root', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
|
|
@ -89,4 +101,119 @@ describe('resolveInstallTargets', () => {
|
|||
interactive: false
|
||||
})).rejects.toThrow('unknown agent: unknown-agent')
|
||||
})
|
||||
|
||||
test('rejects dir and scope together', async () => {
|
||||
await expect(resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
dir: '/tmp/skills',
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})).rejects.toThrow('--dir cannot be used with --scope')
|
||||
})
|
||||
|
||||
test('scope=project + agent codex returns project root with project scope', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
scope: 'project',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toEqual([{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'explicit' }])
|
||||
})
|
||||
|
||||
test('scope=user + agent codex returns user root with user scope', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toEqual([{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }])
|
||||
})
|
||||
|
||||
test('scope=user + cwd === home + agent codex still labels candidate as user', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/home/u',
|
||||
home: '/home/u',
|
||||
agents: ['codex'],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
})
|
||||
|
||||
test('scope=user clean env falls back to user agents skills', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/nonexistent-home-' + Math.random().toString(36).slice(2),
|
||||
agents: [],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toEqual([{
|
||||
agent: 'generic',
|
||||
rootDir: targets[0]!.rootDir,
|
||||
scope: 'user',
|
||||
source: 'fallback'
|
||||
}])
|
||||
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
|
||||
expect(targets[0]!.rootDir.startsWith('/nonexistent-home-')).toBe(true)
|
||||
})
|
||||
|
||||
test('scope=project clean env falls back to cwd agents skills', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/nonexistent-repo-' + Math.random().toString(36).slice(2),
|
||||
home: '/home/u',
|
||||
agents: [],
|
||||
scope: 'project',
|
||||
json: false,
|
||||
interactive: false
|
||||
})
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]!.scope).toBe('project')
|
||||
expect(targets[0]!.source).toBe('fallback')
|
||||
expect(targets[0]!.rootDir).toMatch(/\.agents\/skills$/)
|
||||
})
|
||||
|
||||
test('scope filters detected candidates and falls back when filtered empty', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: [],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false,
|
||||
detected: [
|
||||
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' }
|
||||
]
|
||||
})
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]!.source).toBe('fallback')
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
})
|
||||
|
||||
test('scope filters detected candidates keeps matching scope', async () => {
|
||||
const targets = await resolveInstallTargets({
|
||||
cwd: '/repo',
|
||||
home: '/home/u',
|
||||
agents: [],
|
||||
scope: 'user',
|
||||
json: false,
|
||||
interactive: false,
|
||||
detected: [
|
||||
{ agent: 'codex', rootDir: '/repo/.codex/skills', scope: 'project', source: 'detected' },
|
||||
{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'detected' }
|
||||
]
|
||||
})
|
||||
expect(targets).toHaveLength(1)
|
||||
expect(targets[0]!.rootDir).toBe('/home/u/.codex/skills')
|
||||
expect(targets[0]!.scope).toBe('user')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
200
cli/test/unit/commands/install-command.test.ts
Normal file
200
cli/test/unit/commands/install-command.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { CliError } from '../../../src/shared/errors'
|
||||
import {
|
||||
computeStrictIsTTY,
|
||||
installCommand,
|
||||
resolveEffectiveScope,
|
||||
type InstallCommandDeps,
|
||||
type InstallCommandOptions
|
||||
} from '../../../src/commands/install'
|
||||
import type { AgentCandidate } from '../../../src/agents/types'
|
||||
import type { ResolveInstallTargetOptions } from '../../../src/agents/resolver'
|
||||
|
||||
describe('computeStrictIsTTY', () => {
|
||||
test('true when stdin and stdout are TTY and not json', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: true, json: false })).toBe(true)
|
||||
})
|
||||
|
||||
test('false when stdin is not TTY', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: false, stdoutIsTTY: true, json: false })).toBe(false)
|
||||
})
|
||||
|
||||
test('false when stdout is not TTY', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: false, json: false })).toBe(false)
|
||||
})
|
||||
|
||||
test('false when json is true', () => {
|
||||
expect(computeStrictIsTTY({ stdinIsTTY: true, stdoutIsTTY: true, json: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEffectiveScope', () => {
|
||||
function neverPrompt(): Promise<'user' | 'project'> {
|
||||
throw new Error('promptScope should not be called')
|
||||
}
|
||||
|
||||
test('rejects invalid --scope value', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{ scope: 'team' } as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)).rejects.toThrow('--scope must be "user" or "project"')
|
||||
})
|
||||
|
||||
test('rejects --dir with --scope', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{ scope: 'user', dir: '/tmp/x' } as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)).rejects.toThrow('--dir cannot be used with --scope')
|
||||
})
|
||||
|
||||
test('rejects --dir with --agent', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{ dir: '/tmp/x', agent: ['codex'] } as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)).rejects.toThrow('--dir cannot be used with --agent')
|
||||
})
|
||||
|
||||
test('returns explicit --scope value', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ scope: 'user' } as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBe('user')
|
||||
})
|
||||
|
||||
test('--agent without --scope returns undefined (regression protection)', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ agent: ['codex'] } as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('--dir without --scope returns undefined (regression protection)', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ dir: '/tmp/x' } as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('non-interactive bare install returns undefined without calling promptScope', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{ isTTY: false, promptScope: neverPrompt }
|
||||
)
|
||||
expect(scope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('interactive bare install calls promptScope and returns user', async () => {
|
||||
let calls = 0
|
||||
const scope = await resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{
|
||||
isTTY: true,
|
||||
promptScope: async () => { calls++; return 'user' }
|
||||
}
|
||||
)
|
||||
expect(scope).toBe('user')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test('interactive bare install + promptScope returns project', async () => {
|
||||
const scope = await resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{ isTTY: true, promptScope: async () => 'project' }
|
||||
)
|
||||
expect(scope).toBe('project')
|
||||
})
|
||||
|
||||
test('interactive bare install + promptScope cancel propagates CliError', async () => {
|
||||
await expect(resolveEffectiveScope(
|
||||
{} as InstallCommandOptions,
|
||||
{
|
||||
isTTY: true,
|
||||
promptScope: async () => { throw new CliError('installation cancelled', 5) }
|
||||
}
|
||||
)).rejects.toThrow('installation cancelled')
|
||||
})
|
||||
|
||||
test('empty agent array does not skip promptScope', async () => {
|
||||
let calls = 0
|
||||
const scope = await resolveEffectiveScope(
|
||||
{ agent: [] } as InstallCommandOptions,
|
||||
{
|
||||
isTTY: true,
|
||||
promptScope: async () => { calls++; return 'user' }
|
||||
}
|
||||
)
|
||||
expect(scope).toBe('user')
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installCommand dependency injection', () => {
|
||||
function fakeInstallSkill(): NonNullable<InstallCommandDeps['installSkill']> {
|
||||
return async () => ({ installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }] })
|
||||
}
|
||||
|
||||
test('passes prompted scope and strict isTTY into resolveInstallTargets', async () => {
|
||||
const calls: { promptScope: number; resolverCalls: ResolveInstallTargetOptions[] } = {
|
||||
promptScope: 0,
|
||||
resolverCalls: []
|
||||
}
|
||||
const deps: InstallCommandDeps = {
|
||||
isTTY: () => true,
|
||||
promptScope: async () => { calls.promptScope++; return 'user' },
|
||||
resolveInstallTargets: async (opts) => {
|
||||
calls.resolverCalls.push(opts)
|
||||
return [{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }] as AgentCandidate[]
|
||||
},
|
||||
installSkill: fakeInstallSkill()
|
||||
}
|
||||
|
||||
await installCommand('foo', { registry: 'http://localhost', token: 'sk' }, deps)
|
||||
|
||||
expect(calls.promptScope).toBe(1)
|
||||
expect(calls.resolverCalls).toHaveLength(1)
|
||||
expect(calls.resolverCalls[0]!.scope).toBe('user')
|
||||
expect(calls.resolverCalls[0]!.interactive).toBe(true)
|
||||
})
|
||||
|
||||
test('does not call promptScope when --agent is provided', async () => {
|
||||
let promptCalls = 0
|
||||
let resolverScope: 'user' | 'project' | undefined = 'user'
|
||||
const deps: InstallCommandDeps = {
|
||||
isTTY: () => true,
|
||||
promptScope: async () => { promptCalls++; return 'user' },
|
||||
resolveInstallTargets: async (opts) => {
|
||||
resolverScope = opts.scope
|
||||
return [{ agent: 'codex', rootDir: '/home/u/.codex/skills', scope: 'user', source: 'explicit' }] as AgentCandidate[]
|
||||
},
|
||||
installSkill: fakeInstallSkill()
|
||||
}
|
||||
|
||||
await installCommand('foo', {
|
||||
registry: 'http://localhost',
|
||||
token: 'sk',
|
||||
agent: ['codex']
|
||||
}, deps)
|
||||
|
||||
expect(promptCalls).toBe(0)
|
||||
expect(resolverScope).toBeUndefined()
|
||||
})
|
||||
|
||||
test('passes interactive=false when isTTY returns false', async () => {
|
||||
let interactiveFlag: boolean | undefined
|
||||
const deps: InstallCommandDeps = {
|
||||
isTTY: () => false,
|
||||
promptScope: async () => { throw new Error('should not be called') },
|
||||
resolveInstallTargets: async (opts) => {
|
||||
interactiveFlag = opts.interactive
|
||||
return [{ agent: 'generic', rootDir: '/tmp/.agents/skills', scope: 'project', source: 'fallback' }] as AgentCandidate[]
|
||||
},
|
||||
installSkill: fakeInstallSkill()
|
||||
}
|
||||
|
||||
await installCommand('foo', { registry: 'http://localhost', token: 'sk' }, deps)
|
||||
expect(interactiveFlag).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -74,7 +74,7 @@ ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*
|
|||
|
||||
- `SKILL.md` 格式兼容(frontmatter + markdown body)
|
||||
- 技能包目录结构约定(SKILL.md + references/ + scripts/ + assets/)
|
||||
- 四级目录优先级(`.agent/skills` → `~/.agent/skills` → `.claude/skills` → `~/.claude/skills`)
|
||||
- 四级目录优先级(`.agents/skills` → `~/.agents/skills` → `.claude/skills` → `~/.claude/skills`)
|
||||
- 目录名作为 lookup key(安装后目录名 = skill slug)
|
||||
- AGENTS.md `<skill>` 描述块格式兼容
|
||||
- 目标:skillhub CLI 安装的技能可被 OpenSkills/Claude 兼容客户端发现和使用
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ skillhub 的目标是客户端可互操作:skillhub CLI 安装的技能可以
|
|||
|
||||
- SKILL.md 格式(frontmatter + markdown body)
|
||||
- 技能包目录结构约定(SKILL.md + references/ + scripts/ + assets/)
|
||||
- 四级目录优先级:skillhub CLI 遵循 `.agent/skills` → `~/.agent/skills` → `.claude/skills` → `~/.claude/skills` 的发现顺序,与 OpenSkills/Claude 一致
|
||||
- 四级目录优先级:`.agents/skills` → `~/.agents/skills` → `.claude/skills` → `~/.claude/skills`(与 OpenSkills/Claude 一致)。详见 §8.4。
|
||||
- 目录名作为 lookup key:安装后的目录名等于 `skill.slug`(即 SKILL.md 的 `name` 字段),客户端通过目录名发现技能
|
||||
- AGENTS.md `<skill>` 描述块格式:skillhub CLI 生成的 AGENTS.md 索引区块与 OpenSkills 格式兼容
|
||||
|
||||
|
|
@ -78,8 +78,8 @@ skillhub CLI 遵循以下目录优先级,与 OpenSkills/Claude 保持互操作
|
|||
|
||||
| 优先级 | 路径 | 说明 |
|
||||
|--------|------|------|
|
||||
| 1 | `./.agent/skills/` | 项目级,universal 模式 |
|
||||
| 2 | `~/.agent/skills/` | 全局级,universal 模式 |
|
||||
| 1 | `./.agents/skills/` | 项目级,universal 模式 |
|
||||
| 2 | `~/.agents/skills/` | 全局级,universal 模式 |
|
||||
| 3 | `./.claude/skills/` | 项目级,Claude 默认 |
|
||||
| 4 | `~/.claude/skills/` | 全局级,Claude 默认 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -150,18 +154,21 @@ 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 |
|
||||
|-------|-------------------|-----------------|
|
||||
|
|
@ -169,9 +176,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `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/` |
|
||||
|
|
@ -179,8 +186,9 @@ Each Agent has both project-level and user-level skills directories:
|
|||
| `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
|
||||
|
||||
|
|
@ -466,10 +474,11 @@ skillhub install <slug> [options]
|
|||
```
|
||||
|
||||
Options:
|
||||
- `--scope <user|project>` — Install scope (omit for interactive prompt in TTY, or fall back to existing detection in non-TTY)
|
||||
- `--namespace <slug>` — Namespace (default: `global`)
|
||||
- `--version <v>` — Version (default: latest)
|
||||
- `--agent <profile>` — Agent profile (repeatable)
|
||||
- `--dir <path>` — Custom installation directory
|
||||
- `--dir <path>` — Custom installation directory (mutually exclusive with `--scope` and `--agent`)
|
||||
- `--force` — Overwrite existing installation
|
||||
- `--registry <url>` — Registry URL
|
||||
- `--token <token>` — API token
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ skillhub search pdf --json
|
|||
# 安装到自动探测的 Agent 目录
|
||||
skillhub install pdf-parser
|
||||
|
||||
# 显式指定安装范围
|
||||
skillhub install pdf-parser --scope user
|
||||
skillhub install pdf-parser --scope project --agent codex
|
||||
|
||||
# 指定 namespace(默认 global)
|
||||
skillhub install pdf-parser --namespace myspace
|
||||
|
||||
|
|
@ -150,18 +154,21 @@ skillhub install pdf-parser --force
|
|||
|
||||
CLI 按以下逻辑确定安装位置:
|
||||
|
||||
1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`
|
||||
2. 指定 `--agent`:安装到对应 Agent 的 skills 目录
|
||||
3. 未指定:自动扫描当前目录,探测已存在的 Agent 配置目录
|
||||
- 探测到 1 个 Agent → 直接安装
|
||||
- 探测到多个 Agent → 交互式选择(TTY 模式)或报错(非交互模式)
|
||||
- 未探测到 → 回退到 `<cwd>/.agents/skills/`
|
||||
1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`。`--dir` 与 `--scope`、`--agent` 互斥。
|
||||
2. 指定 `--scope user|project`:探测限定在该 scope 内。
|
||||
- 同时指定 `--agent <profile>`:直接安装到该 profile 对应 scope 的 skills 目录。
|
||||
- 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。
|
||||
- 该 scope 下未探测到 → fallback:`--scope user` 回退到 `<home>/.agents/skills/`,`--scope project` 回退到 `<cwd>/.agents/skills/`。
|
||||
3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。
|
||||
4. 三者均未指定:
|
||||
- **交互模式**(stdin 和 stdout 都是 TTY 且未传 `--json`):先交互式询问 user 还是 project scope,再按 `--scope` 规则继续。
|
||||
- **非交互模式**:自动扫描当前目录探测已存在的 Agent 配置目录。1 个 → 直接安装;多个 → 报错;未探测到 → 回退到 `<cwd>/.agents/skills/`。
|
||||
|
||||
> `--dir` 和 `--agent` 不能同时使用。
|
||||
> `--dir` 不能与 `--scope` 或 `--agent` 同时使用。
|
||||
|
||||
### 安装路径
|
||||
|
||||
每个 Agent 有项目级和用户级两个 skills 目录:
|
||||
每个 Agent 有项目级和用户级两个 skills 目录。`--scope user|project` 决定使用哪一个。
|
||||
|
||||
| Agent | 项目级路径 | 用户级路径 |
|
||||
|-------|-----------|-----------|
|
||||
|
|
@ -169,9 +176,9 @@ CLI 按以下逻辑确定安装位置:
|
|||
| `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/` |
|
||||
|
|
@ -179,8 +186,9 @@ CLI 按以下逻辑确定安装位置:
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
|
||||
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
|
||||
|
||||
对于不在列表中的 Agent,使用 `--dir` 指定安装路径。
|
||||
对于不在列表中的 Agent,使用 `--dir` 指定安装路径。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。
|
||||
|
||||
### 安装后的文件结构
|
||||
|
||||
|
|
@ -466,10 +474,11 @@ skillhub install <slug> [options]
|
|||
```
|
||||
|
||||
选项:
|
||||
- `--scope <user|project>` — 安装范围(不传时:TTY 模式下交互式询问,非 TTY 模式沿用现有探测逻辑)
|
||||
- `--namespace <slug>` — namespace(默认 `global`)
|
||||
- `--version <v>` — 版本(默认最新版本)
|
||||
- `--agent <profile>` — Agent 配置(可重复)
|
||||
- `--dir <path>` — 自定义安装目录
|
||||
- `--dir <path>` — 自定义安装目录(与 `--scope`、`--agent` 互斥)
|
||||
- `--force` — 覆盖已存在的安装
|
||||
- `--registry <url>` — Registry URL
|
||||
- `--token <token>` — API token
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue