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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// team command - inspect pane team/workspace scope and manage explicit shared teams
|
|
3
|
+
// ─────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
import type { Context, RegistryScope, TeamPaneInfo, TeamPaneRegistration } from '../types.js';
|
|
6
|
+
import { ExitCodes } from '../exits.js';
|
|
7
|
+
import { registrationFromEntry } from '../registry.js';
|
|
8
|
+
|
|
9
|
+
export function cmdTeam(ctx: Context, args: string[]): void {
|
|
10
|
+
const subcommand = args[0];
|
|
11
|
+
|
|
12
|
+
switch (subcommand) {
|
|
13
|
+
case undefined:
|
|
14
|
+
listTeamNames(ctx, []);
|
|
15
|
+
break;
|
|
16
|
+
case 'ls':
|
|
17
|
+
case 'list':
|
|
18
|
+
listTeamMembers(ctx, args.slice(1));
|
|
19
|
+
break;
|
|
20
|
+
case 'add':
|
|
21
|
+
addTeamMember(ctx, args.slice(1));
|
|
22
|
+
break;
|
|
23
|
+
case 'rm':
|
|
24
|
+
case 'remove':
|
|
25
|
+
removeTeam(ctx, args.slice(1));
|
|
26
|
+
break;
|
|
27
|
+
case 'panes':
|
|
28
|
+
case 'inventory':
|
|
29
|
+
listPaneInventory(ctx, args.slice(1));
|
|
30
|
+
break;
|
|
31
|
+
default:
|
|
32
|
+
ctx.ui.error(`Unknown team subcommand: ${subcommand}`);
|
|
33
|
+
ctx.ui.error(
|
|
34
|
+
'Usage: tmux-team team [ls <team>|add <team> <name> [pane]|rm <team> --force|panes]'
|
|
35
|
+
);
|
|
36
|
+
ctx.exit(ExitCodes.ERROR);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listTeamNames(ctx: Context, args: string[]): void {
|
|
41
|
+
const teams = ctx.tmux.listTeams();
|
|
42
|
+
const summaryOnly = args.includes('--summary');
|
|
43
|
+
|
|
44
|
+
if (ctx.flags.json) {
|
|
45
|
+
ctx.ui.json({ teams });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rows = Object.entries(teams).sort(([a], [b]) => a.localeCompare(b));
|
|
50
|
+
if (rows.length === 0) {
|
|
51
|
+
ctx.ui.info('No shared teams found.');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (summaryOnly) {
|
|
56
|
+
ctx.ui.table(
|
|
57
|
+
['TEAM', 'AGENTS'],
|
|
58
|
+
rows.map(([teamName, agents]) => [teamName, agents.join(', ') || '-'])
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ctx.ui.table(
|
|
64
|
+
['TEAM', 'MEMBERS'],
|
|
65
|
+
rows.map(([teamName, agents]) => [teamName, String(agents.length)])
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function listTeamMembers(ctx: Context, args: string[]): void {
|
|
70
|
+
const teamName = args.find((arg) => !arg.startsWith('-'));
|
|
71
|
+
if (!teamName) {
|
|
72
|
+
listTeamNames(ctx, args);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = teamMemberRows(ctx.tmux.listTeamPanes(), teamName);
|
|
77
|
+
if (ctx.flags.json) {
|
|
78
|
+
ctx.ui.json({ team: teamName, members: rows.map(rowToMemberJson) });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (rows.length === 0) {
|
|
83
|
+
ctx.ui.info(
|
|
84
|
+
`No agents in team "${teamName}". Use 'tmt team add ${teamName} <name>' to add one.`
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ctx.ui.table(['NAME', 'PANE', 'TARGET', 'CWD', 'CMD', 'REMARK'], rows);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function addTeamMember(ctx: Context, args: string[]): void {
|
|
93
|
+
const [teamName, name, maybePane, ...remarkParts] = args.filter((arg) => !arg.startsWith('-'));
|
|
94
|
+
if (!teamName || !name) {
|
|
95
|
+
ctx.ui.error('Usage: tmux-team team add <team> <name> [pane] [remark]');
|
|
96
|
+
ctx.exit(ExitCodes.ERROR);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const scope: RegistryScope = { type: 'team', teamName };
|
|
100
|
+
const registry = ctx.tmux.getAgentRegistry(scope);
|
|
101
|
+
if (registry.paneRegistry[name]) {
|
|
102
|
+
ctx.ui.error(
|
|
103
|
+
`Agent '${name}' already exists in team '${teamName}'. Use 'tmux-team update --team ${teamName}' to modify.`
|
|
104
|
+
);
|
|
105
|
+
ctx.exit(ExitCodes.ERROR);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const targetPane = maybePane ?? ctx.tmux.getCurrentPaneId();
|
|
109
|
+
if (!targetPane) {
|
|
110
|
+
ctx.ui.error(
|
|
111
|
+
'Not running inside tmux. Provide a pane target: tmt team add <team> <name> <pane>'
|
|
112
|
+
);
|
|
113
|
+
ctx.exit(ExitCodes.ERROR);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resolvedPane = ctx.tmux.resolvePaneTarget(targetPane);
|
|
117
|
+
if (!resolvedPane) {
|
|
118
|
+
ctx.ui.error(`Pane '${targetPane}' not found. Is tmux running?`);
|
|
119
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const remark = remarkParts.length > 0 ? remarkParts.join(' ') : undefined;
|
|
123
|
+
ctx.tmux.setAgentRegistration(
|
|
124
|
+
resolvedPane,
|
|
125
|
+
scope,
|
|
126
|
+
registrationFromEntry(name, {
|
|
127
|
+
pane: resolvedPane,
|
|
128
|
+
...(remark !== undefined && { remark }),
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (ctx.flags.json) {
|
|
133
|
+
ctx.ui.json({ added: name, team: teamName, pane: resolvedPane, remark });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ctx.ui.success(`Added agent '${name}' to team '${teamName}' at pane ${resolvedPane}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function listPaneInventory(ctx: Context, _args: string[]): void {
|
|
141
|
+
const teams = ctx.tmux.listTeams();
|
|
142
|
+
const panes = ctx.tmux.listTeamPanes();
|
|
143
|
+
|
|
144
|
+
if (ctx.flags.json) {
|
|
145
|
+
ctx.ui.json({ teams, panes });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const groups = panesToGroups(panes);
|
|
150
|
+
if (groups.length === 0) {
|
|
151
|
+
ctx.ui.info('No tmux panes found.');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [index, group] of groups.entries()) {
|
|
156
|
+
if (index > 0) console.log('');
|
|
157
|
+
console.log(group.title);
|
|
158
|
+
ctx.ui.table(['PANE', 'TARGET', 'CWD', 'CMD'], group.rows);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function listPaneStatus(ctx: Context, target: string): void {
|
|
163
|
+
const resolvedPane = resolvePaneLike(ctx, target);
|
|
164
|
+
if (!resolvedPane) {
|
|
165
|
+
ctx.ui.error(`Pane or team '${target}' not found.`);
|
|
166
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const pane = ctx.tmux.listTeamPanes().find((item) => item.pane === resolvedPane);
|
|
170
|
+
if (!pane) {
|
|
171
|
+
ctx.ui.error(`Pane '${target}' not found.`);
|
|
172
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (ctx.flags.json) {
|
|
176
|
+
ctx.ui.json({ pane });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(`Pane: ${pane.pane}${pane.target ? ` (${pane.target})` : ''}`);
|
|
181
|
+
console.log(`CWD: ${pane.cwd ?? '-'}`);
|
|
182
|
+
console.log(`CMD: ${pane.command || '-'}`);
|
|
183
|
+
|
|
184
|
+
if (pane.registrations.length === 0) {
|
|
185
|
+
ctx.ui.info('No registrations on this pane.');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ctx.ui.table(
|
|
190
|
+
['SCOPE', 'NAME', 'REMARK'],
|
|
191
|
+
pane.registrations.map((registration) => [
|
|
192
|
+
registration.scopeType === 'team'
|
|
193
|
+
? `team:${registration.scope}`
|
|
194
|
+
: `workspace:${registration.scope}`,
|
|
195
|
+
registration.agent,
|
|
196
|
+
registration.remark ?? '-',
|
|
197
|
+
])
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolvePaneLike(ctx: Context, target: string): string | null {
|
|
202
|
+
const candidates = [target];
|
|
203
|
+
const dotted = target.match(/^([^.]+)\.([^.]+)\.([^.]+)$/);
|
|
204
|
+
if (dotted) {
|
|
205
|
+
candidates.push(`${dotted[1]}:${dotted[2]}.${dotted[3]}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const candidate of candidates) {
|
|
209
|
+
const resolved = ctx.tmux.resolvePaneTarget(candidate);
|
|
210
|
+
if (resolved) return resolved;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function teamMemberRows(panes: TeamPaneInfo[], teamName: string): string[][] {
|
|
217
|
+
const rows: string[][] = [];
|
|
218
|
+
for (const pane of panes) {
|
|
219
|
+
for (const registration of pane.registrations) {
|
|
220
|
+
if (registration.scopeType !== 'team' || registration.scope !== teamName) continue;
|
|
221
|
+
rows.push([
|
|
222
|
+
registration.agent,
|
|
223
|
+
pane.pane,
|
|
224
|
+
pane.target ?? '-',
|
|
225
|
+
pane.cwd ?? '-',
|
|
226
|
+
pane.command || '-',
|
|
227
|
+
registration.remark ?? '-',
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return rows.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function rowToMemberJson(row: string[]): {
|
|
235
|
+
name: string;
|
|
236
|
+
pane: string;
|
|
237
|
+
target: string;
|
|
238
|
+
cwd: string;
|
|
239
|
+
command: string;
|
|
240
|
+
remark?: string;
|
|
241
|
+
} {
|
|
242
|
+
return {
|
|
243
|
+
name: row[0],
|
|
244
|
+
pane: row[1],
|
|
245
|
+
target: row[2],
|
|
246
|
+
cwd: row[3],
|
|
247
|
+
command: row[4],
|
|
248
|
+
...(row[5] !== '-' && { remark: row[5] }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface PaneGroup {
|
|
253
|
+
key: string;
|
|
254
|
+
title: string;
|
|
255
|
+
agents: Set<string>;
|
|
256
|
+
rows: string[][];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function panesToGroups(panes: TeamPaneInfo[]): PaneGroup[] {
|
|
260
|
+
const groups = new Map<string, PaneGroup>();
|
|
261
|
+
|
|
262
|
+
for (const pane of panes) {
|
|
263
|
+
if (pane.registrations.length === 0) {
|
|
264
|
+
addPaneToGroup(groups, '2:', 'Unregistered panes', pane);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const registration of pane.registrations) {
|
|
269
|
+
addPaneToGroup(
|
|
270
|
+
groups,
|
|
271
|
+
scopeSortKey(registration),
|
|
272
|
+
groupTitle(registration),
|
|
273
|
+
pane,
|
|
274
|
+
registration
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return [...groups.values()]
|
|
280
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
281
|
+
.map((group) => ({
|
|
282
|
+
...group,
|
|
283
|
+
title:
|
|
284
|
+
group.agents.size > 0
|
|
285
|
+
? `${group.title} (${[...group.agents].sort().join(', ')})`
|
|
286
|
+
: group.title,
|
|
287
|
+
rows: group.rows.sort((a, b) => a[1].localeCompare(b[1])),
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function addPaneToGroup(
|
|
292
|
+
groups: Map<string, PaneGroup>,
|
|
293
|
+
key: string,
|
|
294
|
+
title: string,
|
|
295
|
+
pane: TeamPaneInfo,
|
|
296
|
+
registration?: TeamPaneRegistration
|
|
297
|
+
): void {
|
|
298
|
+
const group = groups.get(key) ?? { key, title, agents: new Set<string>(), rows: [] };
|
|
299
|
+
if (registration) group.agents.add(formatAgent(registration));
|
|
300
|
+
group.rows.push([pane.pane, pane.target ?? '-', pane.cwd ?? '-', pane.command || '-']);
|
|
301
|
+
groups.set(key, group);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function scopeSortKey(registration: TeamPaneRegistration): string {
|
|
305
|
+
if (registration.scopeType === 'team') return `0:${registration.scope}`;
|
|
306
|
+
if (registration.scopeType === 'workspace') return `1:${registration.scope}`;
|
|
307
|
+
return '2:';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function groupTitle(registration: TeamPaneRegistration): string {
|
|
311
|
+
if (registration.scopeType === 'team') return `Team: ${registration.scope}`;
|
|
312
|
+
return `Workspace: ${registration.scope}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function formatAgent(registration: TeamPaneRegistration): string {
|
|
316
|
+
return registration.remark
|
|
317
|
+
? `${registration.agent} (${registration.remark})`
|
|
318
|
+
: registration.agent;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function removeTeam(ctx: Context, args: string[]): void {
|
|
322
|
+
const teamName = args.find((arg) => !arg.startsWith('-'));
|
|
323
|
+
const dryRun = args.includes('--dry-run');
|
|
324
|
+
const force = ctx.flags.force || args.includes('--force') || args.includes('-f');
|
|
325
|
+
|
|
326
|
+
if (!teamName) {
|
|
327
|
+
ctx.ui.error('Usage: tmux-team team rm <team> --force');
|
|
328
|
+
ctx.exit(ExitCodes.ERROR);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const teams = ctx.tmux.listTeams();
|
|
332
|
+
const agents = teams[teamName] ?? [];
|
|
333
|
+
if (agents.length === 0) {
|
|
334
|
+
ctx.ui.error(`Team '${teamName}' not found.`);
|
|
335
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (ctx.flags.json && dryRun) {
|
|
339
|
+
ctx.ui.json({ team: teamName, dryRun: true, agents, removed: 0 });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (dryRun) {
|
|
344
|
+
ctx.ui.info(`Would remove team '${teamName}' from ${agents.length} agent(s).`);
|
|
345
|
+
ctx.ui.table(['TEAM', 'AGENTS'], [[teamName, agents.join(', ')]]);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!force) {
|
|
350
|
+
ctx.ui.error(`Refusing to remove team '${teamName}' without --force.`);
|
|
351
|
+
ctx.exit(ExitCodes.ERROR);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const result = ctx.tmux.removeTeam(teamName);
|
|
355
|
+
if (ctx.flags.json) {
|
|
356
|
+
ctx.ui.json({ team: teamName, removed: result.removed, agents: result.agents });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
ctx.ui.success(`Removed team '${teamName}' from ${result.removed} pane(s).`);
|
|
361
|
+
}
|
package/src/commands/update.ts
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
import type { Context, PaneEntry } from '../types.js';
|
|
6
6
|
import { ExitCodes } from '../exits.js';
|
|
7
7
|
import { loadLocalConfigFile, saveLocalConfigFile } from '../config.js';
|
|
8
|
+
import { getRegistryScope, registrationFromEntry } from '../registry.js';
|
|
8
9
|
|
|
9
10
|
export function cmdUpdate(
|
|
10
11
|
ctx: Context,
|
|
11
12
|
name: string,
|
|
12
13
|
options: { pane?: string; remark?: string }
|
|
13
14
|
): void {
|
|
14
|
-
const { ui, config, paths, flags, exit } = ctx;
|
|
15
|
+
const { ui, config, paths, flags, tmux, exit } = ctx;
|
|
15
16
|
|
|
16
17
|
if (!config.paneRegistry[name]) {
|
|
17
18
|
ui.error(`Agent '${name}' not found. Use 'tmux-team add' to create.`);
|
|
@@ -23,22 +24,18 @@ export function cmdUpdate(
|
|
|
23
24
|
exit(ExitCodes.ERROR);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
const localConfig = loadLocalConfigFile(paths);
|
|
28
|
-
|
|
29
|
-
// Handle edge case where local config was modified externally
|
|
30
|
-
let entry = localConfig[name] as PaneEntry | undefined;
|
|
31
|
-
if (!entry) {
|
|
32
|
-
// Fall back to in-memory paneRegistry if entry is missing
|
|
33
|
-
entry = { ...config.paneRegistry[name] };
|
|
34
|
-
localConfig[name] = entry;
|
|
35
|
-
}
|
|
27
|
+
let entry: PaneEntry = { ...config.paneRegistry[name] };
|
|
36
28
|
|
|
37
29
|
const updates: string[] = [];
|
|
38
30
|
|
|
39
31
|
if (options.pane) {
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
const resolvedPane = tmux.resolvePaneTarget(options.pane);
|
|
33
|
+
if (!resolvedPane) {
|
|
34
|
+
ui.error(`Pane '${options.pane}' not found. Is tmux running?`);
|
|
35
|
+
exit(ExitCodes.PANE_NOT_FOUND);
|
|
36
|
+
}
|
|
37
|
+
entry.pane = resolvedPane as string;
|
|
38
|
+
updates.push(`pane → ${entry.pane}`);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
if (options.remark) {
|
|
@@ -46,7 +43,21 @@ export function cmdUpdate(
|
|
|
46
43
|
updates.push(`remark updated`);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
const canonicalPane = tmux.resolvePaneTarget(entry.pane);
|
|
47
|
+
if (!canonicalPane) {
|
|
48
|
+
ui.error(`Pane '${entry.pane}' not found. Is tmux running?`);
|
|
49
|
+
exit(ExitCodes.PANE_NOT_FOUND);
|
|
50
|
+
}
|
|
51
|
+
entry.pane = canonicalPane as string;
|
|
52
|
+
|
|
53
|
+
const scope = getRegistryScope(ctx);
|
|
54
|
+
const updatedLegacy = updateLegacyEntryIfPresent(paths, name, entry);
|
|
55
|
+
const removed = options.pane ? tmux.clearAgentRegistration(name, scope) : false;
|
|
56
|
+
tmux.setAgentRegistration(entry.pane, scope, registrationFromEntry(name, entry));
|
|
57
|
+
|
|
58
|
+
if (updatedLegacy && removed) {
|
|
59
|
+
pruneLegacyEntry(paths, name);
|
|
60
|
+
}
|
|
50
61
|
|
|
51
62
|
if (flags.json) {
|
|
52
63
|
ui.json({ updated: name, ...options });
|
|
@@ -56,3 +67,23 @@ export function cmdUpdate(
|
|
|
56
67
|
}
|
|
57
68
|
}
|
|
58
69
|
}
|
|
70
|
+
|
|
71
|
+
function updateLegacyEntryIfPresent(
|
|
72
|
+
paths: Context['paths'],
|
|
73
|
+
name: string,
|
|
74
|
+
entry: PaneEntry
|
|
75
|
+
): boolean {
|
|
76
|
+
const localConfig = loadLocalConfigFile(paths);
|
|
77
|
+
if (!localConfig[name]) return false;
|
|
78
|
+
|
|
79
|
+
localConfig[name] = { ...entry };
|
|
80
|
+
saveLocalConfigFile(paths, localConfig);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pruneLegacyEntry(paths: Context['paths'], name: string): void {
|
|
85
|
+
const localConfig = loadLocalConfigFile(paths);
|
|
86
|
+
if (!localConfig[name]) return;
|
|
87
|
+
delete localConfig[name];
|
|
88
|
+
saveLocalConfigFile(paths, localConfig);
|
|
89
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -246,6 +246,30 @@ describe('loadConfig', () => {
|
|
|
246
246
|
expect(config.paneRegistry.codex?.pane).toBe('1.1');
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
it('overlays tmux registry on top of legacy local entries', () => {
|
|
250
|
+
const localConfig = {
|
|
251
|
+
claude: { pane: '1.0', remark: 'legacy' },
|
|
252
|
+
codex: { pane: '1.1', remark: 'legacy codex' },
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => p === mockPaths.localConfig);
|
|
256
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(localConfig));
|
|
257
|
+
|
|
258
|
+
const config = loadConfig(mockPaths, {
|
|
259
|
+
paneRegistry: {
|
|
260
|
+
claude: { pane: '%9', remark: 'tmux' },
|
|
261
|
+
},
|
|
262
|
+
agents: {
|
|
263
|
+
claude: { preamble: 'from tmux' },
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(config.registrySource).toBe('tmux');
|
|
268
|
+
expect(config.paneRegistry.claude).toEqual({ pane: '%9', remark: 'tmux' });
|
|
269
|
+
expect(config.paneRegistry.codex).toEqual({ pane: '1.1', remark: 'legacy codex' });
|
|
270
|
+
expect(config.agents.claude?.preamble).toBe('from tmux');
|
|
271
|
+
});
|
|
272
|
+
|
|
249
273
|
it('merges both global and local config (agents from local only)', () => {
|
|
250
274
|
const globalConfig = {
|
|
251
275
|
mode: 'wait',
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
LocalSettings,
|
|
13
13
|
ResolvedConfig,
|
|
14
14
|
Paths,
|
|
15
|
+
TmuxRegistry,
|
|
15
16
|
} from './types.js';
|
|
16
17
|
|
|
17
18
|
const CONFIG_FILENAME = 'config.json';
|
|
@@ -103,6 +104,27 @@ function findUpward(filename: string, startDir: string): string | null {
|
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the workspace scope used for default registrations.
|
|
109
|
+
*
|
|
110
|
+
* Prefer the nearest Git root so commands run from subdirectories share the
|
|
111
|
+
* same default team. Fall back to cwd for non-Git folders.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveWorkspaceRoot(cwd: string = process.cwd()): string {
|
|
114
|
+
let dir = path.resolve(cwd);
|
|
115
|
+
while (true) {
|
|
116
|
+
const gitPath = path.join(dir, '.git');
|
|
117
|
+
if (fs.existsSync(gitPath)) {
|
|
118
|
+
return dir;
|
|
119
|
+
}
|
|
120
|
+
const parent = path.dirname(dir);
|
|
121
|
+
if (parent === dir) {
|
|
122
|
+
return path.resolve(cwd);
|
|
123
|
+
}
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
/**
|
|
107
129
|
* Get the path to a shared team config file.
|
|
108
130
|
*/
|
|
@@ -122,6 +144,7 @@ export function ensureTeamsDir(globalDir: string): void {
|
|
|
122
144
|
|
|
123
145
|
export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Paths {
|
|
124
146
|
const globalDir = resolveGlobalDir();
|
|
147
|
+
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
|
125
148
|
|
|
126
149
|
// If team name is specified, use the shared team config
|
|
127
150
|
if (teamName) {
|
|
@@ -131,6 +154,7 @@ export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Pa
|
|
|
131
154
|
globalConfig: path.join(globalDir, CONFIG_FILENAME),
|
|
132
155
|
localConfig: teamConfig,
|
|
133
156
|
stateFile: path.join(globalDir, STATE_FILENAME),
|
|
157
|
+
workspaceRoot,
|
|
134
158
|
};
|
|
135
159
|
}
|
|
136
160
|
|
|
@@ -143,6 +167,7 @@ export function resolvePaths(cwd: string = process.cwd(), teamName?: string): Pa
|
|
|
143
167
|
globalConfig: path.join(globalDir, CONFIG_FILENAME),
|
|
144
168
|
localConfig: localConfigPath,
|
|
145
169
|
stateFile: path.join(globalDir, STATE_FILENAME),
|
|
170
|
+
workspaceRoot,
|
|
146
171
|
};
|
|
147
172
|
}
|
|
148
173
|
|
|
@@ -177,13 +202,14 @@ function loadJsonFile<T>(filePath: string): T | null {
|
|
|
177
202
|
*
|
|
178
203
|
* Note: CLI flags are applied by the caller after this function returns.
|
|
179
204
|
*/
|
|
180
|
-
export function loadConfig(paths: Paths): ResolvedConfig {
|
|
205
|
+
export function loadConfig(paths: Paths, tmuxRegistry?: TmuxRegistry): ResolvedConfig {
|
|
181
206
|
// Start with defaults
|
|
182
207
|
const config: ResolvedConfig = {
|
|
183
208
|
...DEFAULT_CONFIG,
|
|
184
209
|
defaults: { ...DEFAULT_CONFIG.defaults },
|
|
185
210
|
agents: {},
|
|
186
211
|
paneRegistry: {},
|
|
212
|
+
registrySource: 'none',
|
|
187
213
|
};
|
|
188
214
|
|
|
189
215
|
// Merge global config (mode, preambleMode, defaults only)
|
|
@@ -196,8 +222,9 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
196
222
|
}
|
|
197
223
|
}
|
|
198
224
|
|
|
199
|
-
// Load local config (pane registry + optional settings + agent config)
|
|
200
|
-
//
|
|
225
|
+
// Load local config (legacy pane registry + optional settings + agent config).
|
|
226
|
+
// Runtime agent registration now prefers tmux pane metadata; local pane entries
|
|
227
|
+
// remain as a compatibility fallback when no tmux metadata exists for the scope.
|
|
201
228
|
const localConfigFile = loadJsonFile<LocalConfigFile>(paths.localConfig);
|
|
202
229
|
if (localConfigFile) {
|
|
203
230
|
// Extract local settings if present
|
|
@@ -222,6 +249,7 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
222
249
|
// Add to pane registry if has valid pane field
|
|
223
250
|
if (paneEntry.pane) {
|
|
224
251
|
config.paneRegistry[agentName] = paneEntry;
|
|
252
|
+
config.registrySource = 'legacy';
|
|
225
253
|
}
|
|
226
254
|
|
|
227
255
|
// Build agents config from preamble/deny fields
|
|
@@ -237,6 +265,12 @@ export function loadConfig(paths: Paths): ResolvedConfig {
|
|
|
237
265
|
}
|
|
238
266
|
}
|
|
239
267
|
|
|
268
|
+
if (tmuxRegistry && Object.keys(tmuxRegistry.paneRegistry).length > 0) {
|
|
269
|
+
config.paneRegistry = { ...config.paneRegistry, ...tmuxRegistry.paneRegistry };
|
|
270
|
+
config.agents = { ...config.agents, ...tmuxRegistry.agents };
|
|
271
|
+
config.registrySource = 'tmux';
|
|
272
|
+
}
|
|
273
|
+
|
|
240
274
|
return config;
|
|
241
275
|
}
|
|
242
276
|
|
package/src/context.test.ts
CHANGED
|
@@ -18,7 +18,14 @@ describe('createContext', () => {
|
|
|
18
18
|
const config: ResolvedConfig = {
|
|
19
19
|
mode: 'polling',
|
|
20
20
|
preambleMode: 'always',
|
|
21
|
-
defaults: {
|
|
21
|
+
defaults: {
|
|
22
|
+
timeout: 180,
|
|
23
|
+
pollInterval: 1,
|
|
24
|
+
captureLines: 100,
|
|
25
|
+
maxCaptureLines: 2000,
|
|
26
|
+
preambleEvery: 3,
|
|
27
|
+
pasteEnterDelayMs: 500,
|
|
28
|
+
},
|
|
22
29
|
agents: {},
|
|
23
30
|
paneRegistry: {},
|
|
24
31
|
};
|
|
@@ -35,6 +42,13 @@ describe('createContext', () => {
|
|
|
35
42
|
capture: vi.fn(),
|
|
36
43
|
listPanes: vi.fn(() => []),
|
|
37
44
|
getCurrentPaneId: vi.fn(() => null),
|
|
45
|
+
resolvePaneTarget: vi.fn((target: string) => target),
|
|
46
|
+
getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
|
|
47
|
+
setAgentRegistration: vi.fn(),
|
|
48
|
+
clearAgentRegistration: vi.fn(() => false),
|
|
49
|
+
listTeams: vi.fn(() => ({})),
|
|
50
|
+
listTeamPanes: vi.fn(() => []),
|
|
51
|
+
removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
|
|
38
52
|
};
|
|
39
53
|
|
|
40
54
|
vi.doMock('./config.js', () => ({
|
|
@@ -54,6 +68,67 @@ describe('createContext', () => {
|
|
|
54
68
|
expect(ctx.tmux).toBe(tmux);
|
|
55
69
|
});
|
|
56
70
|
|
|
71
|
+
it('uses team registry scope when --team is set', async () => {
|
|
72
|
+
vi.resetModules();
|
|
73
|
+
|
|
74
|
+
const paths: Paths = {
|
|
75
|
+
globalDir: '/g',
|
|
76
|
+
globalConfig: '/g/config.json',
|
|
77
|
+
localConfig: '/g/teams/egp.json',
|
|
78
|
+
stateFile: '/g/state.json',
|
|
79
|
+
workspaceRoot: '/repo',
|
|
80
|
+
};
|
|
81
|
+
const config: ResolvedConfig = {
|
|
82
|
+
mode: 'polling',
|
|
83
|
+
preambleMode: 'always',
|
|
84
|
+
defaults: {
|
|
85
|
+
timeout: 180,
|
|
86
|
+
pollInterval: 1,
|
|
87
|
+
captureLines: 100,
|
|
88
|
+
maxCaptureLines: 2000,
|
|
89
|
+
preambleEvery: 3,
|
|
90
|
+
pasteEnterDelayMs: 500,
|
|
91
|
+
},
|
|
92
|
+
agents: {},
|
|
93
|
+
paneRegistry: {},
|
|
94
|
+
};
|
|
95
|
+
const tmux: Tmux = {
|
|
96
|
+
send: vi.fn(),
|
|
97
|
+
capture: vi.fn(),
|
|
98
|
+
listPanes: vi.fn(() => []),
|
|
99
|
+
getCurrentPaneId: vi.fn(() => null),
|
|
100
|
+
resolvePaneTarget: vi.fn((target: string) => target),
|
|
101
|
+
getAgentRegistry: vi.fn(() => ({ paneRegistry: {}, agents: {} })),
|
|
102
|
+
setAgentRegistration: vi.fn(),
|
|
103
|
+
clearAgentRegistration: vi.fn(() => false),
|
|
104
|
+
listTeams: vi.fn(() => ({})),
|
|
105
|
+
listTeamPanes: vi.fn(() => []),
|
|
106
|
+
removeTeam: vi.fn(() => ({ removed: 0, agents: [] })),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
vi.doMock('./config.js', () => ({
|
|
110
|
+
resolvePaths: () => paths,
|
|
111
|
+
loadConfig: () => config,
|
|
112
|
+
}));
|
|
113
|
+
vi.doMock('./ui.js', () => ({
|
|
114
|
+
createUI: () => ({
|
|
115
|
+
info: vi.fn(),
|
|
116
|
+
success: vi.fn(),
|
|
117
|
+
warn: vi.fn(),
|
|
118
|
+
error: vi.fn(),
|
|
119
|
+
table: vi.fn(),
|
|
120
|
+
json: vi.fn(),
|
|
121
|
+
}),
|
|
122
|
+
}));
|
|
123
|
+
vi.doMock('./tmux.js', () => ({ createTmux: () => tmux }));
|
|
124
|
+
|
|
125
|
+
const { createContext } = await import('./context.js');
|
|
126
|
+
const ctx = createContext({ argv: [], flags: { json: false, verbose: false, team: 'egp' } });
|
|
127
|
+
|
|
128
|
+
expect(ctx.registryScope).toEqual({ type: 'team', teamName: 'egp' });
|
|
129
|
+
expect(tmux.getAgentRegistry).toHaveBeenCalledWith({ type: 'team', teamName: 'egp' });
|
|
130
|
+
});
|
|
131
|
+
|
|
57
132
|
it('ctx.exit calls process.exit', async () => {
|
|
58
133
|
vi.resetModules();
|
|
59
134
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|