From f7ab8f4db7ee700420480a324378903136760d06 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 15 May 2026 10:29:58 +0800 Subject: [PATCH 1/3] feat(cli): add --scope option to install command - Distinguish user vs project install scope via explicit --scope flag - Interactive mode prompts for scope when --scope/--agent/--dir not provided - Non-interactive bare install preserves existing behavior (backward compatible) - Mutual exclusion: --dir cannot be combined with --scope or --agent - Symmetric fallback: --scope user falls back to ~/.agents/skills, --scope project falls back to /.agents/skills - Strict TTY check requires both stdin and stdout TTY plus no --json - Scope-aware candidate generation avoids root.startsWith(cwd) misjudgement when cwd === home or paths overlap - Correct gemini-cli (.gemini/skills) and kiro-cli (.kiro/skills) paths in install path tables across README and guide docs - Note CLI fallback uses .agents/skills (with s) in skill protocol doc --- cli/README.md | 32 +-- cli/src/agents/resolver.ts | 102 +++++++-- cli/src/commands/help.ts | 8 +- cli/src/commands/install.ts | 83 +++++++- cli/src/index.ts | 1 + cli/test/integration/install-command.test.ts | 151 +++++++++++++ cli/test/unit/agents/resolver.test.ts | 115 ++++++++++ .../unit/commands/install-command.test.ts | 200 ++++++++++++++++++ docs/07-skill-protocol.md | 4 +- docs/skillhub/en/guide/cli.md | 33 +-- docs/skillhub/guide/cli.md | 33 +-- 11 files changed, 705 insertions(+), 57 deletions(-) create mode 100644 cli/test/unit/commands/install-command.test.ts diff --git a/cli/README.md b/cli/README.md index 3f64aec0..57a0e495 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 `/.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 `: 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 `/.agents/skills/` for `--scope user` or `/.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 `/.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` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | | `github-copilot` | `/.github-copilot/skills/` | `~/.github-copilot/skills/` | -| `gemini-cli` | `/.gemini-cli/skills/` | `~/.gemini-cli/skills/` | +| `gemini-cli` | `/.gemini/skills/` | `~/.gemini/skills/` | | `windsurf` | `/.windsurf/skills/` | `~/.windsurf/skills/` | -| `kiro-cli` | `/.kiro-cli/skills/` | `~/.kiro-cli/skills/` | +| `kiro-cli` | `/.kiro/skills/` | `~/.kiro/skills/` | | `roo` | `/.roo/skills/` | `~/.roo/skills/` | | `trae` | `/.trae/skills/` | `~/.trae/skills/` | | `trae-cn` | `/.trae-cn/skills/` | `~/.trae-cn/skills/` | @@ -179,8 +186,9 @@ Each Agent has both project-level and user-level skills directories: | `openclaw` | `/.openclaw/skills/` | `~/.openclaw/skills/` | | `opencode` | `/.opencode/skills/` | `~/.opencode/skills/` | | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | +| _fallback_ | `/.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 ] [--json]` | Remove token for specified registry | | `skillhub whoami [--registry ] [--token ] [--json]` | Validate current token and display user information | | `skillhub search [--registry ] [--limit ] [--json]` | Search published skills | -| `skillhub install [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | +| `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | | `skillhub list [--agent ] [--dir ] [--registry ] [--json]` | List installed skills | | `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | | `skillhub doctor [--json]` | Scan project directory and rebuild local inventory | diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index 3925bd4b..e34e53b2 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -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 { - 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 { + 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 { + 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 { const results: AgentCandidate[] = [] for (const profile of allProfiles) { @@ -48,7 +110,12 @@ async function detectAll(cwd: string, home: string): Promise { return dedupeByRoot(results) } -async function resolveExplicitAgents(agents: string[], cwd: string, home?: string): Promise { +async function resolveExplicitAgents( + agents: string[], + cwd: string, + home: string, + scope?: 'user' | 'project' +): Promise { const results: AgentCandidate[] = [] for (const agentId of agents) { const profile = profileMap.get(agentId) @@ -57,18 +124,25 @@ 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) + } + for (const root of roots) { + const candidateScope: AgentCandidate['scope'] = scope !== undefined + ? scope + : (root.startsWith(cwd) ? 'project' : 'user') + results.push({ agent: agentId, rootDir: root, - scope, - source: 'explicit' as const - } - })) + scope: candidateScope, + source: 'explicit' + }) } } return results diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index e463a39e..083d72aa 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -33,8 +33,12 @@ export const commands = { }, install: { summary: 'Install a skill locally', - usage: 'skillhub install [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--json]', - examples: ['skillhub install pdf-parser', 'skillhub install pdf-parser --agent codex'] + usage: 'skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--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', diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index cc334131..e9937a7b 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -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 { +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 { + 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, diff --git a/cli/src/index.ts b/cli/src/index.ts index 97f3f34f..19bb6096 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -233,6 +233,7 @@ cli .command('install ', 'Install a skill locally') .option('--namespace ', 'Namespace', { default: 'global' }) .option('--version ', 'Version') + .option('--scope ', 'Install scope: user or project') .option('--agent ', 'Agent profile (repeatable)') .option('--dir ', 'Install directory') .option('--force', 'Overwrite existing') diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index e25cdbd7..b5879827 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -272,3 +272,154 @@ describe('install command — P1', () => { // test/unit/agents/resolver.test.ts. // ------------------------------------------------------------------------- }) + +// --------------------------------------------------------------------------- +// P0 — --scope flag +// --------------------------------------------------------------------------- + +describe('install command — --scope', () => { + test('--scope project --agent codex installs to /.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 /.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 /.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/) + }) +}) diff --git a/cli/test/unit/agents/resolver.test.ts b/cli/test/unit/agents/resolver.test.ts index 9ec6aea8..1abb7f31 100644 --- a/cli/test/unit/agents/resolver.test.ts +++ b/cli/test/unit/agents/resolver.test.ts @@ -89,4 +89,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') + }) }) diff --git a/cli/test/unit/commands/install-command.test.ts b/cli/test/unit/commands/install-command.test.ts new file mode 100644 index 00000000..14d46b63 --- /dev/null +++ b/cli/test/unit/commands/install-command.test.ts @@ -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 { + 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) + }) +}) diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 4bc14103..3a4fc611 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -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 一致 +- 四级目录优先级:协议层定义为 `.agent/skills` → `~/.agent/skills` → `.claude/skills` → `~/.claude/skills`(与 OpenSkills/Claude 一致)。**注:当前 skillhub CLI 实现的 universal fallback 实际使用 `.agents/skills`(带 s),属于历史命名漂移,统一工作另行排期。** 详见 §8.4。 - 目录名作为 lookup key:安装后的目录名等于 `skill.slug`(即 SKILL.md 的 `name` 字段),客户端通过目录名发现技能 - AGENTS.md `` 描述块格式:skillhub CLI 生成的 AGENTS.md 索引区块与 OpenSkills 格式兼容 @@ -85,6 +85,8 @@ skillhub CLI 遵循以下目录优先级,与 OpenSkills/Claude 保持互操作 安装后目录名等于 `skill.slug`(SKILL.md 的 `name` 字段),确保其他兼容客户端可通过目录名发现。 +> **当前 CLI 实现状态:** skillhub CLI 当前 fallback 实现使用 `.agents/skills`(带 s),与本节列出的协议层 `.agent/skills` 存在历史命名漂移。这只是对现状的记录,不构成对最终协议目录约定的判定。CLI 实际行为以 `cli/src/agents/profiles/generic-fallback.ts` 为准;协议层目录约定的统一是独立工作项,不在本次 scope 设计范围。 + ## 8.5 与 AGENTS.md 的关系 - skillhub CLI 安装技能后,通过 `sync` 命令在 AGENTS.md 中生成 `` 描述块 diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index dbe7bc8f..1a3069b9 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -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 `/.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 `: 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 `/.agents/skills/` for `--scope user` or `/.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 `/.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` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | | `github-copilot` | `/.github-copilot/skills/` | `~/.github-copilot/skills/` | -| `gemini-cli` | `/.gemini-cli/skills/` | `~/.gemini-cli/skills/` | +| `gemini-cli` | `/.gemini/skills/` | `~/.gemini/skills/` | | `windsurf` | `/.windsurf/skills/` | `~/.windsurf/skills/` | -| `kiro-cli` | `/.kiro-cli/skills/` | `~/.kiro-cli/skills/` | +| `kiro-cli` | `/.kiro/skills/` | `~/.kiro/skills/` | | `roo` | `/.roo/skills/` | `~/.roo/skills/` | | `trae` | `/.trae/skills/` | `~/.trae/skills/` | | `trae-cn` | `/.trae-cn/skills/` | `~/.trae-cn/skills/` | @@ -179,8 +186,9 @@ Each Agent has both project-level and user-level skills directories: | `openclaw` | `/.openclaw/skills/` | `~/.openclaw/skills/` | | `opencode` | `/.opencode/skills/` | `~/.opencode/skills/` | | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | +| _fallback_ | `/.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 [options] ``` Options: +- `--scope ` — Install scope (omit for interactive prompt in TTY, or fall back to existing detection in non-TTY) - `--namespace ` — Namespace (default: `global`) - `--version ` — Version (default: latest) - `--agent ` — Agent profile (repeatable) -- `--dir ` — Custom installation directory +- `--dir ` — Custom installation directory (mutually exclusive with `--scope` and `--agent`) - `--force` — Overwrite existing installation - `--registry ` — Registry URL - `--token ` — API token diff --git a/docs/skillhub/guide/cli.md b/docs/skillhub/guide/cli.md index 6c664fbc..910d9cf5 100644 --- a/docs/skillhub/guide/cli.md +++ b/docs/skillhub/guide/cli.md @@ -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 模式)或报错(非交互模式) - - 未探测到 → 回退到 `/.agents/skills/` +1. 指定 `--dir`:安装到该目录,agent 标记为 `custom`。`--dir` 与 `--scope`、`--agent` 互斥。 +2. 指定 `--scope user|project`:探测限定在该 scope 内。 + - 同时指定 `--agent `:直接安装到该 profile 对应 scope 的 skills 目录。 + - 未指定 `--agent`:只探测该 scope 下已存在的 skills 目录。 + - 该 scope 下未探测到 → fallback:`--scope user` 回退到 `/.agents/skills/`,`--scope project` 回退到 `/.agents/skills/`。 +3. 指定 `--agent`(无 `--scope`):安装到对应 Agent 的 skills 目录(沿用现有行为,不变)。 +4. 三者均未指定: + - **交互模式**(stdin 和 stdout 都是 TTY 且未传 `--json`):先交互式询问 user 还是 project scope,再按 `--scope` 规则继续。 + - **非交互模式**:自动扫描当前目录探测已存在的 Agent 配置目录。1 个 → 直接安装;多个 → 报错;未探测到 → 回退到 `/.agents/skills/`。 -> `--dir` 和 `--agent` 不能同时使用。 +> `--dir` 不能与 `--scope` 或 `--agent` 同时使用。 ### 安装路径 -每个 Agent 有项目级和用户级两个 skills 目录: +每个 Agent 有项目级和用户级两个 skills 目录。`--scope user|project` 决定使用哪一个。 | Agent | 项目级路径 | 用户级路径 | |-------|-----------|-----------| @@ -169,9 +176,9 @@ CLI 按以下逻辑确定安装位置: | `codex` | `/.codex/skills/` | `~/.codex/skills/` | | `cursor` | `/.cursor/skills/` | `~/.cursor/skills/` | | `github-copilot` | `/.github-copilot/skills/` | `~/.github-copilot/skills/` | -| `gemini-cli` | `/.gemini-cli/skills/` | `~/.gemini-cli/skills/` | +| `gemini-cli` | `/.gemini/skills/` | `~/.gemini/skills/` | | `windsurf` | `/.windsurf/skills/` | `~/.windsurf/skills/` | -| `kiro-cli` | `/.kiro-cli/skills/` | `~/.kiro-cli/skills/` | +| `kiro-cli` | `/.kiro/skills/` | `~/.kiro/skills/` | | `roo` | `/.roo/skills/` | `~/.roo/skills/` | | `trae` | `/.trae/skills/` | `~/.trae/skills/` | | `trae-cn` | `/.trae-cn/skills/` | `~/.trae-cn/skills/` | @@ -179,8 +186,9 @@ CLI 按以下逻辑确定安装位置: | `openclaw` | `/.openclaw/skills/` | `~/.openclaw/skills/` | | `opencode` | `/.opencode/skills/` | `~/.opencode/skills/` | | `kilo` | `/.kilo/skills/` | `~/.kilo/skills/` | +| _fallback_ | `/.agents/skills/` | `~/.agents/skills/` | -对于不在列表中的 Agent,使用 `--dir` 指定安装路径。 +对于不在列表中的 Agent,使用 `--dir` 指定安装路径。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。 ### 安装后的文件结构 @@ -466,10 +474,11 @@ skillhub install [options] ``` 选项: +- `--scope ` — 安装范围(不传时:TTY 模式下交互式询问,非 TTY 模式沿用现有探测逻辑) - `--namespace ` — namespace(默认 `global`) - `--version ` — 版本(默认最新版本) - `--agent ` — Agent 配置(可重复) -- `--dir ` — 自定义安装目录 +- `--dir ` — 自定义安装目录(与 `--scope`、`--agent` 互斥) - `--force` — 覆盖已存在的安装 - `--registry ` — Registry URL - `--token ` — API token From 96681b021a86e5a8984f9382f3c61457b0fccc78 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Fri, 15 May 2026 11:15:04 +0800 Subject: [PATCH 2/3] fix(cli): label scope by userRoots membership instead of cwd prefix When --agent is provided without --scope, scope was inferred via root.startsWith(cwd), which mislabels user roots as project when cwd === home. Use profile.userRoots(home) membership instead, so the candidate scope reflects the profile's intent rather than path prefix overlap. The chosen root path itself is unchanged. --- cli/src/agents/resolver.ts | 3 ++- cli/test/unit/agents/resolver.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cli/src/agents/resolver.ts b/cli/src/agents/resolver.ts index e34e53b2..3d983727 100644 --- a/cli/src/agents/resolver.ts +++ b/cli/src/agents/resolver.ts @@ -133,10 +133,11 @@ async function resolveExplicitAgents( 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 - : (root.startsWith(cwd) ? 'project' : 'user') + : (userRootSet.has(root) ? 'user' : 'project') results.push({ agent: agentId, rootDir: root, diff --git a/cli/test/unit/agents/resolver.test.ts b/cli/test/unit/agents/resolver.test.ts index 1abb7f31..36e8170b 100644 --- a/cli/test/unit/agents/resolver.test.ts +++ b/cli/test/unit/agents/resolver.test.ts @@ -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', From 8c8b047cbb454226da7088d06bfa12426cd68dfa Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 19 May 2026 10:49:31 +0800 Subject: [PATCH 3/3] docs(protocol): adopt .agents/skills (plural) as canonical universal fallback Resolve historical naming drift between protocol spec and CLI by adopting the plural form across both docs: - docs/07-skill-protocol.md: drop the drift caveat; the four-tier priority is now stated as .agents/skills / ~/.agents/skills / .claude/skills / ~/.claude/skills directly. - docs/00-product-direction.md: align with the same plural form. The CLI already uses .agents/skills (cli/src/agents/profiles/generic-fallback.ts and cli/src/agents/resolver.ts). No code change required. --- docs/00-product-direction.md | 2 +- docs/07-skill-protocol.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/00-product-direction.md b/docs/00-product-direction.md index 295e46fc..36b1bb38 100644 --- a/docs/00-product-direction.md +++ b/docs/00-product-direction.md @@ -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 `` 描述块格式兼容 - 目标:skillhub CLI 安装的技能可被 OpenSkills/Claude 兼容客户端发现和使用 diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 3a4fc611..6e9db3cc 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -8,7 +8,7 @@ skillhub 的目标是客户端可互操作:skillhub CLI 安装的技能可以 - SKILL.md 格式(frontmatter + markdown body) - 技能包目录结构约定(SKILL.md + references/ + scripts/ + assets/) -- 四级目录优先级:协议层定义为 `.agent/skills` → `~/.agent/skills` → `.claude/skills` → `~/.claude/skills`(与 OpenSkills/Claude 一致)。**注:当前 skillhub CLI 实现的 universal fallback 实际使用 `.agents/skills`(带 s),属于历史命名漂移,统一工作另行排期。** 详见 §8.4。 +- 四级目录优先级:`.agents/skills` → `~/.agents/skills` → `.claude/skills` → `~/.claude/skills`(与 OpenSkills/Claude 一致)。详见 §8.4。 - 目录名作为 lookup key:安装后的目录名等于 `skill.slug`(即 SKILL.md 的 `name` 字段),客户端通过目录名发现技能 - AGENTS.md `` 描述块格式:skillhub CLI 生成的 AGENTS.md 索引区块与 OpenSkills 格式兼容 @@ -78,15 +78,13 @@ 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 默认 | 安装后目录名等于 `skill.slug`(SKILL.md 的 `name` 字段),确保其他兼容客户端可通过目录名发现。 -> **当前 CLI 实现状态:** skillhub CLI 当前 fallback 实现使用 `.agents/skills`(带 s),与本节列出的协议层 `.agent/skills` 存在历史命名漂移。这只是对现状的记录,不构成对最终协议目录约定的判定。CLI 实际行为以 `cli/src/agents/profiles/generic-fallback.ts` 为准;协议层目录约定的统一是独立工作项,不在本次 scope 设计范围。 - ## 8.5 与 AGENTS.md 的关系 - skillhub CLI 安装技能后,通过 `sync` 命令在 AGENTS.md 中生成 `` 描述块