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