skillhub/cli/test/unit/commands/install-command.test.ts
dongmucat f7ab8f4db7 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 <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
2026-05-15 14:43:25 +08:00

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)
})
})