tmux-team 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import type { Context, Flags, Paths, ResolvedConfig, Tmux, UI } from '../types.js';
6
+ import { ExitCodes } from '../exits.js';
7
+
8
+ import { cmdInit } from './init.js';
9
+ import { cmdAdd } from './add.js';
10
+ import { cmdRemove } from './remove.js';
11
+ import { cmdUpdate } from './update.js';
12
+ import { cmdList } from './list.js';
13
+ import { cmdCheck } from './check.js';
14
+ import { cmdPreamble } from './preamble.js';
15
+ import { cmdConfig } from './config.js';
16
+ import { cmdCompletion } from './completion.js';
17
+ import { cmdHelp } from './help.js';
18
+ import { cmdLearn } from './learn.js';
19
+
20
+ function createMockUI(): UI & { jsonCalls: unknown[] } {
21
+ return {
22
+ jsonCalls: [],
23
+ info: vi.fn(),
24
+ success: vi.fn(),
25
+ warn: vi.fn(),
26
+ error: vi.fn(),
27
+ table: vi.fn(),
28
+ json(data: unknown) {
29
+ (this as any).jsonCalls.push(data);
30
+ },
31
+ } as any;
32
+ }
33
+
34
+ function createMockTmux(): Tmux {
35
+ return {
36
+ send: vi.fn(),
37
+ capture: vi.fn(() => 'captured'),
38
+ listPanes: vi.fn(() => []),
39
+ getCurrentPaneId: vi.fn(() => null),
40
+ };
41
+ }
42
+
43
+ function createCtx(
44
+ testDir: string,
45
+ overrides?: Partial<{ flags: Partial<Flags>; config: Partial<ResolvedConfig> }>
46
+ ): Context {
47
+ const paths: Paths = {
48
+ globalDir: testDir,
49
+ globalConfig: path.join(testDir, 'config.json'),
50
+ localConfig: path.join(testDir, 'tmux-team.json'),
51
+ stateFile: path.join(testDir, 'state.json'),
52
+ };
53
+ const baseConfig: ResolvedConfig = {
54
+ mode: 'polling',
55
+ preambleMode: 'always',
56
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, preambleEvery: 3 },
57
+ agents: {},
58
+ paneRegistry: {},
59
+ ...overrides?.config,
60
+ };
61
+ const flags: Flags = { json: false, verbose: false, ...(overrides?.flags ?? {}) } as Flags;
62
+ const ui = createMockUI();
63
+ const tmux = createMockTmux();
64
+ return {
65
+ argv: [],
66
+ flags,
67
+ ui,
68
+ config: baseConfig,
69
+ tmux,
70
+ paths,
71
+ exit: ((code: number) => {
72
+ const err = new Error(`exit(${code})`);
73
+ (err as Error & { exitCode: number }).exitCode = code;
74
+ throw err;
75
+ }) as any,
76
+ };
77
+ }
78
+
79
+ describe('basic commands', () => {
80
+ let testDir = '';
81
+
82
+ beforeEach(() => {
83
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-cmd-'));
84
+ });
85
+
86
+ afterEach(() => {
87
+ fs.rmSync(testDir, { recursive: true, force: true });
88
+ });
89
+
90
+ it('cmdInit creates tmux-team.json', () => {
91
+ const ctx = createCtx(testDir);
92
+ cmdInit(ctx);
93
+ expect(fs.existsSync(ctx.paths.localConfig)).toBe(true);
94
+ });
95
+
96
+ it('cmdInit errors if tmux-team.json exists', () => {
97
+ const ctx = createCtx(testDir);
98
+ fs.writeFileSync(ctx.paths.localConfig, '{}\n');
99
+ expect(() => cmdInit(ctx)).toThrow(`exit(${ExitCodes.ERROR})`);
100
+ });
101
+
102
+ it('cmdAdd creates config if missing and writes new agent', () => {
103
+ const ctx = createCtx(testDir);
104
+ cmdAdd(ctx, 'codex', '1.1', 'review');
105
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
106
+ expect(saved.codex.pane).toBe('1.1');
107
+ expect(saved.codex.remark).toBe('review');
108
+ });
109
+
110
+ it('cmdAdd errors if agent exists', () => {
111
+ const ctx = createCtx(testDir, { config: { paneRegistry: { codex: { pane: '1.1' } } } });
112
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ codex: { pane: '1.1' } }, null, 2));
113
+ expect(() => cmdAdd(ctx, 'codex', '1.1')).toThrow(`exit(${ExitCodes.ERROR})`);
114
+ });
115
+
116
+ it('cmdRemove deletes agent', () => {
117
+ const ctx = createCtx(testDir, { config: { paneRegistry: { codex: { pane: '1.1' } } } });
118
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ codex: { pane: '1.1' } }, null, 2));
119
+ cmdRemove(ctx, 'codex');
120
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
121
+ expect(saved.codex).toBeUndefined();
122
+ });
123
+
124
+ it('cmdUpdate updates pane and remark; creates entry if missing', () => {
125
+ const ctx = createCtx(testDir, { config: { paneRegistry: { codex: { pane: '1.1' } } } });
126
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({}, null, 2));
127
+ cmdUpdate(ctx, 'codex', { pane: '2.2', remark: 'new' });
128
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
129
+ expect(saved.codex.pane).toBe('2.2');
130
+ expect(saved.codex.remark).toBe('new');
131
+ });
132
+
133
+ it('cmdList outputs JSON when --json', () => {
134
+ const ctx = createCtx(testDir, {
135
+ flags: { json: true },
136
+ config: { paneRegistry: { claude: { pane: '1.0', remark: 'main' } } },
137
+ });
138
+ cmdList(ctx);
139
+ expect((ctx.ui as any).jsonCalls.length).toBe(1);
140
+ });
141
+
142
+ it('cmdList prints hint when no agents', () => {
143
+ const ctx = createCtx(testDir);
144
+ cmdList(ctx);
145
+ expect(ctx.ui.info).toHaveBeenCalled();
146
+ });
147
+
148
+ it('cmdList prints table when agents exist', () => {
149
+ const ctx = createCtx(testDir, {
150
+ config: { paneRegistry: { claude: { pane: '1.0', remark: 'main' } } },
151
+ });
152
+ cmdList(ctx);
153
+ expect(ctx.ui.table).toHaveBeenCalled();
154
+ });
155
+
156
+ it('cmdCheck captures pane output', () => {
157
+ const ctx = createCtx(testDir, {
158
+ config: { paneRegistry: { claude: { pane: '1.0' } } },
159
+ });
160
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
161
+ cmdCheck(ctx, 'claude', 10);
162
+ expect(ctx.tmux.capture).toHaveBeenCalledWith('1.0', 10);
163
+ expect(logSpy).toHaveBeenCalled();
164
+ });
165
+
166
+ it('cmdCheck errors when agent missing', () => {
167
+ const ctx = createCtx(testDir);
168
+ expect(() => cmdCheck(ctx, 'nope')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
169
+ });
170
+
171
+ it('cmdCheck outputs JSON when --json', () => {
172
+ const ctx = createCtx(testDir, {
173
+ flags: { json: true },
174
+ config: { paneRegistry: { claude: { pane: '1.0' } } },
175
+ });
176
+ cmdCheck(ctx, 'claude', 5);
177
+ expect((ctx.ui as any).jsonCalls.length).toBe(1);
178
+ });
179
+
180
+ it('cmdPreamble set/show/clear updates local config', () => {
181
+ const ctx = createCtx(testDir, {
182
+ config: { paneRegistry: { claude: { pane: '1.0' } }, agents: { claude: {} } },
183
+ });
184
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ claude: { pane: '1.0' } }, null, 2));
185
+
186
+ cmdPreamble(ctx, ['set', 'claude', 'Be', 'concise']);
187
+ let saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
188
+ expect(saved.claude.preamble).toBe('Be concise');
189
+
190
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
191
+ // show will read from ctx.config.agents; update it to reflect loadConfig behavior
192
+ ctx.config.agents.claude = { preamble: 'Be concise' };
193
+ cmdPreamble(ctx, ['show', 'claude']);
194
+ expect(logSpy).toHaveBeenCalled();
195
+
196
+ cmdPreamble(ctx, ['clear', 'claude']);
197
+ saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
198
+ expect(saved.claude.preamble).toBeUndefined();
199
+ });
200
+
201
+ it('cmdPreamble set errors when agent missing', () => {
202
+ const ctx = createCtx(testDir);
203
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({}, null, 2));
204
+ expect(() => cmdPreamble(ctx, ['set', 'nope', 'x'])).toThrow(`exit(${ExitCodes.ERROR})`);
205
+ });
206
+
207
+ it('cmdPreamble clear returns not_set when missing', () => {
208
+ const ctx = createCtx(testDir, { flags: { json: true } });
209
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ claude: { pane: '1.0' } }, null, 2));
210
+ cmdPreamble(ctx, ['clear', 'claude']);
211
+ const out = (ctx.ui as any).jsonCalls[0] as any;
212
+ expect(out.status).toBe('not_set');
213
+ });
214
+
215
+ it('cmdConfig set/show/clear works for local settings', () => {
216
+ const ctx = createCtx(testDir);
217
+ fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({}, null, 2));
218
+
219
+ cmdConfig(ctx, ['set', 'mode', 'wait']);
220
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
221
+ expect(saved.$config.mode).toBe('wait');
222
+
223
+ cmdConfig(ctx, ['clear', 'mode']);
224
+ const saved2 = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
225
+ expect(saved2.$config?.mode).toBeUndefined();
226
+ });
227
+
228
+ it('cmdConfig set supports --global', () => {
229
+ const ctx = createCtx(testDir);
230
+ cmdConfig(ctx, ['set', 'mode', 'wait', '--global']);
231
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.globalConfig, 'utf-8'));
232
+ expect(saved.mode).toBe('wait');
233
+ });
234
+
235
+ it('cmdCompletion prints scripts', () => {
236
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
237
+ cmdCompletion('bash');
238
+ expect(logSpy.mock.calls.join('\n')).toContain('complete -F _tmux_team');
239
+
240
+ logSpy.mockClear();
241
+ cmdCompletion('zsh');
242
+ expect(logSpy.mock.calls.join('\n')).toContain('#compdef tmux-team');
243
+ });
244
+
245
+ it('cmdHelp/cmdLearn print output', () => {
246
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
247
+ cmdHelp({ mode: 'polling', showIntro: true });
248
+ cmdHelp({ mode: 'wait', timeout: 10 });
249
+ cmdLearn();
250
+ expect(logSpy).toHaveBeenCalled();
251
+ });
252
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import type { Context, Flags, Paths, ResolvedConfig, Tmux, UI } from '../types.js';
6
+ import { ExitCodes } from '../exits.js';
7
+ import { cmdConfig } from './config.js';
8
+
9
+ function createMockUI(): UI & { jsonCalls: unknown[] } {
10
+ return {
11
+ jsonCalls: [],
12
+ info: vi.fn(),
13
+ success: vi.fn(),
14
+ warn: vi.fn(),
15
+ error: vi.fn(),
16
+ table: vi.fn(),
17
+ json(data: unknown) {
18
+ (this as any).jsonCalls.push(data);
19
+ },
20
+ } as any;
21
+ }
22
+
23
+ function createCtx(
24
+ testDir: string,
25
+ flags?: Partial<Flags>,
26
+ configOverrides?: Partial<ResolvedConfig>
27
+ ): Context {
28
+ const paths: Paths = {
29
+ globalDir: path.join(testDir, 'global'),
30
+ globalConfig: path.join(testDir, 'global', 'config.json'),
31
+ localConfig: path.join(testDir, 'tmux-team.json'),
32
+ stateFile: path.join(testDir, 'global', 'state.json'),
33
+ };
34
+ const config: ResolvedConfig = {
35
+ mode: 'polling',
36
+ preambleMode: 'always',
37
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, preambleEvery: 3 },
38
+ agents: {},
39
+ paneRegistry: {},
40
+ ...configOverrides,
41
+ };
42
+ const tmux: Tmux = {
43
+ send: vi.fn(),
44
+ capture: vi.fn(),
45
+ listPanes: vi.fn(() => []),
46
+ getCurrentPaneId: vi.fn(() => null),
47
+ };
48
+ return {
49
+ argv: [],
50
+ flags: { json: false, verbose: false, ...(flags ?? {}) } as Flags,
51
+ ui: createMockUI(),
52
+ config,
53
+ tmux,
54
+ paths,
55
+ exit: ((code: number) => {
56
+ const err = new Error(`exit(${code})`);
57
+ (err as Error & { exitCode: number }).exitCode = code;
58
+ throw err;
59
+ }) as any,
60
+ };
61
+ }
62
+
63
+ describe('cmdConfig', () => {
64
+ let testDir = '';
65
+
66
+ beforeEach(() => {
67
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-configcmd-'));
68
+ });
69
+
70
+ afterEach(() => {
71
+ fs.rmSync(testDir, { recursive: true, force: true });
72
+ });
73
+
74
+ it('shows config as JSON when --json', () => {
75
+ const ctx = createCtx(testDir, { json: true });
76
+ cmdConfig(ctx, ['show']);
77
+ expect((ctx.ui as any).jsonCalls.length).toBe(1);
78
+ const out = (ctx.ui as any).jsonCalls[0] as any;
79
+ expect(out.resolved).toBeTruthy();
80
+ expect(out.sources).toBeTruthy();
81
+ expect(out.paths).toBeTruthy();
82
+ });
83
+
84
+ it('shows config as table in human mode', () => {
85
+ const ctx = createCtx(testDir);
86
+ cmdConfig(ctx, ['show']);
87
+ expect(ctx.ui.table).toHaveBeenCalled();
88
+ });
89
+
90
+ it('rejects invalid keys and values', () => {
91
+ const ctx = createCtx(testDir);
92
+ expect(() => cmdConfig(ctx, ['set', 'nope', 'x'])).toThrow(`exit(${ExitCodes.ERROR})`);
93
+ expect(() => cmdConfig(ctx, ['set', 'mode', 'nope'])).toThrow(`exit(${ExitCodes.ERROR})`);
94
+ expect(() => cmdConfig(ctx, ['set', 'preambleEvery', '-1'])).toThrow(
95
+ `exit(${ExitCodes.ERROR})`
96
+ );
97
+ });
98
+
99
+ it('sets and clears local settings', () => {
100
+ const ctx = createCtx(testDir);
101
+ cmdConfig(ctx, ['set', 'preambleMode', 'disabled']);
102
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
103
+ expect(saved.$config.preambleMode).toBe('disabled');
104
+
105
+ cmdConfig(ctx, ['clear']);
106
+ const saved2 = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
107
+ expect(saved2.$config).toBeUndefined();
108
+ });
109
+
110
+ it('sets global settings with -g', () => {
111
+ const ctx = createCtx(testDir);
112
+ cmdConfig(ctx, ['set', 'preambleEvery', '5', '-g']);
113
+ const saved = JSON.parse(fs.readFileSync(ctx.paths.globalConfig, 'utf-8'));
114
+ expect(saved.defaults.preambleEvery).toBe(5);
115
+ });
116
+ });
@@ -8,6 +8,7 @@ import { VERSION } from '../version.js';
8
8
  export interface HelpConfig {
9
9
  mode?: 'polling' | 'wait';
10
10
  timeout?: number;
11
+ showIntro?: boolean;
11
12
  }
