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.
- package/README.md +56 -248
- package/package.json +5 -3
- package/skills/README.md +9 -10
- package/src/cli.test.ts +163 -0
- package/src/cli.ts +29 -18
- package/src/commands/basic-commands.test.ts +252 -0
- package/src/commands/config-command.test.ts +116 -0
- package/src/commands/help.ts +19 -3
- package/src/commands/install.test.ts +205 -0
- package/src/commands/install.ts +207 -0
- package/src/commands/learn.ts +80 -0
- package/src/commands/setup.test.ts +175 -0
- package/src/commands/setup.ts +163 -0
- package/src/commands/talk.test.ts +169 -101
- package/src/commands/talk.ts +186 -98
- package/src/context.test.ts +68 -0
- package/src/identity.test.ts +70 -0
- package/src/state.test.ts +14 -0
- package/src/tmux.test.ts +50 -0
- package/src/tmux.ts +66 -2
- package/src/types.ts +10 -1
- package/src/ui.test.ts +63 -0
- package/src/version.test.ts +31 -0
- package/src/version.ts +1 -1
- package/src/commands/install-skill.ts +0 -148
|
@@ -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
|
+
});
|
package/src/commands/help.ts
CHANGED
|
@@ -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
|
+
});
|