tmux-team 4.0.0 → 4.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 +102 -24
- package/package.json +1 -1
- package/src/cli.test.ts +15 -1
- package/src/cli.ts +10 -0
- package/src/commands/add.ts +17 -32
- package/src/commands/basic-commands.test.ts +293 -17
- 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 +5 -2
- package/src/commands/install.test.ts +15 -1
- package/src/commands/list.ts +7 -1
- 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 +111 -22
- package/src/commands/talk.ts +8 -3
- package/src/commands/team.ts +172 -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
|
|
@@ -327,7 +377,9 @@ describe('basic commands', () => {
|
|
|
327
377
|
it('cmdConfig set errors when not enough args', () => {
|
|
328
378
|
const ctx = createCtx(testDir);
|
|
329
379
|
expect(() => cmdConfig(ctx, ['set', 'mode'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
330
|
-
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
380
|
+
expect(ctx.ui.error).toHaveBeenCalledWith(
|
|
381
|
+
'Usage: tmux-team config set <key> <value> [--global]'
|
|
382
|
+
);
|
|
331
383
|
});
|
|
332
384
|
|
|
333
385
|
it('cmdConfig errors on unknown subcommand', () => {
|
|
@@ -360,6 +412,230 @@ describe('basic commands', () => {
|
|
|
360
412
|
);
|
|
361
413
|
});
|
|
362
414
|
|
|
415
|
+
it('cmdMigrate dry-run reports legacy entries without writing tmux metadata', () => {
|
|
416
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
417
|
+
fs.writeFileSync(
|
|
418
|
+
ctx.paths.localConfig,
|
|
419
|
+
JSON.stringify({ claude: { pane: '1.1', remark: 'review' } }, null, 2)
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
cmdMigrate(ctx, ['--dry-run']);
|
|
423
|
+
|
|
424
|
+
expect(ctx.tmux.setAgentRegistration).not.toHaveBeenCalled();
|
|
425
|
+
expect((ctx.ui as any).jsonCalls[0]).toMatchObject({
|
|
426
|
+
dryRun: true,
|
|
427
|
+
migrated: 0,
|
|
428
|
+
items: [{ agent: 'claude', fromPane: '1.1', pane: '1.1', status: 'ready' }],
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('cmdMigrate writes tmux metadata and can clean legacy entries', () => {
|
|
433
|
+
const ctx = createCtx(testDir);
|
|
434
|
+
fs.writeFileSync(
|
|
435
|
+
ctx.paths.localConfig,
|
|
436
|
+
JSON.stringify({
|
|
437
|
+
$config: { mode: 'wait' },
|
|
438
|
+
claude: { pane: '1.1', remark: 'review', preamble: 'Be helpful' },
|
|
439
|
+
})
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
cmdMigrate(ctx, ['--cleanup']);
|
|
443
|
+
|
|
444
|
+
expect(ctx.tmux.setAgentRegistration).toHaveBeenCalledWith(
|
|
445
|
+
'1.1',
|
|
446
|
+
expect.objectContaining({ type: 'workspace' }),
|
|
447
|
+
{ name: 'claude', remark: 'review', preamble: 'Be helpful' }
|
|
448
|
+
);
|
|
449
|
+
const saved = JSON.parse(fs.readFileSync(ctx.paths.localConfig, 'utf-8'));
|
|
450
|
+
expect(saved.$config.mode).toBe('wait');
|
|
451
|
+
expect(saved.claude).toBeUndefined();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('cmdMigrate errors when a legacy pane cannot be resolved', () => {
|
|
455
|
+
const ctx = createCtx(testDir);
|
|
456
|
+
(ctx.tmux.resolvePaneTarget as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
|
457
|
+
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ claude: { pane: 'missing' } }));
|
|
458
|
+
|
|
459
|
+
expect(() => cmdMigrate(ctx, [])).toThrow(`exit(${ExitCodes.PANE_NOT_FOUND})`);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('cmdMigrate reports when no legacy agents exist', () => {
|
|
463
|
+
const ctx = createCtx(testDir);
|
|
464
|
+
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ $config: { mode: 'wait' } }));
|
|
465
|
+
|
|
466
|
+
cmdMigrate(ctx, []);
|
|
467
|
+
|
|
468
|
+
expect(ctx.ui.info).toHaveBeenCalledWith(`No legacy agents found in ${ctx.paths.localConfig}`);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('cmdTeam lists all pane team/workspace scopes', () => {
|
|
472
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
473
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
474
|
+
egp: ['claude', 'codex'],
|
|
475
|
+
});
|
|
476
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
477
|
+
{
|
|
478
|
+
pane: '%1',
|
|
479
|
+
target: 'main:1.0',
|
|
480
|
+
cwd: '/repo',
|
|
481
|
+
command: 'claude',
|
|
482
|
+
suggestedName: 'claude',
|
|
483
|
+
registrations: [
|
|
484
|
+
{ scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
|
|
485
|
+
{ scopeType: 'team', scope: 'egp', agent: 'claude' },
|
|
486
|
+
],
|
|
487
|
+
},
|
|
488
|
+
]);
|
|
489
|
+
|
|
490
|
+
cmdTeam(ctx, ['ls']);
|
|
491
|
+
|
|
492
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
493
|
+
{
|
|
494
|
+
teams: { egp: ['claude', 'codex'] },
|
|
495
|
+
panes: [
|
|
496
|
+
{
|
|
497
|
+
pane: '%1',
|
|
498
|
+
target: 'main:1.0',
|
|
499
|
+
cwd: '/repo',
|
|
500
|
+
command: 'claude',
|
|
501
|
+
suggestedName: 'claude',
|
|
502
|
+
registrations: [
|
|
503
|
+
{ scopeType: 'workspace', scope: '/repo', agent: 'claude', remark: 'lead' },
|
|
504
|
+
{ scopeType: 'team', scope: 'egp', agent: 'claude' },
|
|
505
|
+
],
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
},
|
|
509
|
+
]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('cmdTeam shows empty state and errors on unknown subcommands', () => {
|
|
513
|
+
const ctx = createCtx(testDir);
|
|
514
|
+
|
|
515
|
+
cmdTeam(ctx, ['ls']);
|
|
516
|
+
expect(ctx.ui.info).toHaveBeenCalledWith('No tmux panes found.');
|
|
517
|
+
|
|
518
|
+
expect(() => cmdTeam(ctx, ['wat'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('cmdTeam summary keeps shared-team aggregate view', () => {
|
|
522
|
+
const ctx = createCtx(testDir);
|
|
523
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
524
|
+
|
|
525
|
+
cmdTeam(ctx, ['ls', '--summary']);
|
|
526
|
+
|
|
527
|
+
expect(ctx.ui.table).toHaveBeenCalledWith(['TEAM', 'AGENTS'], [['egp', 'claude']]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('cmdTeam table groups by team/workspace scope before pane order', () => {
|
|
531
|
+
const ctx = createCtx(testDir);
|
|
532
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
533
|
+
(ctx.tmux.listTeamPanes as ReturnType<typeof vi.fn>).mockReturnValue([
|
|
534
|
+
{
|
|
535
|
+
pane: '%3',
|
|
536
|
+
target: 'main:3.0',
|
|
537
|
+
cwd: '/tmp',
|
|
538
|
+
command: 'zsh',
|
|
539
|
+
suggestedName: null,
|
|
540
|
+
registrations: [],
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
pane: '%1',
|
|
544
|
+
target: 'main:1.0',
|
|
545
|
+
cwd: '/repo',
|
|
546
|
+
command: 'claude',
|
|
547
|
+
suggestedName: 'claude',
|
|
548
|
+
registrations: [{ scopeType: 'workspace', scope: '/repo', agent: 'claude' }],
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
pane: '%2',
|
|
552
|
+
target: 'main:2.0',
|
|
553
|
+
cwd: '/repo',
|
|
554
|
+
command: 'codex',
|
|
555
|
+
suggestedName: 'codex',
|
|
556
|
+
registrations: [{ scopeType: 'team', scope: 'beta', agent: 'codex' }],
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
pane: '%4',
|
|
560
|
+
target: 'main:4.0',
|
|
561
|
+
cwd: '/repo',
|
|
562
|
+
command: 'gemini',
|
|
563
|
+
suggestedName: 'gemini',
|
|
564
|
+
registrations: [{ scopeType: 'team', scope: 'alpha', agent: 'gemini' }],
|
|
565
|
+
},
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
cmdTeam(ctx, ['ls']);
|
|
569
|
+
|
|
570
|
+
expect(logSpy.mock.calls.map((call) => call[0])).toEqual([
|
|
571
|
+
'Team: alpha (gemini)',
|
|
572
|
+
'',
|
|
573
|
+
'Team: beta (codex)',
|
|
574
|
+
'',
|
|
575
|
+
'Workspace: /repo (claude)',
|
|
576
|
+
'',
|
|
577
|
+
'Unregistered panes',
|
|
578
|
+
]);
|
|
579
|
+
expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0])).toEqual([
|
|
580
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
581
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
582
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
583
|
+
['PANE', 'TARGET', 'CWD', 'CMD'],
|
|
584
|
+
]);
|
|
585
|
+
expect((ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls[0][1]).toEqual([
|
|
586
|
+
['%4', 'main:4.0', '/repo', 'gemini'],
|
|
587
|
+
]);
|
|
588
|
+
logSpy.mockRestore();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('cmdTeam rm supports dry-run and force removal', () => {
|
|
592
|
+
const ctx = createCtx(testDir);
|
|
593
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
594
|
+
egp: ['claude'],
|
|
595
|
+
});
|
|
596
|
+
(ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
597
|
+
removed: 1,
|
|
598
|
+
agents: ['claude'],
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
|
|
602
|
+
expect(ctx.tmux.removeTeam).not.toHaveBeenCalled();
|
|
603
|
+
|
|
604
|
+
ctx.flags.force = true;
|
|
605
|
+
cmdTeam(ctx, ['rm', 'egp']);
|
|
606
|
+
expect(ctx.tmux.removeTeam).toHaveBeenCalledWith('egp');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('cmdTeam rm requires --force and errors for missing teams', () => {
|
|
610
|
+
const ctx = createCtx(testDir);
|
|
611
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
612
|
+
|
|
613
|
+
expect(() => cmdTeam(ctx, ['rm', 'egp'])).toThrow(`exit(${ExitCodes.ERROR})`);
|
|
614
|
+
|
|
615
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({});
|
|
616
|
+
expect(() => cmdTeam(ctx, ['rm', 'missing', '--force'])).toThrow(
|
|
617
|
+
`exit(${ExitCodes.PANE_NOT_FOUND})`
|
|
618
|
+
);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('cmdTeam rm outputs JSON in dry-run and force modes', () => {
|
|
622
|
+
const ctx = createCtx(testDir, { flags: { json: true } });
|
|
623
|
+
(ctx.tmux.listTeams as ReturnType<typeof vi.fn>).mockReturnValue({ egp: ['claude'] });
|
|
624
|
+
(ctx.tmux.removeTeam as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
625
|
+
removed: 1,
|
|
626
|
+
agents: ['claude'],
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
cmdTeam(ctx, ['rm', 'egp', '--dry-run']);
|
|
630
|
+
ctx.flags.force = true;
|
|
631
|
+
cmdTeam(ctx, ['rm', 'egp']);
|
|
632
|
+
|
|
633
|
+
expect((ctx.ui as any).jsonCalls).toEqual([
|
|
634
|
+
{ team: 'egp', dryRun: true, agents: ['claude'], removed: 0 },
|
|
635
|
+
{ team: 'egp', removed: 1, agents: ['claude'] },
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
|
|
363
639
|
it('cmdCompletion prints scripts', () => {
|
|
364
640
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
365
641
|
cmdCompletion('bash');
|
|
@@ -16,15 +16,15 @@ _tmux-team() {
|
|
|
16
16
|
'add:Add a new agent'
|
|
17
17
|
'update:Update agent config'
|
|
18
18
|
'remove:Remove an agent'
|
|
19
|
-
'
|
|
19
|
+
'migrate:Copy legacy tmux-team.json registry into tmux metadata'
|
|
20
|
+
'team:Manage shared teams'
|
|
21
|
+
'init:Create empty legacy tmux-team.json'
|
|
20
22
|
'completion:Output shell completion script'
|
|
21
23
|
'help:Show help message'
|
|
22
24
|
)
|
|
23
25
|
|
|
24
26
|
_get_agents() {
|
|
25
|
-
|
|
26
|
-
agents=(\${(f)"$(node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('./tmux-team.json'))).join('\\\\n'))" 2>/dev/null)"})
|
|
27
|
-
fi
|
|
27
|
+
agents=(\${(f)"$(tmux-team list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{const j=JSON.parse(s); console.log(Object.keys(j.agents||{}).join("\\n"))}catch{}})')"})
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if (( CURRENT == 2 )); then
|
|
@@ -64,16 +64,14 @@ const bashCompletion = `_tmux_team() {
|
|
|
64
64
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
65
65
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
66
66
|
|
|
67
|
-
commands="talk check list add update remove init completion help"
|
|
67
|
+
commands="talk check list add update remove migrate team init completion help"
|
|
68
68
|
|
|
69
69
|
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
70
70
|
COMPREPLY=( $(compgen -W "\${commands}" -- \${cur}) )
|
|
71
71
|
elif [[ \${COMP_CWORD} -eq 2 ]]; then
|
|
72
72
|
case "\${prev}" in
|
|
73
73
|
talk|check|update|remove|rm)
|
|
74
|
-
|
|
75
|
-
agents=$(node -e "console.log(Object.keys(JSON.parse(require('fs').readFileSync('./tmux-team.json'))).join(' '))" 2>/dev/null)
|
|
76
|
-
fi
|
|
74
|
+
agents=$(tmux-team list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{try{const j=JSON.parse(s); console.log(Object.keys(j.agents||{}).join(" "))}catch{}})')
|
|
77
75
|
if [[ "\${prev}" == "talk" ]]; then
|
|
78
76
|
agents="\${agents} all"
|
|
79
77
|
fi
|
|
@@ -51,6 +51,13 @@ function createCtx(
|
|
|
51
51
|
capture: vi.fn(),
|
|
52
52
|
listPanes: vi.fn(() => []),
|
|
53
53
|
getCurrentPaneId: vi.fn(() => null),
|
|
54
|
+
resolvePaneTarget: vi.fn((target: string) => target),
|
|
55
|
+
getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
|
|
56
|
+
setAgentRegistration: vi.fn(),
|
|
57
|
+
clearAgentRegistration: vi.fn(() => false),
|
|
58
|
+
listTeams: vi.fn(() => ({})),
|
|
59
|
+
listTeamPanes: vi.fn(() => []),
|
|
60
|
+
removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
|
|
54
61
|
};
|
|
55
62
|
return {
|
|
56
63
|
argv: [],
|
|
@@ -167,10 +174,7 @@ describe('cmdConfig', () => {
|
|
|
167
174
|
|
|
168
175
|
it('shows sources in table mode with local settings', () => {
|
|
169
176
|
const ctx = createCtx(testDir);
|
|
170
|
-
fs.writeFileSync(
|
|
171
|
-
ctx.paths.localConfig,
|
|
172
|
-
JSON.stringify({ $config: { mode: 'wait' } })
|
|
173
|
-
);
|
|
177
|
+
fs.writeFileSync(ctx.paths.localConfig, JSON.stringify({ $config: { mode: 'wait' } }));
|
|
174
178
|
cmdConfig(ctx, ['show']);
|
|
175
179
|
expect(ctx.ui.table).toHaveBeenCalled();
|
|
176
180
|
// The table call should include (local) source
|
|
@@ -181,10 +185,7 @@ describe('cmdConfig', () => {
|
|
|
181
185
|
it('shows sources in table mode with global settings', () => {
|
|
182
186
|
const ctx = createCtx(testDir);
|
|
183
187
|
fs.mkdirSync(ctx.paths.globalDir, { recursive: true });
|
|
184
|
-
fs.writeFileSync(
|
|
185
|
-
ctx.paths.globalConfig,
|
|
186
|
-
JSON.stringify({ mode: 'wait' })
|
|
187
|
-
);
|
|
188
|
+
fs.writeFileSync(ctx.paths.globalConfig, JSON.stringify({ mode: 'wait' }));
|
|
188
189
|
cmdConfig(ctx, ['show']);
|
|
189
190
|
expect(ctx.ui.table).toHaveBeenCalled();
|
|
190
191
|
const tableCall = (ctx.ui.table as ReturnType<typeof vi.fn>).mock.calls[0];
|
package/src/commands/config.ts
CHANGED
|
@@ -114,11 +114,7 @@ function showConfig(ctx: Context): void {
|
|
|
114
114
|
['mode', ctx.config.mode, modeSource],
|
|
115
115
|
['preambleMode', ctx.config.preambleMode, preambleSource],
|
|
116
116
|
['preambleEvery', String(ctx.config.defaults.preambleEvery), preambleEverySource],
|
|
117
|
-
[
|
|
118
|
-
'pasteEnterDelayMs',
|
|
119
|
-
String(ctx.config.defaults.pasteEnterDelayMs),
|
|
120
|
-
pasteEnterDelaySource,
|
|
121
|
-
],
|
|
117
|
+
['pasteEnterDelayMs', String(ctx.config.defaults.pasteEnterDelayMs), pasteEnterDelaySource],
|
|
122
118
|
['defaults.timeout', String(ctx.config.defaults.timeout), '(global)'],
|
|
123
119
|
['defaults.pollInterval', String(ctx.config.defaults.pollInterval), '(global)'],
|
|
124
120
|
['defaults.captureLines', String(ctx.config.defaults.captureLines), '(global)'],
|
package/src/commands/help.ts
CHANGED
|
@@ -52,6 +52,8 @@ ${colors.yellow('COMMANDS')}
|
|
|
52
52
|
${colors.green('this')} <name> [remark] Register current pane as an agent
|
|
53
53
|
${colors.green('update')} <name> [options] Update an agent's config
|
|
54
54
|
${colors.green('remove')} <name> Remove an agent
|
|
55
|
+
${colors.green('migrate')} [--dry-run] Copy legacy JSON registry to tmux metadata
|
|
56
|
+
${colors.green('team')} [ls|rm] Inspect pane scopes and manage teams
|
|
55
57
|
${colors.green('install')} [claude|codex] Install tmux-team for an AI agent
|
|
56
58
|
${colors.green('init')} Create empty tmux-team.json
|
|
57
59
|
${colors.green('config')} [show|set|clear] View/modify settings
|
|
@@ -89,9 +91,10 @@ ${colors.yellow('EXAMPLES')}${
|
|
|
89
91
|
tmux-team add codex 10.1 "Code review specialist"
|
|
90
92
|
|
|
91
93
|
${colors.yellow('CONFIG')}
|
|
92
|
-
|
|
94
|
+
Runtime: tmux pane metadata (agent registry)
|
|
95
|
+
Local: ./tmux-team.json (legacy registry + $config override)
|
|
93
96
|
Global: ~/.config/tmux-team/config.json (settings)
|
|
94
|
-
Teams:
|
|
97
|
+
Teams: tmux pane metadata; team ls shows pane cwd + workspace/team scopes
|
|
95
98
|
|
|
96
99
|
${colors.yellow('CHANGE MODE')}
|
|
97
100
|
tmux-team config set mode wait ${colors.dim('Enable wait mode (local)')}
|
|
@@ -26,7 +26,14 @@ function createCtx(testDir: string, overrides?: Partial<{ flags: Partial<Flags>
|
|
|
26
26
|
const config: ResolvedConfig = {
|
|
27
27
|
mode: 'polling',
|
|
28
28
|
preambleMode: 'always',
|
|
29
|
-
defaults: {
|
|
29
|
+
defaults: {
|
|
30
|
+
timeout: 180,
|
|
31
|
+
pollInterval: 1,
|
|
32
|
+
captureLines: 100,
|
|
33
|
+
maxCaptureLines: 2000,
|
|
34
|
+
preambleEvery: 3,
|
|
35
|
+
pasteEnterDelayMs: 500,
|
|
36
|
+
},
|
|
30
37
|
agents: {},
|
|
31
38
|
paneRegistry: {},
|
|
32
39
|
};
|
|
@@ -36,6 +43,13 @@ function createCtx(testDir: string, overrides?: Partial<{ flags: Partial<Flags>
|
|
|
36
43
|
capture: vi.fn(),
|
|
37
44
|
listPanes: vi.fn(() => []),
|
|
38
45
|
getCurrentPaneId: vi.fn(() => null),
|
|
46
|
+
resolvePaneTarget: vi.fn((target: string) => target),
|
|
47
|
+
getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
|
|
48
|
+
setAgentRegistration: vi.fn(),
|
|
49
|
+
clearAgentRegistration: vi.fn(() => false),
|
|
50
|
+
listTeams: vi.fn(() => ({})),
|
|
51
|
+
listTeamPanes: vi.fn(() => []),
|
|
52
|
+
removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
|
|
39
53
|
};
|
|
40
54
|
return {
|
|
41
55
|
argv: [],
|
package/src/commands/list.ts
CHANGED
|
@@ -15,7 +15,9 @@ export function cmdList(ctx: Context): void {
|
|
|
15
15
|
|
|
16
16
|
if (agents.length === 0) {
|
|
17
17
|
if (flags.team) {
|
|
18
|
-
ui.info(
|
|
18
|
+
ui.info(
|
|
19
|
+
`No agents in team "${flags.team}". Use 'tmt this <name> --team ${flags.team}' to add one.`
|
|
20
|
+
);
|
|
19
21
|
} else {
|
|
20
22
|
ui.info("No agents configured. Use 'tmux-team add <name> <pane>' to add one.");
|
|
21
23
|
}
|
|
@@ -26,6 +28,10 @@ export function cmdList(ctx: Context): void {
|
|
|
26
28
|
if (flags.team) {
|
|
27
29
|
console.log(`Team: ${flags.team}`);
|
|
28
30
|
console.log();
|
|
31
|
+
} else if (config.registrySource === 'legacy') {
|
|
32
|
+
ui.warn(
|
|
33
|
+
'Using legacy tmux-team.json registry. Run `tmt migrate` to store registrations in tmux.'
|
|
34
|
+
);
|
|
29
35
|
}
|
|
30
36
|
ui.table(
|
|
31
37
|
['NAME', 'PANE', 'REMARK'],
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// migrate command - copy legacy JSON registry into tmux metadata
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import type { Context, PaneEntry } from '../types.js';
|
|
6
|
+
import { ExitCodes } from '../exits.js';
|
|
7
|
+
import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
|
|
8
|
+
import { getRegistryScope, registrationFromEntry, scopeLabel } from '../registry.js';
|
|
9
|
+
|
|
10
|
+
interface MigrationItem {
|
|
11
|
+
agent: string;
|
|
12
|
+
fromPane: string;
|
|
13
|
+
pane: string;
|
|
14
|
+
remark?: string;
|
|
15
|
+
status: 'ready' | 'migrated';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function cmdMigrate(ctx: Context, args: string[]): void {
|
|
19
|
+
const dryRun = args.includes('--dry-run');
|
|
20
|
+
const cleanup = args.includes('--cleanup');
|
|
21
|
+
const { ui, paths, flags, tmux, exit } = ctx;
|
|
22
|
+
const localConfig = loadLocalConfigFile(paths);
|
|
23
|
+
const scope = getRegistryScope(ctx);
|
|
24
|
+
const items: MigrationItem[] = [];
|
|
25
|
+
|
|
26
|
+
for (const [agentName, rawEntry] of Object.entries(localConfig)) {
|
|
27
|
+
if (agentName === '$config') continue;
|
|
28
|
+
const entry = rawEntry as PaneEntry | undefined;
|
|
29
|
+
if (!entry?.pane) continue;
|
|
30
|
+
|
|
31
|
+
const pane = tmux.resolvePaneTarget(entry.pane);
|
|
32
|
+
if (!pane) {
|
|
33
|
+
ui.error(`Pane '${entry.pane}' for agent '${agentName}' not found. Is tmux running?`);
|
|
34
|
+
exit(ExitCodes.PANE_NOT_FOUND);
|
|
35
|
+
}
|
|
36
|
+
const paneId = pane as string;
|
|
37
|
+
|
|
38
|
+
items.push({
|
|
39
|
+
agent: agentName,
|
|
40
|
+
fromPane: entry.pane,
|
|
41
|
+
pane: paneId,
|
|
42
|
+
...(entry.remark !== undefined && { remark: entry.remark }),
|
|
43
|
+
status: dryRun ? 'ready' : 'migrated',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!dryRun) {
|
|
47
|
+
tmux.setAgentRegistration(paneId, scope, registrationFromEntry(agentName, entry));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!dryRun && cleanup && items.length > 0) {
|
|
52
|
+
for (const item of items) {
|
|
53
|
+
delete localConfig[item.agent];
|
|
54
|
+
}
|
|
55
|
+
saveLocalConfigFile(paths, localConfig);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (flags.json) {
|
|
59
|
+
ui.json({
|
|
60
|
+
dryRun,
|
|
61
|
+
cleanup,
|
|
62
|
+
scope,
|
|
63
|
+
migrated: dryRun ? 0 : items.length,
|
|
64
|
+
items,
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (items.length === 0) {
|
|
70
|
+
ui.info(`No legacy agents found in ${paths.localConfig}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const action = dryRun ? 'Would migrate' : 'Migrated';
|
|
75
|
+
ui.success(`${action} ${items.length} agent(s) to ${scopeLabel(scope)}`);
|
|
76
|
+
ui.table(
|
|
77
|
+
['AGENT', 'FROM', 'PANE', 'REMARK'],
|
|
78
|
+
items.map((item) => [item.agent, item.fromPane, item.pane, item.remark ?? '-'])
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!dryRun && !cleanup) {
|
|
82
|
+
ui.info('Legacy JSON was left in place. Use --cleanup to remove migrated agent entries.');
|
|
83
|
+
}
|
|
84
|
+
}
|