tmux-team 3.0.1 → 3.2.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.
package/src/cli.ts CHANGED
@@ -19,7 +19,8 @@ import { cmdCheck } from './commands/check.js';
19
19
  import { cmdCompletion } from './commands/completion.js';
20
20
  import { cmdConfig } from './commands/config.js';
21
21
  import { cmdPreamble } from './commands/preamble.js';
22
- import { cmdInstallSkill } from './commands/install-skill.js';
22
+ import { cmdInstall } from './commands/install.js';
23
+ import { cmdSetup } from './commands/setup.js';
23
24
  import { cmdLearn } from './commands/learn.js';
24
25
 
25
26
  // ─────────────────────────────────────────────────────────────
@@ -42,6 +43,8 @@ function parseArgs(argv: string[]): { command: string; args: string[]; flags: Fl
42
43
  flags.json = true;
43
44
  } else if (arg === '--verbose' || arg === '-v') {
44
45
  flags.verbose = true;
46
+ } else if (arg === '--debug') {
47
+ flags.debug = true;
45
48
  } else if (arg === '--force' || arg === '-f') {
46
49
  flags.force = true;
47
50
  } else if (arg === '--config') {
@@ -52,6 +55,8 @@ function parseArgs(argv: string[]): { command: string; args: string[]; flags: Fl
52
55
  flags.wait = true;
53
56
  } else if (arg === '--timeout') {
54
57
  flags.timeout = parseTime(argv[++i]);
58
+ } else if (arg === '--lines') {
59
+ flags.lines = parseInt(argv[++i], 10) || 100;
55
60
  } else if (arg === '--no-preamble') {
56
61
  flags.noPreamble = true;
57
62
  } else if (arg.startsWith('--pane=')) {
@@ -123,6 +128,7 @@ function main(): void {
123
128
  cmdHelp({ showIntro });
124
129
  }
125
130
  process.exit(ExitCodes.SUCCESS);
131
+ return;
126
132
  }
127
133
 
128
134
  if (command === '--version' || command === '-V') {
@@ -134,11 +140,18 @@ function main(): void {
134
140
  if (command === 'completion') {
135
141
  cmdCompletion(args[0]);
136
142
  process.exit(ExitCodes.SUCCESS);
143
+ return;
137
144
  }
138
145
 
139
146
  // Create context for all other commands
140
147
  const ctx = createContext({ argv, flags });
141
148
 
149
+ // Warn if not in tmux for commands that require it
150
+ const TMUX_REQUIRED_COMMANDS = ['talk', 'send', 'check', 'read', 'setup'];
151
+ if (!process.env.TMUX && TMUX_REQUIRED_COMMANDS.includes(command)) {
152
+ ctx.ui.warn('Not running inside tmux. Some features may not work.');
153
+ }
154
+
142
155
  const run = async (): Promise<void> => {
143
156
  switch (command) {
144
157
  case 'init':
@@ -215,22 +228,12 @@ function main(): void {
215
228
  cmdPreamble(ctx, args);
216
229
  break;
217
230
 
218
- case 'install-skill':
219
- {
220
- // Parse --local or --user flag
221
- let scope = 'user';
222
- const filteredArgs: string[] = [];
223
- for (const arg of args) {
224
- if (arg === '--local') {
225
- scope = 'local';
226
- } else if (arg === '--user') {
227
- scope = 'user';
228
- } else {
229
- filteredArgs.push(arg);
230
- }
231
- }
232
- cmdInstallSkill(ctx, filteredArgs[0], scope);
233
- }
231
+ case 'install':
232
+ await cmdInstall(ctx, args[0]);
233
+ break;
234
+
235
+ case 'setup':
236
+ await cmdSetup(ctx);
234
237
  break;
235
238
 
236
239
  case 'learn':
@@ -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
+ });
@@ -51,10 +51,11 @@ ${colors.yellow('COMMANDS')}
51
51
  ${colors.green('add')} <name> <pane> [remark] Add a new agent
52
52
  ${colors.green('update')} <name> [options] Update an agent's config
53
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
54
56
  ${colors.green('init')} Create empty tmux-team.json
55
57
  ${colors.green('config')} [show|set|clear] View/modify settings
56
58
  ${colors.green('preamble')} [show|set|clear] Manage agent preambles
57
- ${colors.green('install-skill')} <agent> Install skill for AI agent
58
59
  ${colors.green('completion')} Output shell completion script
59
60
  ${colors.green('learn')} Show educational guide
60
61
  ${colors.green('help')} Show this help message
@@ -68,7 +69,9 @@ ${colors.yellow('TALK OPTIONS')}
68
69
  ${colors.green('--delay')} <seconds> Wait before sending
69
70
  ${colors.green('--wait')} Force wait mode (block until response)
70
71
  ${colors.green('--timeout')} <seconds> Max wait time (current: ${timeout}s)
72
+ ${colors.green('--lines')} <number> Lines to capture (default: 100)
71
73
  ${colors.green('--no-preamble')} Skip agent preamble for this message
74
+ ${colors.green('--debug')} Show debug output
72
75
 
73
76
  ${colors.yellow('EXAMPLES')}${
74
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
+ });