tmux-team 4.0.0 → 4.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/README.md +123 -25
- package/package.json +15 -16
- package/src/cli.test.ts +15 -1
- package/src/cli.ts +15 -1
- package/src/commands/add.ts +17 -32
- package/src/commands/basic-commands.test.ts +534 -17
- package/src/commands/check.ts +20 -0
- package/src/commands/completion.ts +6 -8
- package/src/commands/config-command.test.ts +9 -8
- package/src/commands/config.ts +1 -5
- package/src/commands/help.ts +8 -3
- package/src/commands/install.test.ts +15 -1
- package/src/commands/list.ts +21 -2
- package/src/commands/migrate.ts +84 -0
- package/src/commands/preamble.test.ts +15 -2
- package/src/commands/preamble.ts +61 -16
- package/src/commands/remove.ts +10 -6
- package/src/commands/talk.test.ts +132 -22
- package/src/commands/talk.ts +28 -3
- package/src/commands/team.ts +361 -0
- package/src/commands/update.ts +45 -14
- package/src/config.test.ts +24 -0
- package/src/config.ts +37 -3
- package/src/context.test.ts +76 -1
- package/src/context.ts +8 -1
- package/src/identity.test.ts +3 -9
- package/src/identity.ts +7 -9
- package/src/registry.test.ts +61 -0
- package/src/registry.ts +29 -0
- package/src/tmux.test.ts +190 -1
- package/src/tmux.ts +289 -9
- package/src/types.ts +55 -0
- package/src/ui.test.ts +7 -1
|
@@ -17,6 +17,8 @@ import { cmdConfig } from './config.js';
|
|
|
17
17
|
import { cmdCompletion } from './completion.js';
|
|
18
18
|
import { cmdHelp } from './help.js';
|
|
19
19
|
import { cmdLearn } from './learn.js';
|
|
20
|
+
import { cmdMigrate } from './migrate.js';
|
|
21
|
+
import { cmdTeam } from './team.js';
|
|
20
22
|
|
|
21
23
|
function createMockUI(): UI & { jsonCalls: unknown[] } {
|
|
22
24
|
return {
|
|
@@ -38,6 +40,13 @@ function createMockTmux(): Tmux {
|
|
|
38
40
|
capture: vi.fn(() => 'captured'),
|
|
39
41
|
listPanes: vi.fn(() => []),
|
|
40
42
|
getCurrentPaneId: vi.fn(() => null),
|
|
43
|
+
resolvePaneTarget: vi.fn((target: string) => target),
|
|
44
|
+
getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
|
|
45
|
+
setAgentRegistration: vi.fn(),
|
|
46
|
+
clearAgentRegistration: vi.fn(() => false),
|
|
47
|
+
listTeams: vi.fn(() => ({})),
|
|
48
|
+
listTeamPanes: vi.fn(() => []),
|
|
49
|
+
removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
|
|
41
50
|
};
|
|
42
51
|
}
|
|
43
52
|
|
|
@@ -54,7 +63,14 @@ function createCtx(
|
|
|
54
63
|
const baseConfig: ResolvedConfig = {
|
|
55
64
|
mode: 'polling',
|
|
56
65
|
preambleMode: 'always',
|
|
57
|
-
defaults: {
|
|
66
|
+
defaults: {
|
|
67
|
+
timeout: 180,
|
|
68
|
+
pollInterval: 1,
|
|
69
|
+
captureLines: 100,
|
|
70
|
+
maxCaptureLines: 2000,
|
|
71
|
+
preambleEvery: 3,
|
|
72
|
+
pasteEnterDelayMs: 500,
|
|
73
|
+
},
|
|
58
74
|
agents: {},
|
|
59
75
|
paneRegistry: {},
|
|
60
76
|
...overrides?.config,
|
|
@@ -107,12 +123,14 @@ describe('basic commands', () => {
|
|
|
107
123
|
expect((ctx.ui as any).jsonCalls[0]).toMatchObject({ created: ctx.paths.localConfig });
|
|
108
124
|
});
|
|
109
125
|
|
|
110
|
-
it('cmdAdd
|
|
126
|
+
it('cmdAdd writes new agent to tmux metadata', () => {
|
|
111
127
|
const ctx = createCtx(testDir);
|
|
112
128
|
cmdAdd(ctx, 'codex', '1.1', 'review');
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
130
|
+
'1.1',
|
|
131
|
+
expect.objectContaining({ type: 'workspace' }),
|
|
132
|
+
{ name: 'codex', remark: 'review' }
|
|
133
|
+
);
|
|
116
134
|
});
|
|
117
135
|
|
|
118
136
|
it('cmdAdd errors if agent exists', () => {
|
|
@@ -125,9 +143,11 @@ describe('basic commands', () => {
|
|
|
125
143
|
const ctx = createCtx(testDir);
|
|
126
144
|
(ctx.tmux.getCurrentPaneId as ReturnType<typeof vi.fn>).mockReturnValue('%5');
|
|
127
145
|
cmdThis(ctx, 'myagent', 'test remark');
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
146
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
147
|
+
'%5',
|
|
148
|
+
expect.objectContaining({ type: 'workspace' }),
|
|
149
|
+
{ name: 'myagent', remark: 'test remark' }
|
|
150
|
+
);
|
|
131
151
|
});
|
|
132
152
|
|
|
133
153
|
it('cmdThis errors when not in tmux', () => {
|
|
@@ -166,22 +186,32 @@ describe('basic commands', () => {
|
|
|
166
186
|
});
|
|
167
187
|
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ codex: { pane: '1.1' } }, null, 2));
|
|
168
188
|
cmdRemove(ctx, 'codex');
|
|
169
|
-
expect((ctx.ui as any).jsonCalls).toEqual([{ removed: 'codex' }]);
|
|
189
|
+
expect((ctx.ui as any).jsonCalls).toEqual([{ removed: 'codex', source: 'legacy' }]);
|
|
170
190
|
});
|
|
171
191
|
|
|
172
|
-
it('cmdUpdate updates pane and remark
|
|
192
|
+
it('cmdUpdate updates pane and remark in tmux metadata', () => {
|
|
173
193
|
const ctx = createCtx(testDir, { config: { paneRegistry: { codex: { pane: '1.1' } } } });
|
|
174
194
|
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({}, null, 2));
|
|
175
195
|
cmdUpdate(ctx, 'codex', { pane: '2.2', remark: 'new' });
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
196
|
+
expect(ctx.tmux.clearAgentRegistration).toHaveBeenCalledWith(
|
|
197
|
+
'codex',
|
|
198
|
+
expect.objectContaining({ type: 'workspace' })
|
|
199
|
+
);
|
|
200
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
201
|
+
'2.2',
|
|
202
|
+
expect.objectContaining({ type: 'workspace' }),
|
|
203
|
+
{ name: 'codex', remark: 'new' }
|
|
204
|
+
);
|
|
179
205
|
});
|
|
180
206
|
|
|
181
207
|
it('cmdUpdate errors when agent not found', () => {
|
|
182
208
|
const ctx = createCtx(testDir);
|
|
183
|
-
expect(() => cmdUpdate(ctx, 'notfound', { pane: '1.0' })).toThrow(
|
|
184
|
-
|
|
209
|
+
expect(() => cmdUpdate(ctx, 'notfound', { pane: '1.0' })).toThrow(
|
|
210
|
+
`exit(${ExitCodes.PANE_NOT_FOUND})`
|
|
211
|
+
);
|
|
212
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
213
|
+
"Agent 'notfound' not found. Use 'tmux-team add' to create."
|
|
214
|
+
);
|
|
185
215
|
});
|
|
186
216
|
|
|
187
217
|
it('cmdUpdate errors when no updates specified', () => {
|
|
@@ -197,7 +227,9 @@ describe('basic commands', () => {
|
|
|
197
227
|
});
|
|
198
228
|
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ codex: { pane: '1.1' } }, null, 2));
|
|
199
229
|
cmdUpdate(ctx, 'codex', { pane: '2.0', remark: 'updated' });
|
|
200
|
-
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
230
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
231
|
+
{ updated: 'codex', pane: '2.0', remark: 'updated' },
|
|
232
|
+
]);
|
|
201
233
|
});
|
|
202
234
|
|
|
203
235
|
it('cmdList outputs JSON when --json', () => {
|
|
@@ -215,6 +247,14 @@ describe('basic commands', () => {
|
|
|
215
247
|
expect(ctx.ui.info).toHaveBeenCalled();
|
|
216
248
|
});
|
|
217
249
|
|
|
250
|
+
it('cmdList prints team hint when no shared team agents exist', () => {
|
|
251
|
+
const ctx = createCtx(testDir, { flags: { team: 'egp' } });
|
|
252
|
+
cmdList(ctx);
|
|
253
|
+
expect(ctx.ui.info).toHaveBeenCalledWith(
|
|
254
|
+
'No agents in team "egp". Use \'tmt this <name> --team egp\' to add one.'
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
218
258
|
it('cmdList prints table when agents exist', () => {
|
|
219
259
|
const ctx = createCtx(testDir, {
|
|
220
260
|
config: { paneRegistry: { claude: { pane: '1.0', remark: 'main' } } },
|
|
@@ -223,6 +263,16 @@ describe('basic commands', () => {
|
|
|
223
263
|
expect(ctx.ui.table).toHaveBeenCalled();
|
|
224
264
|
});
|
|
225
265
|
|
|
266
|
+
it('cmdList warns when using legacy registry', () => {
|
|
267
|
+
const ctx = createCtx(testDir, {
|
|
268
|
+
config: { paneRegistry: { claude: { pane: '1.0' } }, registrySource: 'legacy' },
|
|
269
|
+
});
|
|
270
|
+
cmdList(ctx);
|
|
271
|
+
expect(ctx.ui.warn).toHaveBeenCalledWith(
|
|
272
|
+
'Using legacy tmux-team.json registry. Run `tmt migrate` to store registrations in tmux.'
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
226
276
|
it('cmdList shows dash for missing remark', () => {
|
|
227
277
|
const ctx = createCtx(testDir, {
|
|
228
278
|
config: { paneRegistry: { claude: { pane: '1.0' } } }, // no remark
|
|
@@ -234,6 +284,98 @@ describe('basic commands', () => {
|
|
|
234
284
|
expect(tableCall[1][0][2]).toBe('-');
|
|
235
285
|
});
|
|
236
286
|
|
|
287
|
+
it('cmdList can list a shared team by positional name', () => {
|
|
288
|
+
const ctx = createCtx(testDir);
|
|
289
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
290
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
291
|
+
{
|
|
292
|
+
pane: '%1',
|
|
293
|
+
target: 'main:1.0',
|
|
294
|
+
cwd: '/repo',
|
|
295
|
+
command: 'claude',
|
|
296
|
+
suggestedName: 'claude',
|
|
297
|
+
registrations: [{ scopeType: 'team', scope: 'egp', agent: 'claude' }],
|
|
298
|
+
},
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
cmdList(ctx, 'egp');
|
|
302
|
+
|
|
303
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
304
|
+
['NAME', 'PANE', 'TARGET', 'CWD', 'CMD', 'REMARK'],
|
|
305
|
+
[['claude', '%1', 'main:1.0', '/repo', 'claude', '-']]
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('cmdList can show pane status by pane target', () => {
|
|
310
|
+
const ctx = createCtx(testDir);
|
|
311
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockImplementation((target: string) =>
|
|
312
|
+
target === 'main:1.0' ? '%1' : null
|
|
313
|
+
);
|
|
314
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
315
|
+
{
|
|
316
|
+
pane: '%1',
|
|
317
|
+
target: 'main:1.0',
|
|
318
|
+
cwd: '/repo',
|
|
319
|
+
command: 'claude',
|
|
320
|
+
suggestedName: 'claude',
|
|
321
|
+
registrations: [
|
|
322
|
+
{ scopeType: 'workspace', scope: '/repo', agent: 'claude' },
|
|
323
|
+
{ scopeType: 'team', scope: 'egp', agent: 'reviewer', remark: 'strict' },
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
cmdList(ctx, 'main.1.0');
|
|
329
|
+
|
|
330
|
+
expect(ctx.tmux.resolvePaneTarget).toHaveBeenCalledWith('main.1.0');
|
|
331
|
+
expect(ctx.tmux.resolvePaneTarget).toHaveBeenCalledWith('main:1.0');
|
|
332
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
333
|
+
['SCOPE', 'NAME', 'REMARK'],
|
|
334
|
+
[
|
|
335
|
+
['workspace:/repo', 'claude', '-'],
|
|
336
|
+
['team:egp', 'reviewer', 'strict'],
|
|
337
|
+
]
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('cmdList outputs pane status JSON and handles unregistered panes', () => {
|
|
342
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
343
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue('%9');
|
|
344
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
345
|
+
{
|
|
346
|
+
pane: '%9',
|
|
347
|
+
target: 'main:9.0',
|
|
348
|
+
cwd: '/tmp',
|
|
349
|
+
command: 'zsh',
|
|
350
|
+
suggestedName: null,
|
|
351
|
+
registrations: [],
|
|
352
|
+
},
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
cmdList(ctx, '9.0');
|
|
356
|
+
|
|
357
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
358
|
+
{
|
|
359
|
+
pane: {
|
|
360
|
+
pane: '%9',
|
|
361
|
+
target: 'main:9.0',
|
|
362
|
+
cwd: '/tmp',
|
|
363
|
+
command: 'zsh',
|
|
364
|
+
suggestedName: null,
|
|
365
|
+
registrations: [],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('cmdList errors when positional target is neither team nor pane', () => {
|
|
372
|
+
const ctx = createCtx(testDir);
|
|
373
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
|
374
|
+
|
|
375
|
+
expect(() => cmdList(ctx, 'missing')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
|
|
376
|
+
expect(ctx.ui.error).toHaveBeenCalledWith("Pane or team 'missing' not found.");
|
|
377
|
+
});
|
|
378
|
+
|
|
237
379
|
it('cmdCheck captures pane output', () => {
|
|
238
380
|
const ctx = createCtx(testDir, {
|
|
239
381
|
config: { paneRegistry: { claude: { pane: '1.0' } } },
|
|
@@ -249,6 +391,31 @@ describe('basic commands', () => {
|
|
|
249
391
|
expect(() => cmdCheck(ctx, 'nope')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
|
|
250
392
|
});
|
|
251
393
|
|
|
394
|
+
it('cmdCheck points at shared teams when a missing agent is only registered there', () => {
|
|
395
|
+
const ctx = createCtx(testDir);
|
|
396
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
397
|
+
alpha: ['codex'],
|
|
398
|
+
beta: ['codex'],
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
expect(() => cmdCheck(ctx, 'codex')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
|
|
402
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
403
|
+
"Agent 'codex' is in multiple shared teams: alpha, beta. Specify one with --team <team>."
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('cmdCheck points at the single shared team when there is no ambiguity', () => {
|
|
408
|
+
const ctx = createCtx(testDir);
|
|
409
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
410
|
+
alpha: ['codex'],
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(() => cmdCheck(ctx, 'codex')).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
|
|
414
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
415
|
+
"Agent 'codex' is in shared team 'alpha'. Specify it: tmt check codex --team alpha"
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
252
419
|
it('cmdCheck errors when tmux capture fails', () => {
|
|
253
420
|
const ctx = createCtx(testDir, {
|
|
254
421
|
config: { paneRegistry: { claude: { pane: '1.0' } } },
|
|
@@ -327,7 +494,9 @@ describe('basic commands', () => {
|
|
|
327
494
|
it('cmdConfig set errors when not enough args', () => {
|
|
328
495
|
const ctx = createCtx(testDir);
|
|
329
496
|
expect(() => cmdConfig(ctx, ['set', 'mode'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
330
|
-
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
497
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
498
|
+
'Usage: tmux-team config set <key> <value> [--global]'
|
|
499
|
+
);
|
|
331
500
|
});
|
|
332
501
|
|
|
333
502
|
it('cmdConfig errors on unknown subcommand', () => {
|
|
@@ -360,6 +529,354 @@ describe('basic commands', () => {
|
|
|
360
529
|
);
|
|
361
530
|
});
|
|
362
531
|
|
|
532
|
+
it('cmdMigrate dry-run reports legacy entries without writing tmux metadata', () => {
|
|
533
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
534
|
+
fs.writeFileSync(
|
|
535
|
+
ctx.paths.localConfig,
|
|
536
|
+
JSON.stringify({ claude: { pane: '1.1', remark: 'review' } }, null, 2)
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
cmdMigrate(ctx, ['--dry-run']);
|
|
540
|
+
|
|
541
|
+
expect(ctx.tmux.setAgentRegistration).not.toHaveBeenCalled();
|
|
542
|
+
expect((ctx.ui as any).jsonCalls[0]).toMatchObject({
|
|
543
|
+
dryRun: true,
|
|
544
|
+
migrated: 0,
|
|
545
|
+
items: [{ agent: 'claude', fromPane: '1.1', pane: '1.1', status: 'ready' }],
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('cmdMigrate writes tmux metadata and can clean legacy entries', () => {
|
|
550
|
+
const ctx = createCtx(testDir);
|
|
551
|
+
fs.writeFileSync(
|
|
552
|
+
ctx.paths.localConfig,
|
|
553
|
+
JSON.stringify({
|
|
554
|
+
$config: { mode: 'wait' },
|
|
555
|
+
claude: { pane: '1.1', remark: 'review', preamble: 'Be helpful' },
|
|
556
|
+
})
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
cmdMigrate(ctx, ['--cleanup']);
|
|
560
|
+
|
|
561
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
562
|
+
'1.1',
|
|
563
|
+
expect.objectContaining({ type: 'workspace' }),
|
|
564
|
+
{ name: 'claude', remark: 'review', preamble: 'Be helpful' }
|
|
565
|
+
);
|
|
566
|
+
const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
|
|
567
|
+
expect(saved.$config.mode).toBe('wait');
|
|
568
|
+
expect(saved.claude).toBeUndefined();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('cmdMigrate errors when a legacy pane cannot be resolved', () => {
|
|
572
|
+
const ctx = createCtx(testDir);
|
|
573
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
|
574
|
+
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ claude: { pane: 'missing' } }));
|
|
575
|
+
|
|
576
|
+
expect(() => cmdMigrate(ctx, [])).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('cmdMigrate reports when no legacy agents exist', () => {
|
|
580
|
+
const ctx = createCtx(testDir);
|
|
581
|
+
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ $config: { mode: 'wait' } }));
|
|
582
|
+
|
|
583
|
+
cmdMigrate(ctx, []);
|
|
584
|
+
|
|
585
|
+
expect(ctx.ui.info).toHaveBeenCalledWith(`No legacy agents found in ${ctx.paths.localConfig}`);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('cmdTeam lists team names by default', () => {
|
|
589
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
590
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
591
|
+
egp: ['claude', 'codex'],
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
cmdTeam(ctx, []);
|
|
595
|
+
|
|
596
|
+
expect((ctx.ui as any).jsonCalls).toEqual([{ teams: { egp: ['claude', 'codex'] } }]);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('cmdTeam panes lists all pane team/workspace scopes', () => {
|
|
600
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
601
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
602
|
+
egp: ['claude', 'codex'],
|
|
603
|
+
});
|
|
604
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
605
|
+
{
|
|
606
|
+
pane: '%1',
|
|
607
|
+
target: 'main:1.0',
|
|
608
|
+
cwd: '/repo',
|
|
609
|
+
command: 'claude',
|
|
610
|
+
suggestedName: 'claude',
|
|
611
|
+
registrations: [
|
|
612
|
+
{ scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
|
|
613
|
+
{ scopeType: 'team', scope: 'egp', agent: 'claude' },
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
cmdTeam(ctx, ['panes']);
|
|
619
|
+
|
|
620
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
621
|
+
{
|
|
622
|
+
teams: { egp: ['claude', 'codex'] },
|
|
623
|
+
panes: [
|
|
624
|
+
{
|
|
625
|
+
pane: '%1',
|
|
626
|
+
target: 'main:1.0',
|
|
627
|
+
cwd: '/repo',
|
|
628
|
+
command: 'claude',
|
|
629
|
+
suggestedName: 'claude',
|
|
630
|
+
registrations: [
|
|
631
|
+
{ scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
|
|
632
|
+
{ scopeType: 'team', scope: 'egp', agent: 'claude' },
|
|
633
|
+
],
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('cmdTeam shows empty state and errors on unknown subcommands', () => {
|
|
641
|
+
const ctx = createCtx(testDir);
|
|
642
|
+
|
|
643
|
+
cmdTeam(ctx, []);
|
|
644
|
+
expect(ctx.ui.info).toHaveBeenCalledWith('No shared teams found.');
|
|
645
|
+
|
|
646
|
+
expect(() => cmdTeam(ctx, ['wat'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('cmdTeam summary keeps shared-team aggregate view', () => {
|
|
650
|
+
const ctx = createCtx(testDir);
|
|
651
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
652
|
+
|
|
653
|
+
cmdTeam(ctx, ['ls', '--summary']);
|
|
654
|
+
|
|
655
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(['TEAM', 'AGENTS'], [['egp', 'claude']]);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('cmdTeam ls lists members for one team', () => {
|
|
659
|
+
const ctx = createCtx(testDir);
|
|
660
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
661
|
+
{
|
|
662
|
+
pane: '%2',
|
|
663
|
+
target: 'main:2.0',
|
|
664
|
+
cwd: '/repo',
|
|
665
|
+
command: 'codex',
|
|
666
|
+
suggestedName: 'codex',
|
|
667
|
+
registrations: [{ scopeType: 'team', scope: 'egp', agent: 'codex', remark: 'review' }],
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
pane: '%1',
|
|
671
|
+
target: 'main:1.0',
|
|
672
|
+
cwd: '/repo',
|
|
673
|
+
command: 'claude',
|
|
674
|
+
suggestedName: 'claude',
|
|
675
|
+
registrations: [{ scopeType: 'team', scope: 'egp', agent: 'claude' }],
|
|
676
|
+
},
|
|
677
|
+
]);
|
|
678
|
+
|
|
679
|
+
cmdTeam(ctx, ['ls', 'egp']);
|
|
680
|
+
|
|
681
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(
|
|
682
|
+
['NAME', 'PANE', 'TARGET', 'CWD', 'CMD', 'REMARK'],
|
|
683
|
+
[
|
|
684
|
+
['claude', '%1', 'main:1.0', '/repo', 'claude', '-'],
|
|
685
|
+
['codex', '%2', 'main:2.0', '/repo', 'codex', 'review'],
|
|
686
|
+
]
|
|
687
|
+
);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('cmdTeam ls outputs JSON and reports empty teams', () => {
|
|
691
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
692
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
|
693
|
+
|
|
694
|
+
cmdTeam(ctx, ['ls', 'missing']);
|
|
695
|
+
|
|
696
|
+
expect((ctx.ui as any).jsonCalls).toEqual([{ team: 'missing', members: [] }]);
|
|
697
|
+
|
|
698
|
+
const humanCtx = createCtx(testDir);
|
|
699
|
+
cmdTeam(humanCtx, ['ls', 'missing']);
|
|
700
|
+
expect(humanCtx.ui.info).toHaveBeenCalledWith(
|
|
701
|
+
'No agents in team "missing". Use \'tmt team add missing <name>\' to add one.'
|
|
702
|
+
);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('cmdTeam add registers current pane in a team', () => {
|
|
706
|
+
const ctx = createCtx(testDir);
|
|
707
|
+
(ctx.tmux.getCurrentPaneId as ReturnType<typeof vi.fn>).mockReturnValue('%5');
|
|
708
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue('%5');
|
|
709
|
+
|
|
710
|
+
cmdTeam(ctx, ['add', 'egp', 'claude']);
|
|
711
|
+
|
|
712
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
713
|
+
'%5',
|
|
714
|
+
{ type: 'team', teamName: 'egp' },
|
|
715
|
+
{ name: 'claude' }
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('cmdTeam add supports explicit panes, remarks, JSON, and duplicate errors', () => {
|
|
720
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
721
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue('%6');
|
|
722
|
+
|
|
723
|
+
cmdTeam(ctx, ['add', 'egp', 'codex', '1.2', 'reviewer']);
|
|
724
|
+
|
|
725
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
726
|
+
'%6',
|
|
727
|
+
{ type: 'team', teamName: 'egp' },
|
|
728
|
+
{ name: 'codex', remark: 'reviewer' }
|
|
729
|
+
);
|
|
730
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
731
|
+
{ added: 'codex', team: 'egp', pane: '%6', remark: 'reviewer' },
|
|
732
|
+
]);
|
|
733
|
+
|
|
734
|
+
const duplicateCtx = createCtx(testDir);
|
|
735
|
+
(duplicateCtx.tmux.getAgentRegistry as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
736
|
+
paneRegistry: { codex: { pane: '%6' } },
|
|
737
|
+
agents: {},
|
|
738
|
+
});
|
|
739
|
+
expect(() => cmdTeam(duplicateCtx, ['add', 'egp', 'codex', '1.2'])).toThrow(
|
|
740
|
+
`exit(${ExitCodes.ERROR})`
|
|
741
|
+
);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('cmdTeam add requires a pane when outside tmux', () => {
|
|
745
|
+
const ctx = createCtx(testDir);
|
|
746
|
+
|
|
747
|
+
expect(() => cmdTeam(ctx, ['add', 'egp', 'codex'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
748
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
749
|
+
'Not running inside tmux. Provide a pane target: tmt team add <team> <name> <pane>'
|
|
750
|
+
);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('cmdTeam add validates arguments and pane targets', () => {
|
|
754
|
+
const ctx = createCtx(testDir);
|
|
755
|
+
expect(() => cmdTeam(ctx, ['add', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
756
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
757
|
+
'Usage: tmux-team team add <team> <name> [pane] [remark]'
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
const paneCtx = createCtx(testDir);
|
|
761
|
+
(paneCtx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
|
762
|
+
expect(() => cmdTeam(paneCtx, ['add', 'egp', 'codex', 'missing'])).toThrow(
|
|
763
|
+
`exit(${ExitCodes.PANE_NOT_FOUND})`
|
|
764
|
+
);
|
|
765
|
+
expect(paneCtx.ui.error).toHaveBeenCalledWith("Pane 'missing' not found. Is tmux running?");
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('cmdTeam panes table groups by team/workspace scope before pane order', () => {
|
|
769
|
+
const ctx = createCtx(testDir);
|
|
770
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
771
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
772
|
+
{
|
|
773
|
+
pane: '%3',
|
|
774
|
+
target: 'main:3.0',
|
|
775
|
+
cwd: '/tmp',
|
|
776
|
+
command: 'zsh',
|
|
777
|
+
suggestedName: null,
|
|
778
|
+
registrations: [],
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
pane: '%1',
|
|
782
|
+
target: 'main:1.0',
|
|
783
|
+
cwd: '/repo',
|
|
784
|
+
command: 'claude',
|
|
785
|
+
suggestedName: 'claude',
|
|
786
|
+
registrations: [{ scopeType: 'workspace', scope: '/repo', agent: 'claude' }],
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
pane: '%2',
|
|
790
|
+
target: 'main:2.0',
|
|
791
|
+
cwd: '/repo',
|
|
792
|
+
command: 'codex',
|
|
793
|
+
suggestedName: 'codex',
|
|
794
|
+
registrations: [{ scopeType: 'team', scope: 'beta', agent: 'codex' }],
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
pane: '%4',
|
|
798
|
+
target: 'main:4.0',
|
|
799
|
+
cwd: '/repo',
|
|
800
|
+
command: 'gemini',
|
|
801
|
+
suggestedName: 'gemini',
|
|
802
|
+
registrations: [{ scopeType: 'team', scope: 'alpha', agent: 'gemini' }],
|
|
803
|
+
},
|
|
804
|
+
]);
|
|
805
|
+
|
|
806
|
+
cmdTeam(ctx, ['panes']);
|
|
807
|
+
|
|
808
|
+
expect(logSpy.mock.calls.map((call) => call[0])).toEqual([
|
|
809
|
+
'Team: alpha (gemini)',
|
|
810
|
+
'',
|
|
811
|
+
'Team: beta (codex)',
|
|
812
|
+
'',
|
|
813
|
+
'Workspace: /repo (claude)',
|
|
814
|
+
'',
|
|
815
|
+
'Unregistered panes',
|
|
816
|
+
]);
|
|
817
|
+
expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0])).toEqual([
|
|
818
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
819
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
820
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
821
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
822
|
+
]);
|
|
823
|
+
expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls[0][1]).toEqual([
|
|
824
|
+
['%4', 'main:4.0', '/repo', 'gemini'],
|
|
825
|
+
]);
|
|
826
|
+
logSpy.mockRestore();
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('cmdTeam rm supports dry-run and force removal', () => {
|
|
830
|
+
const ctx = createCtx(testDir);
|
|
831
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
832
|
+
egp: ['claude'],
|
|
833
|
+
});
|
|
834
|
+
(ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
835
|
+
removed: 1,
|
|
836
|
+
agents: ['claude'],
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
|
|
840
|
+
expect(ctx.tmux.removeTeam).not.toHaveBeenCalled();
|
|
841
|
+
|
|
842
|
+
ctx.flags.force = true;
|
|
843
|
+
cmdTeam(ctx, ['rm', 'egp']);
|
|
844
|
+
expect(ctx.tmux.removeTeam).toHaveBeenCalledWith('egp');
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('cmdTeam rm requires --force and errors for missing teams', () => {
|
|
848
|
+
const ctx = createCtx(testDir);
|
|
849
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
850
|
+
|
|
851
|
+
expect(() => cmdTeam(ctx, ['rm'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
852
|
+
expect(ctx.ui.error).toHaveBeenCalledWith('Usage: tmux-team team rm <team> --force');
|
|
853
|
+
|
|
854
|
+
expect(() => cmdTeam(ctx, ['rm', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
855
|
+
|
|
856
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({});
|
|
857
|
+
expect(() => cmdTeam(ctx, ['rm', 'missing', '--force'])).toThrow(
|
|
858
|
+
`exit(${ExitCodes.PANE_NOT_FOUND})`
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('cmdTeam rm outputs JSON in dry-run and force modes', () => {
|
|
863
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
864
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
865
|
+
(ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
866
|
+
removed: 1,
|
|
867
|
+
agents: ['claude'],
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
|
|
871
|
+
ctx.flags.force = true;
|
|
872
|
+
cmdTeam(ctx, ['rm', 'egp']);
|
|
873
|
+
|
|
874
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
875
|
+
{ team: 'egp', dryRun: true, agents: ['claude'], removed: 0 },
|
|
876
|
+
{ team: 'egp', removed: 1, agents: ['claude'] },
|
|
877
|
+
]);
|
|
878
|
+
});
|
|
879
|
+
|
|
363
880
|
it('cmdCompletion prints scripts', () => {
|
|
364
881
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
365
882
|
cmdCompletion('bash');
|
package/src/commands/check.ts
CHANGED
|
@@ -6,11 +6,31 @@ import type { Context } from '../types.js';
|
|
|
6
6
|
import { ExitCodes } from '../exits.js';
|
|
7
7
|
import { colors } from '../ui.js';
|
|
8
8
|
|
|
9
|
+
function teamHintForMissingAgent(ctx: Context, target: string): string | null {
|
|
10
|
+
if (ctx.flags.team) return null;
|
|
11
|
+
|
|
12
|
+
const matches = Object.entries(ctx.tmux.listTeams())
|
|
13
|
+
.filter(([, agents]) => agents.includes(target))
|
|
14
|
+
.map(([teamName]) => teamName)
|
|
15
|
+
.sort();
|
|
16
|
+
|
|
17
|
+
if (matches.length === 0) return null;
|
|
18
|
+
if (matches.length === 1) {
|
|
19
|
+
return `Agent '${target}' is in shared team '${matches[0]}'. Specify it: tmt check ${target} --team ${matches[0]}`;
|
|
20
|
+
}
|
|
21
|
+
return `Agent '${target}' is in multiple shared teams: ${matches.join(', ')}. Specify one with --team <team>.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
export function cmdCheck(ctx: Context, target: string, lines?: number): void {
|
|
10
25
|
const { ui, config, tmux, flags, exit } = ctx;
|
|
11
26
|
|
|
12
27
|
if (!config.paneRegistry[target]) {
|
|
13
28
|
const available = Object.keys(config.paneRegistry).join(', ');
|
|
29
|
+
const teamHint = teamHintForMissingAgent(ctx, target);
|
|
30
|
+
if (teamHint) {
|
|
31
|
+
ui.error(teamHint);
|
|
32
|
+
exit(ExitCodes.PANE_NOT_FOUND);
|
|
33
|
+
}
|
|
14
34
|
ui.error(`Agent '${target}' not found. Available: ${available || 'none'}`);
|
|
15
35
|
exit(ExitCodes.PANE_NOT_FOUND);
|
|
16
36
|
}
|