12
13
 
13
14
  export function cmdHelp(config?: HelpConfig): void {
@@ -15,17 +16,28 @@ export function cmdHelp(config?: HelpConfig): void {
15
16
  const timeout = config?.timeout ?? 180;
16
17
  const isWaitMode = mode === 'wait';
17
18
 
19
+ // Show intro highlight when running just `tmux-team` with no args
20
+ if (config?.showIntro) {
21
+ console.log(`
22
+ ${colors.cyan('┌─────────────────────────────────────────────────────────────┐')}
23
+ ${colors.cyan('│')} ${colors.yellow('New to tmux-team?')} Run ${colors.green('tmux-team learn')} or ${colors.green('tmt learn')} ${colors.cyan('│')}
24
+ ${colors.cyan('│')} ${colors.dim('tmt is a shorthand alias for tmux-team')} ${colors.cyan('│')}
25
+ ${colors.cyan('└─────────────────────────────────────────────────────────────┘')}`);
26
+ }
27
+
18
28
  // Mode indicator with clear explanation
19
29
  const modeInfo = isWaitMode
20
- ? `${colors.yellow('CURRENT MODE')}: ${colors.green('wait')} (timeout: ${timeout}s)
30
+ ? `${colors.yellow('CURRENT MODE')}: ${colors.green('wait')} (timeout: ${timeout}s) ${colors.green('✓ recommended')}
21
31
  ${colors.dim('→ talk commands will BLOCK until agent responds or timeout')}
22
32
  ${colors.dim('→ Response is returned directly, no need to use check command')}`
23
33
  : `${colors.yellow('CURRENT MODE')}: ${colors.cyan('polling')}
24
34
  ${colors.dim('→ talk commands send and return immediately')}
25
- ${colors.dim('→ Use check command to read agent response')}`;
35
+ ${colors.dim('→ Use check command to read agent response')}
36
+ ${colors.dim('→')} ${colors.yellow('TIP')}: ${colors.dim('Use --wait or set mode to wait for better token utilization')}`;
26
37
 
27
38
  console.log(`
28
39
  ${colors.cyan('tmux-team')} v${VERSION} - AI agent collaboration in tmux
40
+ ${colors.dim('Alias: tmt')}
29
41
 
30
42
  ${modeInfo}
31
43
 
@@ -39,11 +51,13 @@ ${colors.yellow('COMMANDS')}
39
51
  ${colors.green('add')} <name> <pane> [remark] Add a new agent
40
52
  ${colors.green('update')} <name> [options] Update an agent's config
41
53
  ${colors.green('remove')} <name> Remove an agent
54
+ ${colors.green('install')} [claude|codex] Install tmux-team for an AI agent
55
+ ${colors.green('setup')} Interactive wizard to configure agents
42
56
  ${colors.green('init')} Create empty tmux-team.json
43
57
  ${colors.green('config')} [show|set|clear] View/modify settings
44
58
  ${colors.green('preamble')} [show|set|clear] Manage agent preambles
45
- ${colors.green('install-skill')} <agent> Install skill for AI agent
46
59
  ${colors.green('completion')} Output shell completion script
60
+ ${colors.green('learn')} Show educational guide
47
61
  ${colors.green('help')} Show this help message
48
62
 
49
63
  ${colors.yellow('OPTIONS')}
@@ -55,7 +69,9 @@ ${colors.yellow('TALK OPTIONS')}
55
69
  ${colors.green('--delay')} <seconds> Wait before sending
56
70
  ${colors.green('--wait')} Force wait mode (block until response)
57
71
  ${colors.green('--timeout')} <seconds> Max wait time (current: ${timeout}s)
72
+ ${colors.green('--lines')} <number> Lines to capture (default: 100)
58
73
  ${colors.green('--no-preamble')} Skip agent preamble for this message
74
+ ${colors.green('--debug')} Show debug output
59
75
 
60
76
  ${colors.yellow('EXAMPLES')}${
61
77
  isWaitMode
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import type { Context, Flags, Paths, ResolvedConfig, Tmux, UI } from '../types.js';
6
+ import { ExitCodes } from '../exits.js';
7
+
8
+ function createMockUI(): UI {
9
+ return {
10
+ info: vi.fn(),
11
+ success: vi.fn(),
12
+ warn: vi.fn(),
13
+ error: vi.fn(),
14
+ table: vi.fn(),
15
+ json: vi.fn(),
16
+ };
17
+ }
18
+
19
+ function createCtx(testDir: string, overrides?: Partial<{ flags: Partial<Flags> }>): Context {
20
+ const paths: Paths = {
21
+ globalDir: testDir,
22
+ globalConfig: path.join(testDir, 'config.json'),
23
+ localConfig: path.join(testDir, 'tmux-team.json'),
24
+ stateFile: path.join(testDir, 'state.json'),
25
+ };
26
+ const config: ResolvedConfig = {
27
+ mode: 'polling',
28
+ preambleMode: 'always',
29
+ defaults: { timeout: 180, pollInterval: 1, captureLines: 100, preambleEvery: 3 },
30
+ agents: {},
31
+ paneRegistry: {},
32
+ };
33
+ const flags: Flags = { json: false, verbose: false, ...(overrides?.flags ?? {}) } as Flags;
34
+ const tmux: Tmux = {
35
+ send: vi.fn(),
36
+ capture: vi.fn(),
37
+ listPanes: vi.fn(() => []),
38
+ getCurrentPaneId: vi.fn(() => null),
39
+ };
40
+ return {
41
+ argv: [],
42
+ flags,
43
+ ui: createMockUI(),
44
+ config,
45
+ tmux,
46
+ paths,
47
+ exit: ((code: number) => {
48
+ const err = new Error(`exit(${code})`);
49
+ (err as Error & { exitCode: number }).exitCode = code;
50
+ throw err;
51
+ }) as any,
52
+ };
53
+ }
54
+
55
+ describe('cmdInstall', () => {
56
+ let testDir = '';
57
+ let homeDir = '';
58
+ const originalHome = process.env.HOME;
59
+ const originalTmux = process.env.TMUX;
60
+ const originalCodexHome = process.env.CODEX_HOME;
61
+
62
+ beforeEach(() => {
63
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-install-'));
64
+ homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-home-'));
65
+ process.env.HOME = homeDir;
66
+ process.env.CODEX_HOME = path.join(homeDir, '.codex');
67
+ delete process.env.TMUX;
68
+ });
69
+
70
+ afterEach(() => {
71
+ process.env.HOME = originalHome;
72
+ process.env.TMUX = originalTmux;
73
+ process.env.CODEX_HOME = originalCodexHome;
74
+ fs.rmSync(testDir, { recursive: true, force: true });
75
+ fs.rmSync(homeDir, { recursive: true, force: true });
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ it('installs claude skill when agent is provided', async () => {
80
+ vi.resetModules();
81
+ vi.doMock('node:os', () => ({
82
+ default: { homedir: () => homeDir },
83
+ homedir: () => homeDir,
84
+ }));
85
+ vi.doMock('node:readline', () => ({
86
+ createInterface: () => ({
87
+ question: (_q: string, cb: (a: string) => void) => cb(''),
88
+ close: () => {},
89
+ }),
90
+ }));
91
+
92
+ const { cmdInstall } = await import('./install.js');
93
+ const ctx = createCtx(testDir, { flags: { force: true } });
94
+ await cmdInstall(ctx, 'claude');
95
+
96
+ const installed = path.join(homeDir, '.claude', 'commands', 'team.md');
97
+ expect(fs.existsSync(installed)).toBe(true);
98
+ });
99
+
100
+ it('errors on unknown agent', async () => {
101
+ vi.resetModules();
102
+ vi.doMock('node:os', () => ({
103
+ default: { homedir: () => homeDir },
104
+ homedir: () => homeDir,
105
+ }));
106
+ vi.doMock('node:readline', () => ({
107
+ createInterface: () => ({
108
+ question: (_q: string, cb: (a: string) => void) => cb(''),
109
+ close: () => {},
110
+ }),
111
+ }));
112
+ const { cmdInstall } = await import('./install.js');
113
+ const ctx = createCtx(testDir);
114
+ await expect(cmdInstall(ctx, 'nope')).rejects.toThrow(`exit(${ExitCodes.ERROR})`);
115
+ });
116
+
117
+ it('prompts when environment is not detected', async () => {
118
+ vi.resetModules();
119
+ const answers = ['codex'];
120
+ vi.doMock('node:os', () => ({
121
+ default: { homedir: () => homeDir },
122
+ homedir: () => homeDir,
123
+ }));
124
+ vi.doMock('node:readline', () => ({
125
+ createInterface: () => ({
126
+ question: (_q: string, cb: (a: string) => void) => cb(answers.shift() ?? ''),
127
+ close: () => {},
128
+ }),
129
+ }));
130
+
131
+ const { cmdInstall } = await import('./install.js');
132
+ const ctx = createCtx(testDir, { flags: { force: true } });
133
+ await cmdInstall(ctx);
134
+
135
+ const installed = path.join(homeDir, '.codex', 'skills', 'tmux-team', 'SKILL.md');
136
+ expect(fs.existsSync(installed)).toBe(true);
137
+ });
138
+
139
+ it('auto-selects detected environment when exactly one is found', async () => {
140
+ vi.resetModules();
141
+ fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
142
+ vi.doMock('node:os', () => ({
143
+ default: { homedir: () => homeDir },
144
+ homedir: () => homeDir,
145
+ }));
146
+ vi.doMock('node:readline', () => ({
147
+ createInterface: () => ({
148
+ question: (_q: string, cb: (a: string) => void) => cb(''),
149
+ close: () => {},
150
+ }),
151
+ }));
152
+
153
+ const { cmdInstall } = await import('./install.js');
154
+ const ctx = createCtx(testDir, { flags: { force: true } });
155
+ await cmdInstall(ctx);
156
+ expect(fs.existsSync(path.join(homeDir, '.claude', 'commands', 'team.md'))).toBe(true);
157
+ });
158
+
159
+ it('prompts when multiple environments are detected', async () => {
160
+ vi.resetModules();
161
+ fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
162
+ fs.mkdirSync(path.join(homeDir, '.codex'), { recursive: true });
163
+
164
+ vi.doMock('node:os', () => ({
165
+ default: { homedir: () => homeDir },
166
+ homedir: () => homeDir,
167
+ }));
168
+
169
+ const answers = ['claude'];
170
+ vi.doMock('node:readline', () => ({
171
+ createInterface: () => ({
172
+ question: (_q: string, cb: (a: string) => void) => cb(answers.shift() ?? ''),
173
+ close: () => {},
174
+ }),
175
+ }));
176
+
177
+ const { cmdInstall } = await import('./install.js');
178
+ const ctx = createCtx(testDir, { flags: { force: true } });
179
+ await cmdInstall(ctx);
180
+ expect(fs.existsSync(path.join(homeDir, '.claude', 'commands', 'team.md'))).toBe(true);
181
+ });
182
+
183
+ it('fails if skill exists and --force is not set', async () => {
184
+ vi.resetModules();
185
+ vi.doMock('node:os', () => ({
186
+ default: { homedir: () => homeDir },
187
+ homedir: () => homeDir,
188
+ }));
189
+ vi.doMock('node:readline', () => ({
190
+ createInterface: () => ({
191
+ question: (_q: string, cb: (a: string) => void) => cb(''),
192
+ close: () => {},
193
+ }),
194
+ }));
195
+
196
+ const target = path.join(homeDir, '.claude', 'commands', 'team.md');
197
+ fs.mkdirSync(path.dirname(target), { recursive: true });
198
+ fs.writeFileSync(target, 'existing');
199
+
200
+ const { cmdInstall } = await import('./install.js');
201
+ const ctx = createCtx(testDir);
202
+ await expect(cmdInstall(ctx, 'claude')).rejects.toThrow(`exit(${ExitCodes.ERROR})`);
203
+ expect(ctx.ui.warn).toHaveBeenCalled();
204
+ });
205
+ });