mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
- 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 <cwd>/.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
200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
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)
|
|
})
|
|
})
|